@volter/supercode-teams 0.3.157 → 0.3.158

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.
package/README.md CHANGED
@@ -61,11 +61,19 @@ the source boundary and the remaining runtime qualification.
61
61
  The connector's redaction memo keeps serialized redacted JSON, with at most
62
62
  200,000 entries and 32 MiB of accounted string payload (two bytes per code unit,
63
63
  including keys and digests). An entry over 1 MiB is not retained. Oldest entries
64
- are evicted to fit; cache misses still go through the redaction worker. These are
64
+ are evicted to fit; cache misses use a held digest observed under the same checked
65
+ redaction basis, or go through the redaction worker. These are
65
66
  memo bounds, not total connector memory limits. The
66
67
  [source investigation](../../docs/evidence/connector-redaction-memo-2026-10-08.md)
67
68
  does not attribute the observed process peak or claim a measured runtime improvement.
68
69
 
70
+ Memo entries are discarded when the observed custody/rules basis changes. A
71
+ capture and its worker use one matching custody snapshot; a basis mismatch
72
+ rejects the capture before queueing and leaves it owed for retry. Older basis
73
+ labels cannot authorize held-digest reuse. Custody still refreshes on its existing
74
+ 30-second interval; queued and published captures are not retroactively redacted.
75
+ [Source boundary and qualification](../../docs/evidence/connector-redaction-basis-2026-10-08.md).
76
+
69
77
  `supercode teams rpc mail.statistics` reads local storage counts and import/retention receipts. It is refused remotely
70
78
  and through the app door. Writer groups count stored envelope copies, not call attempts or quotas; database page
71
79
  counts exclude physical WAL size. Successful imports record their actual source reads, bytes, batch sizes and time.
@@ -46,6 +46,7 @@ const covers=(root,cwd)=>{
46
46
  };
47
47
  // An error of the connection itself (the outbox, authentication, the server), not of one session.
48
48
  const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
49
+ const retryCapture=error=>connectionError(error)||error?.code==='redaction_basis_changed';
49
50
  const sameError=(a,b)=>a!=null&&b!=null&&a.code===b.code&&a.message===b.message;
50
51
  const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='string'?error.code:null,message:String(error.message??error).slice(0,500),at};
