@volter/supercode-teams 0.2.78 → 0.2.80

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.
@@ -27,6 +27,9 @@ const sameOwner = owner => {
27
27
  try { process.kill(owner.pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
28
28
  };
29
29
 
30
+ /** Whether the process holding this state directory's lifecycle lock is still the one that took it. */
31
+ export function lifecycleHeld(directory) { return sameOwner(readOwner(join(directory, 'lifecycle.lock'))); }
32
+
30
33
  export async function activeSocket(path) {
31
34
  return new Promise(resolve => {
32
35
  const socket = createConnection(path);
package/bin/workspace.mjs CHANGED
@@ -17,7 +17,7 @@ import { teamsHome, paths } from '@volter/teams/home';
17
17
  import { loadOrCreateKeyPair, publicKeyId } from '@volter/teams/identity';
18
18
  import { duration, parseArgs, positiveInteger, requireArgs, usage } from './workspace/args.mjs';
19
19
  import { reserveArtifactDirectory, reserveProtected, writeProtected } from './workspace/files.mjs';
20
- import { acquireLifecycle, activeSocket, requestStop } from './workspace/lifecycle.mjs';
20
+ import { acquireLifecycle, activeSocket, lifecycleHeld, requestStop } from './workspace/lifecycle.mjs';
21
21
  import { volterDeviceSignIn } from './workspace/volter-device.mjs';
22
22
  import { loginSummary, signInToTeams } from './workspace/volter-login.mjs';
23
23
  import { readSharedSignIn, signOut } from '@volter/identity';
@@ -653,12 +653,14 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
653
653
  if (verb === 'status') {
654
654
  let reachable = true; let account = null; try { account = await client.me(); } catch { reachable = false; }
655
655
  const saved = state.read(); const running = saved?.process?.socket ? await activeSocket(saved.process.socket) : false;
656
+ // A connector deep in a large capture can miss the probe's second while its process lives on: it is busy, not stopped.
657
+ const busy = !running && Boolean(saved?.process) && lifecycleHeld(state.directory);
656
658
  // What the connector could not sync, as it last wrote it to its state (it is written while it runs).
657
659
  const sync = saved ? readSyncStatus(state.directory) ?? { not_syncing: [], last_error: null, queued_captures: 0 } : null;
658
- const value = { version: 1, context: scope(context), data: { reachable, account, connector: saved ? { enrolled: true, running, machine: saved.machine, source: saved.source, sync } : { enrolled: false, running: false }, sources: reachable ? (await team.sources.list()).items : [] } };
660
+ const value = { version: 1, context: scope(context), data: { reachable, account, connector: saved ? { enrolled: true, running: running || busy, ...(busy ? { busy: true } : {}), machine: saved.machine, source: saved.source, sync } : { enrolled: false, running: false }, sources: reachable ? (await team.sources.list()).items : [] } };
659
661
  if (flags.json) return output(value);
660
662
  const lines = [`Team ${context.team_name ?? context.team_id} · ${new URL(context.origin).host}${reachable ? '' : ' · unreachable'}`];
661
- lines.push(saved?.machine ? `Connector ${saved.machine.name ?? saved.machine.id} · ${running ? 'running' : 'not running'}${saved.service?.kind ? ` (${saved.service.kind})` : ''}` : 'Connector not connected (supercode teams connect)');
663
+ lines.push(saved?.machine ? `Connector ${saved.machine.name ?? saved.machine.id} · ${running ? 'running' : busy ? 'running (busy: its control socket did not answer within 1 s)' : 'not running'}${saved.service?.kind ? ` (${saved.service.kind})` : ''}` : 'Connector not connected (supercode teams connect)');
662
664
  lines.push(...syncStatusLines(sync));
663
665
  process.stdout.write(`${lines.join('\n')}\n`);
664
666
  return;
@@ -52,6 +52,20 @@ function settledToolTail(previous,messages){
52
52
  return messages.slice(0,count).map(digestOf);
53
53
  }
54
54
  const hash=value=>createHash('sha256').update(value).digest('hex');
55
+ // A capture part is one request, and the server refuses a body over 8 MiB (volter-teams request-json.mjs): a part
56
+ // holds at most PART_MESSAGES messages (the contract's bound) and PART_BYTES of them, so a run of large tool results
57
+ // splits across parts instead of failing the whole session. A single message larger than that goes alone.
58
+ const PART_MESSAGES=500,PART_BYTES=6*1024*1024;
59
+ function captureParts(captureId,messages){
60
+ const parts=[];let current=[],bytes=0;
61
+ for(const message of messages){
62
+ const size=Buffer.byteLength(JSON.stringify(message));
63
+ if(current.length&&(current.length>=PART_MESSAGES||bytes+size>PART_BYTES)){parts.push({capture_id:captureId,index:parts.length,messages:current});current=[];bytes=0;}
64
+ current.push(message);bytes+=size;
65
+ }
66
+ if(current.length)parts.push({capture_id:captureId,index:parts.length,messages:current});
67
+ return parts;
68
+ }
55
69
  const CHUNK_BYTES=1024*1024;
56
70
  // A native file at least NATIVE_IDLE_BYTES long gets its native bundle only once it has gone NATIVE_IDLE_MS
57
71
  // without a write. Translating reads the whole file, takes minutes for a 100 MB transcript, and holds the
@@ -425,7 +439,8 @@ export class TeamsConnector {
425
439
  const artifactBefore=path?statSync(path):null;
426
440
  options={...options,preloaded:{loaded:await (options.live?this.liveCore:this.core).session(locator).load(),artifactBefore}};
427
441
  }
428
- const work=this.work.then(()=>this.captureSerial(options)).then(receipt=>{this.clearSessionProblem(options.harness,options.nativeSessionId);return receipt;});
442
+ // A capture the server refused and the outbox dropped stays that session's problem; any other capture clears it.
443
+ const work=this.work.then(()=>this.captureSerial(options)).then(receipt=>{if(!this.droppedCaptures?.delete(`${options.harness}:${options.nativeSessionId}`))this.clearSessionProblem(options.harness,options.nativeSessionId);return receipt;});
429
444
  this.work=work.catch(()=>{});
430
445
  return work;
431
446
  }
@@ -565,7 +580,7 @@ export class TeamsConnector {
565
580
  const carried=this.carriedPrefix(effectivePrevious,messages,{ruleRevision,backfillMode:backfillPolicy.mode,clearBaseline});
566
581
  if(carried)begin.carry={count:carried,digest:carryDigest(messages.slice(0,carried))};
567
582
  const sent=carried?messages.slice(carried):messages;
568
- const parts=[];for(let index=0;index<sent.length;index+=500)parts.push({capture_id:captureId,index:parts.length,messages:sent.slice(index,index+500)});
583
+ const parts=captureParts(captureId,sent);
569
584
  let artifacts=[];
570
585
  // Native stores normally live in harness homes outside the repository. The locator is
571
586
  // accepted only when it came from this configured core discovery, and raw bytes are safe
@@ -662,6 +677,16 @@ export class TeamsConnector {
662
677
  item.artifacts=[];item.commit.artifacts=[];delete item.commit.native_bundle;item.nativeDeferred=true;
663
678
  const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.save();
664
679
  }
680
+ // A capture queued before parts were bounded by bytes can hold a part the server refuses as too large, and the
681
+ // outbox is flushed in order: it is split again, under a new capture id (the server drops the old reservation of
682
+ // the same revision) and its digest resealed, so it neither fails nor holds every later capture behind it.
683
+ refitParts(item){
684
+ if(item.parts.every(part=>part.messages.length<=PART_MESSAGES&&Buffer.byteLength(JSON.stringify(part))<=PART_BYTES+1024*1024))return;
685
+ const captureId=`cap_${randomBytes(16).toString('hex')}`;
686
+ item.begin={...item.begin,capture_id:captureId};item.parts=captureParts(captureId,item.parts.flatMap(part=>part.messages));
687
+ item.commit={...item.commit,capture_id:captureId,parts:item.parts.length};
688
+ const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.save();
689
+ }
665
690
  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);
666
691
  // A capture queued by an older connector holds its chunks inline; a current one reads them from its spill file.
667
692
  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);}
@@ -677,6 +702,6 @@ export class TeamsConnector {
677
702
  if(!Array.isArray(configuration?.rules))return false;
678
703
  return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
679
704
  }
680
- 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(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'){this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused 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();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
705
+ 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();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
681
706
  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();}
682
707
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.78",
3
+ "version": "0.2.80",
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",