@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 +9 -1
- package/connector/index.mjs +38 -24
- package/connector/redactor.mjs +20 -11
- package/package.json +2 -2
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
|
|
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.
|
package/connector/index.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
664
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
1327
|
-
* capture is owed until a flush passes (it may have failed before it was queued), and the
|
|
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
|
-
//
|
|
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
|
}
|
package/connector/redactor.mjs
CHANGED
|
@@ -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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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",
|