51
52
  /** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
@@ -105,12 +106,21 @@ export function foldConnectorState(stateDir){
105
106
  // observed, a baseline, the carried prefix) is of that form: a digest of the local text named a prefix the server never
106
107
  // held, every carry was refused, and each session holding a secret went whole on every pass. A message is redacted
107
108
  // in the connector's redaction worker, with a bounded digest memo: no body for one without a secret (most), serialized
108
- // redacted JSON for one with. Evicted or oversized values are redacted again. Each loaded message's digest (of the form
109
+ // redacted JSON for one with. A miss uses the same-basis held-digest proof or redacts again. Each loaded message's digest (of the form
109
110
  // sent) is taken once and read where needed. The byte budget counts two bytes per retained string code unit, including
110
111
  // keys and digests; the entry bound separately limits Map/record overhead. Neither is a total-process heap bound.
111
112
  const REDACTED_MEMO=200000,REDACTED_MEMO_BYTES=32*1024*1024,REDACTED_MEMO_ENTRY_BYTES=1024*1024;
112
113
  const redactions=new Map();
113
- let redactionBytes=0;
114
+ let redactionBytes=0,redactionMemoBasis=null;
115
+ function useRedactionMemo(basis){
116
+ if(redactionMemoBasis!==basis){redactions.clear();redactionBytes=0;redactionMemoBasis=basis;}
117
+ }
118
+ function requireRedactionBasis(basis){
119
+ if(redactionBasis()!==basis)throw Object.assign(new Error('redaction basis changed; capture needs fresh redaction'),{code:'redaction_basis_changed'});
120
+ }
121
+ // Older queued/committed captures could label a result with the basis read after its worker returned. They do not
122
+ // establish the checked observation needed for held-digest reuse, even when that old label equals today's basis.
123
+ const checkedRedactionBasis=basis=>`checked-v1:${basis}`;
114
124
  const sentDigests=new WeakMap();
115
125
  const digestOf=message=>{let digest=sentDigests.get(message);if(digest===undefined){digest=hash(canonicalJson(message.value));sentDigests.set(message,digest);}return digest;};
116
126
  function remember(digest,redacted){
@@ -660,9 +670,8 @@ export class TeamsConnector {
660
670
  if(this.unreadable?.has(key))captured=false;
661
671
  else if(changed&&!this.predatesWindow(options))try{await this.capture({...options,live:Boolean(this.watched?.has(key))});}catch(error){
662
672
  if(this.abort.signal.aborted)return;
663
- // An error of the connection itself (the outbox, authentication, the server): the outbox's own retry takes it, and
664
- // this session's capture stays owed until a flush passes (it may have failed before it was queued)
665
- if(connectionError(error)){this.oweCapture(options,error);return;}
673
+ // A connection failure or a changed redaction basis keeps this session owed; the existing retry wake re-reads it.
674
+ if(retryCapture(error)){this.oweCapture(options,error);return;}
666
675
  captured=false;this.noteSessionProblem(options,error);
667
676
  if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
668
677
  }
@@ -892,8 +901,9 @@ export class TeamsConnector {
892
901
  // Each loaded message in the form sent: the memo's; the loaded one itself when the server holds it unchanged under
893
902
  // this redaction basis (its digest is among those last observed); else what the redaction worker makes of it, all of
894
903
  // a capture's new messages in one request. A digest is taken once per message, of the form sent.
895
- async sentMessages(raw,previous){
896
- const basis=redactionBasis(),held=previous?.redaction_basis===basis&&previous?.observed_digests?.length?new Set(previous.observed_digests):null;
904
+ async sentMessages(raw,previous,basis=redactionBasis()){
905
+ requireRedactionBasis(basis);useRedactionMemo(basis);
906
+ const held=previous?.redaction_basis===checkedRedactionBasis(basis)&&previous?.observed_digests?.length?new Set(previous.observed_digests):null;
897
907
  const out=new Array(raw.length),missing=[];
898
908
  raw.forEach((message,index)=>{
899
909
  const text=canonicalJson(message.value),digest=hash(text);
@@ -905,8 +915,10 @@ export class TeamsConnector {
905
915
  this.redactor??=new Redactor();
906
916
  if(!this.redactionCounted){this.redactionCounted=true;this.syncStatus().redaction={since:new Date(this.clock.nowMs()).toISOString(),batches:0,messages:0,bytes:0,worker_ms:0,main_ms:0,max_main_ms:0};}
907
917
  // The worker is sent each new message's canonical JSON and answers only those redaction changed.
908
- let started=performance.now();const texts=missing.map(([,,text])=>text),pending=this.redactor.run('canonical',texts);let mainMs=performance.now()-started;
918
+ let started=performance.now();const texts=missing.map(([,,text])=>text),pending=this.redactor.run('canonical',texts,{basis});let mainMs=performance.now()-started;
909
919
  const changed=await pending;started=performance.now();
920
+ // Another connector/capture can run while this worker answers. Never store its result under a later basis.
921
+ requireRedactionBasis(basis);useRedactionMemo(basis);
910
922
  for(const [index,digest] of missing){remember(digest,null);out[index]=raw[index];sentDigests.set(raw[index],digest);}
911
923
  for(const {index:at,value,digest:sent} of changed){
912
924
  const [index,digest]=missing[at];
@@ -920,13 +932,14 @@ export class TeamsConnector {
920
932
  cost.batches+=1;cost.messages+=missing.length;cost.bytes+=texts.reduce((n,text)=>n+text.length,0);
921
933
  cost.worker_ms=Math.round(this.redactor.workerMs);cost.main_ms=Math.round((cost.main_ms+mainMs)*10)/10;cost.max_main_ms=Math.max(cost.max_main_ms,Math.round(mainMs*10)/10);
922
934
  }
923
- return out;
935
+ requireRedactionBasis(basis);return out;
924
936
  }
925
- async spillTranslated(translated,defaultName){
937
+ async spillTranslated(translated,defaultName,basis){
926
938
  const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
927
939
  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'});
928
940
  let total=0;const spilled=[];
929
- this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content));
941
+ this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content),{basis});
942
+ if(basis!==undefined)requireRedactionBasis(basis);
930
943
  for(const [index,file] of files.entries()){const bytes=Buffer.from(texts[index],'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)});}
931
944
  return {fidelity:translated.fidelity,residue:translated.residue??[],files:spilled};
932
945
  }
@@ -956,7 +969,7 @@ export class TeamsConnector {
956
969
  const wait=this.watched?.has(key)||options.paced?0:CAPTURE_FLOOR_MS-(now-(this.lastCaptures.get(key)??0));
957
970
  // one retry wake per session, holding each rule's latest obligation: a newer generation of a rule replaces that
958
971
  // rule's options, never another rule's; the wake runs every rule held, paced as one
959
- if(wait>0){this.holdObligation(this.retryOptions??=new Map(),key,options);if(!this.captureRetry.has(key))this.captureRetry.set(key,setTimeout(()=>{this.captureRetry.delete(key);for(const latest of this.takeObligations(this.retryOptions,key))this.capture({...latest,paced:true}).catch(error=>{if(connectionError(error))this.oweCapture(latest,error);else this.lastError=error;});},wait).unref?.());return null;}
972
+ if(wait>0){this.holdObligation(this.retryOptions??=new Map(),key,options);if(!this.captureRetry.has(key))this.captureRetry.set(key,setTimeout(()=>{this.captureRetry.delete(key);for(const latest of this.takeObligations(this.retryOptions,key))this.capture({...latest,paced:true}).catch(error=>{if(retryCapture(error))this.oweCapture(latest,error);else this.lastError=error;});},wait).unref?.());return null;}
960
973
  this.lastCaptures.set(key,now);
961
974
  const {locator}=options,path=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
962
975
  const artifactBefore=path?statSync(path):null,anchor=this.codexAnchor(key,artifactBefore);
@@ -1080,7 +1093,7 @@ export class TeamsConnector {
1080
1093
  if(this.abort.signal.aborted)return;
1081
1094
  // A load that failed or timed out waits like a slow one, so it does not hold its lane on every pass.
1082
1095
  if(started!==null)this.largeCaptures.set(key,{at:started,ms:this.clock.nowMs()-started});
1083
- if(connectionError(error)){this.oweCapture(options,error);continue;}
1096
+ if(retryCapture(error)){this.oweCapture(options,error);continue;}
1084
1097
  this.noteSessionProblem(options,error);
1085
1098
  if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${options.harness} session ${options.nativeSessionId} not synced: ${error.message}\n`);
1086
1099
  }
@@ -1127,13 +1140,13 @@ export class TeamsConnector {
1127
1140
  }
1128
1141
  // The turns typed through a door here are labelled before redaction, which then reads them as sent.
1129
1142
  const raw=(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};});this.labelTurns(key,loaded.messages??loaded.turns??[],raw);
1130
- let messages=await this.sentMessages(raw,previous);const loadedMessages=messages,basis=redactionBasis();
1143
+ const basis=redactionBasis();let messages=await this.sentMessages(raw,previous,basis);const loadedMessages=messages;
1131
1144
  if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
1132
1145
  // A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
1133
1146
  const nativeWanted=configuration.native_capture!==false&&backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
1134
1147
  const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
1135
1148
  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]&&digestOf(message)===previous.observed_digests?.[index]);
1136
- if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
1149
+ if(unchanged&&!retryNative){requireRedactionBasis(basis);this.origins[key]={...previous,redaction_basis:checkedRedactionBasis(basis)};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);await this.publishSessionProfile(key);return null;}
1137
1150
  let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
1138
1151
  // A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
1139
1152
  // whatever a session holds when first seen: a session begun after the rule is published from its first message.
@@ -1199,9 +1212,9 @@ export class TeamsConnector {
1199
1212
  // translated for this capture alone: its spilled files live while the capture is queued (outbox-only)
1200
1213
  try{
1201
1214
  const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
1202
- if(fileStamp(statSync(artifactSource))===onDisk)bundle={stamp:onDisk,...await this.spillTranslated(translated,basename(artifactSource))};
1215
+ if(fileStamp(statSync(artifactSource))===onDisk)bundle={stamp:onDisk,...await this.spillTranslated(translated,basename(artifactSource),basis)};
1203
1216
  }catch(error){
1204
- if(this.abort.signal.aborted)throw error;
1217
+ if(this.abort.signal.aborted||error?.code==='redaction_basis_changed')throw error;
1205
1218
  this.lastError=error;process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} published without its native bundle: ${error.message}\n`);
1206
1219
  // The same file is not translated again; a write to it clears this.
1207
1220
  if(this.sessionLive(key,ruleId,epoch)){this.state.native_failed[key]=onDisk;this.save();}
@@ -1211,17 +1224,18 @@ export class TeamsConnector {
1211
1224
  if(bundle&&fileStamp(statSync(artifactSource))!==onDisk)bundle=null;
1212
1225
  if(bundle)({artifacts,nativeBundle}=this.bundleArtifacts(bundle,harness));else nativeDeferred=true;
1213
1226
  // A retry that still has no bundle would republish the same messages for nothing.
1214
- if(unchanged&&!nativeBundle){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);this.armNativeIdle(key);return null;}
1227
+ if(unchanged&&!nativeBundle){requireRedactionBasis(basis);this.origins[key]={...previous,redaction_basis:checkedRedactionBasis(basis)};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);this.armNativeIdle(key);return null;}
1215
1228
  }else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
1216
1229
  const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
1217
1230
  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'});
1218
- ({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`),harness));
1231
+ ({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`,basis),harness));
1219
1232
  }
1220
1233
  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}:{})};
1221
1234
  const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
1222
1235
  // A session gone or no longer covered while it loaded queues nothing (its spilled bundle, held by nothing, goes at the next start).
1223
1236
  if(!this.sessionLive(key,ruleId,epoch))return null;
1224
- this.queue({begin,parts,commit,artifacts,windowStart:anchor,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:loadedMessages.map(message=>message.native_key),digests:loadedMessages.map(digestOf)},redactionBasis:basis});this.save();const receipt=await this.flushSerial();await this.publishSessionProfile(key);return receipt;
1237
+ requireRedactionBasis(basis);
1238
+ this.queue({begin,parts,commit,artifacts,windowStart:anchor,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:loadedMessages.map(message=>message.native_key),digests:loadedMessages.map(digestOf)},redactionBasis:checkedRedactionBasis(basis)});this.save();const receipt=await this.flushSerial();await this.publishSessionProfile(key);return receipt;
1225
1239
  }
1226
1240
  /**
1227
1241
  * One session's oldest observation published. A session Teams refused (lineage not declared yet, or any error) is
@@ -1318,13 +1332,13 @@ export class TeamsConnector {
1318
1332
  */
1319
1333
  scheduleFlush(error){
1320
1334
  if(this.flushTimer||this.abort.signal.aborted||(!this.outbox.length&&!this.owed?.size))return;
1321
- if(this.flushBackoff==null)process.stderr.write(`supercode teams: ${this.outbox.length} queued capture(s) and ${this.owed?.size??0} session capture(s) wait for the server (${error?.message??'not sent'}); tried again with backoff\n`);
1335
+ if(this.flushBackoff==null)process.stderr.write(`supercode teams: ${this.outbox.length} queued capture(s) and ${this.owed?.size??0} session capture(s) wait to retry (${error?.message??'not sent'}); tried again with backoff\n`);
1322
1336
  this.flushBackoff=Math.min(300000,(this.flushBackoff??2500)*2);
1323
1337
  this.flushTimer=setTimeout(()=>{this.flushTimer=null;void this.flush().catch(failure=>{this.lastError=failure;this.scheduleFlush(failure);});},this.flushBackoff);this.flushTimer.unref?.();
1324
1338
  }
1325
1339
  /**
1326
- * A capture that failed on the connection, wherever it ran (a pass, a floor's wake, a large lane): its session's
1327
- * capture is owed until a flush passes (it may have failed before it was queued), and the outbox's wake is armed
1340
+ * A capture that failed on the connection or a changed redaction basis, wherever it ran (a pass, a floor's wake,
1341
+ * a large lane): its session's capture is owed until a flush passes (it may have failed before it was queued), and the wake is armed
1328
1342
  * for it even when nothing is queued.
1329
1343
  */
1330
1344
  oweCapture(options,error){
@@ -1341,7 +1355,7 @@ export class TeamsConnector {
1341
1355
  async flushSerial(){
1342
1356
  let receipt;
1343
1357
  try{receipt=await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}
1344
- // the connection answers again: every session whose capture failed on it is captured now
1358
+ // Every owed session is re-read, including one whose redaction basis changed before queueing.
1345
1359
  if(this.owed?.size){const owed=[...this.owed];this.owed.clear();for(const key of owed)this.resee(key);}
1346
1360
  return receipt;
1347
1361
  }
@@ -4,26 +4,34 @@
4
4
  import { Worker, isMainThread, parentPort } from 'node:worker_threads';
5
5
 
6
6
  if (!isMainThread && parentPort) {
7
- const { redactText, redactValue } = await import('@volter/teams/redact');
7
+ const { redactText, redactValue, redactionBasis, redactionSnapshot } = await import('@volter/teams/redact');
8
8
  const { canonicalJson } = await import('../contracts/index.mjs');
9
9
  const { createHash } = await import('node:crypto');
10
10
  // 'canonical': each item is a message value's canonical JSON (the connector has it already, for its digest), a string
11
11
  // copied to this thread for little more than its bytes, where a value's object graph costs the connector's thread a
12
12
  // walk each way. Only what redaction changed comes back, with its new digest.
13
- const changedOf = (items) => {
13
+ const changedOf = (items, custody) => {
14
14
  const changed = [];
15
15
  items.forEach((text, index) => {
16
- const value = redactValue(JSON.parse(text)), sent = canonicalJson(value);
16
+ const value = redactValue(JSON.parse(text), undefined, custody), sent = canonicalJson(value);
17
17
  if (sent !== text) changed.push({ index, value, digest: createHash('sha256').update(sent).digest('hex') });
18
18
  });
19
19
  return changed;
20
20
  };
21
21
  const { performance } = await import('node:perf_hooks');
22
- parentPort.on('message', ({ id, kind, items }) => {
22
+ parentPort.on('message', ({ id, kind, items, basis }) => {
23
23
  // Its own busy time rides back with each answer: what redaction costs, apart from the connector's other work.
24
24
  const started = performance.now();
25
- try { const answer = kind === 'canonical' ? changedOf(items) : items.map((item) => (kind === 'texts' ? redactText(item) : redactValue(item))); parentPort.postMessage({ id, items: answer, busy: performance.now() - started }); }
26
- catch (error) { parentPort.postMessage({ id, error: String(error?.message ?? error) }); }
25
+ const requireBasis=()=>{if(basis!==undefined&&redactionBasis()!==basis)throw Object.assign(new Error('redaction worker basis differs from the capture; capture needs fresh redaction'),{code:'redaction_basis_changed'});};
26
+ try {
27
+ // All strings in this checked batch use the same custody observation, even if a batch crosses its refresh interval.
28
+ const snapshot=basis===undefined?null:redactionSnapshot();
29
+ if(snapshot&&snapshot.basis!==basis)throw Object.assign(new Error('redaction worker basis differs from the capture; capture needs fresh redaction'),{code:'redaction_basis_changed'});
30
+ requireBasis();
31
+ const answer = kind === 'canonical' ? changedOf(items,snapshot?.values) : items.map((item) => (kind === 'texts' ? redactText(item,undefined,snapshot?.values) : redactValue(item,undefined,snapshot?.values)));
32
+ requireBasis();
33
+ parentPort.postMessage({ id, items: answer, busy: performance.now() - started });
34
+ } catch (error) { parentPort.postMessage({ id, error: String(error?.message ?? error), code: error?.code }); }
27
35
  });
28
36
  }
29
37
 
@@ -33,11 +41,11 @@ export class Redactor {
33
41
  start() {
34
42
  // The connector's own node flags are not the worker's (one meant for the entry script refuses to start it).
35
43
  const worker = new Worker(new URL(import.meta.url), { execArgv: [] });
36
- worker.on('message', ({ id, items, error, busy }) => {
44
+ worker.on('message', ({ id, items, error, code, busy }) => {
37
45
  const waiter = this.waiting.get(id); if (!waiter) return;
38
46
  this.waiting.delete(id);
39
47
  this.workerMs += busy ?? 0;
40
- if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: 'redaction_failed' }));
48
+ if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: code === 'redaction_basis_changed' ? code : 'redaction_failed' }));
41
49
  else waiter.resolve(items);
42
50
  if (!this.waiting.size) worker.unref();
43
51
  });
@@ -54,13 +62,14 @@ export class Redactor {
54
62
  }
55
63
 
56
64
  /** `items` redacted, in order: `kind` 'values' (JSON values, every string in them) or 'texts'; for 'canonical' (canonical
57
- * JSON texts) only the changed ones, as `{ index, value, digest }`. */
58
- run(kind, items) {
65
+ * JSON texts) only the changed ones, as `{ index, value, digest }`. An optional basis must match the worker before
66
+ * and after redaction; a mismatch rejects the request rather than returning stale decisions. */
67
+ run(kind, items, { basis } = {}) {
59
68
  if (!items.length) return Promise.resolve([]);
60
69
  if (!this.worker) this.start();
61
70
  const id = this.next++;
62
71
  this.worker.ref();
63
- return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items }); });
72
+ return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items, basis }); });
64
73
  }
65
74
 
66
75
  close() { const worker = this.worker; this.worker = null; return worker ? worker.terminate().then(() => {}) : Promise.resolve(); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.3.157",
3
+ "version": "0.3.158",
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",
@@ -75,7 +75,7 @@
75
75
  "@volter/supercode-health": "^0.1.0",
76
76
  "@volter/supercode-terminal": "^0.2.54",
77
77
  "@volter/supercode-trace": "^0.1.0",
78
- "@volter/teams": "0.2.42",
78
+ "@volter/teams": "0.2.43",
79
79
  "@xterm/addon-serialize": "^0.14.0",
80
80
  "@xterm/headless": "^6.0.0",
81
81
  "protobufjs": "8.8.0",