@volter/supercode-teams 0.3.156 → 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 +16 -0
- package/connector/index.mjs +58 -28
- package/connector/redactor.mjs +20 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -58,6 +58,22 @@ mail history and individual records can still be large. The
|
|
|
58
58
|
[allocation evidence](../../docs/evidence/mail-transcript-stream-2026-10-08.md) names
|
|
59
59
|
the source boundary and the remaining runtime qualification.
|
|
60
60
|
|
|
61
|
+
The connector's redaction memo keeps serialized redacted JSON, with at most
|
|
62
|
+
200,000 entries and 32 MiB of accounted string payload (two bytes per code unit,
|
|
63
|
+
including keys and digests). An entry over 1 MiB is not retained. Oldest entries
|
|
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
|
|
66
|
+
memo bounds, not total connector memory limits. The
|
|
67
|
+
[source investigation](../../docs/evidence/connector-redaction-memo-2026-10-08.md)
|
|
68
|
+
does not attribute the observed process peak or claim a measured runtime improvement.
|
|
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
|
+
|
|
61
77
|
`supercode teams rpc mail.statistics` reads local storage counts and import/retention receipts. It is refused remotely
|
|
62
78
|
and through the app door. Writer groups count stored envelope copies, not call attempts or quotas; database page
|
|
63
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. */
|
|
@@ -103,14 +104,39 @@ export function foldConnectorState(stateDir){
|
|
|
103
104
|
}
|
|
104
105
|
// What leaves for the server is redacted (@volter/teams/redact), and every digest this connector keeps or sends (what it
|
|
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
|
-
// held, every carry was refused, and each session holding a secret went whole on every pass. A message is redacted
|
|
107
|
-
//
|
|
108
|
-
// redacted
|
|
109
|
-
|
|
107
|
+
// held, every carry was refused, and each session holding a secret went whole on every pass. A message is redacted
|
|
108
|
+
// in the connector's redaction worker, with a bounded digest memo: no body for one without a secret (most), serialized
|
|
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
|
|
110
|
+
// sent) is taken once and read where needed. The byte budget counts two bytes per retained string code unit, including
|
|
111
|
+
// keys and digests; the entry bound separately limits Map/record overhead. Neither is a total-process heap bound.
|
|
112
|
+
const REDACTED_MEMO=200000,REDACTED_MEMO_BYTES=32*1024*1024,REDACTED_MEMO_ENTRY_BYTES=1024*1024;
|
|
110
113
|
const redactions=new Map();
|
|
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}`;
|
|
111
124
|
const sentDigests=new WeakMap();
|
|
112
125
|
const digestOf=message=>{let digest=sentDigests.get(message);if(digest===undefined){digest=hash(canonicalJson(message.value));sentDigests.set(message,digest);}return digest;};
|
|
113
|
-
|
|
126
|
+
function remember(digest,redacted){
|
|
127
|
+
// Remove the earlier unchanged placeholder even if this redacted body is too large to cache: leaving it would
|
|
128
|
+
// make a later hit reuse the original, unredacted message.
|
|
129
|
+
const previous=redactions.get(digest);
|
|
130
|
+
if(previous){redactionBytes-=previous.bytes;redactions.delete(digest);}
|
|
131
|
+
const kept=redacted?{text:JSON.stringify(redacted.value),digest:redacted.digest}:null;
|
|
132
|
+
const bytes=2*(digest.length+(kept?.digest.length??0)+(kept?.text.length??0));
|
|
133
|
+
if(bytes>REDACTED_MEMO_ENTRY_BYTES)return;
|
|
134
|
+
while(redactions.size>=REDACTED_MEMO||redactionBytes+bytes>REDACTED_MEMO_BYTES){
|
|
135
|
+
const oldest=redactions.keys().next().value;
|
|
136
|
+
redactionBytes-=redactions.get(oldest).bytes;redactions.delete(oldest);
|
|
137
|
+
}
|
|
138
|
+
redactions.set(digest,{kept,bytes});redactionBytes+=bytes;
|
|
139
|
+
}
|
|
114
140
|
// A capture part is one request, and the server refuses a body over 8 MiB (volter-teams request-json.mjs): a part
|
|
115
141
|
// holds at most PART_MESSAGES messages (the contract's bound) and PART_BYTES of them, so a run of large tool results
|
|
116
142
|
// splits across parts instead of failing the whole session. A single message larger than that goes alone.
|
|
@@ -644,9 +670,8 @@ export class TeamsConnector {
|
|
|
644
670
|
if(this.unreadable?.has(key))captured=false;
|
|
645
671
|
else if(changed&&!this.predatesWindow(options))try{await this.capture({...options,live:Boolean(this.watched?.has(key))});}catch(error){
|
|
646
672
|
if(this.abort.signal.aborted)return;
|
|
647
|
-
//
|
|
648
|
-
|
|
649
|
-
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;}
|
|
650
675
|
captured=false;this.noteSessionProblem(options,error);
|
|
651
676
|
if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
|
|
652
677
|
}
|
|
@@ -876,12 +901,13 @@ export class TeamsConnector {
|
|
|
876
901
|
// Each loaded message in the form sent: the memo's; the loaded one itself when the server holds it unchanged under
|
|
877
902
|
// this redaction basis (its digest is among those last observed); else what the redaction worker makes of it, all of
|
|
878
903
|
// a capture's new messages in one request. A digest is taken once per message, of the form sent.
|
|
879
|
-
async sentMessages(raw,previous){
|
|
880
|
-
|
|
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;
|
|
881
907
|
const out=new Array(raw.length),missing=[];
|
|
882
908
|
raw.forEach((message,index)=>{
|
|
883
909
|
const text=canonicalJson(message.value),digest=hash(text);
|
|
884
|
-
if(redactions.has(digest)){const kept=redactions.get(digest);out[index]=kept?{...message,value:kept.
|
|
910
|
+
if(redactions.has(digest)){const {kept}=redactions.get(digest);out[index]=kept?{...message,value:JSON.parse(kept.text)}:message;sentDigests.set(out[index],kept?kept.digest:digest);return;}
|
|
885
911
|
if(held?.has(digest)){remember(digest,null);out[index]=message;sentDigests.set(message,digest);return;}
|
|
886
912
|
missing.push([index,digest,text]);
|
|
887
913
|
});
|
|
@@ -889,8 +915,10 @@ export class TeamsConnector {
|
|
|
889
915
|
this.redactor??=new Redactor();
|
|
890
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};}
|
|
891
917
|
// The worker is sent each new message's canonical JSON and answers only those redaction changed.
|
|
892
|
-
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;
|
|
893
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);
|
|
894
922
|
for(const [index,digest] of missing){remember(digest,null);out[index]=raw[index];sentDigests.set(raw[index],digest);}
|
|
895
923
|
for(const {index:at,value,digest:sent} of changed){
|
|
896
924
|
const [index,digest]=missing[at];
|
|
@@ -904,13 +932,14 @@ export class TeamsConnector {
|
|
|
904
932
|
cost.batches+=1;cost.messages+=missing.length;cost.bytes+=texts.reduce((n,text)=>n+text.length,0);
|
|
905
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);
|
|
906
934
|
}
|
|
907
|
-
return out;
|
|
935
|
+
requireRedactionBasis(basis);return out;
|
|
908
936
|
}
|
|
909
|
-
async spillTranslated(translated,defaultName){
|
|
937
|
+
async spillTranslated(translated,defaultName,basis){
|
|
910
938
|
const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
|
|
911
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'});
|
|
912
940
|
let total=0;const spilled=[];
|
|
913
|
-
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);
|
|
914
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)});}
|
|
915
944
|
return {fidelity:translated.fidelity,residue:translated.residue??[],files:spilled};
|
|
916
945
|
}
|
|
@@ -940,7 +969,7 @@ export class TeamsConnector {
|
|
|
940
969
|
const wait=this.watched?.has(key)||options.paced?0:CAPTURE_FLOOR_MS-(now-(this.lastCaptures.get(key)??0));
|
|
941
970
|
// one retry wake per session, holding each rule's latest obligation: a newer generation of a rule replaces that
|
|
942
971
|
// rule's options, never another rule's; the wake runs every rule held, paced as one
|
|
943
|
-
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;}
|
|
944
973
|
this.lastCaptures.set(key,now);
|
|
945
974
|
const {locator}=options,path=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
|
|
946
975
|
const artifactBefore=path?statSync(path):null,anchor=this.codexAnchor(key,artifactBefore);
|
|
@@ -1064,7 +1093,7 @@ export class TeamsConnector {
|
|
|
1064
1093
|
if(this.abort.signal.aborted)return;
|
|
1065
1094
|
// A load that failed or timed out waits like a slow one, so it does not hold its lane on every pass.
|
|
1066
1095
|
if(started!==null)this.largeCaptures.set(key,{at:started,ms:this.clock.nowMs()-started});
|
|
1067
|
-
if(
|
|
1096
|
+
if(retryCapture(error)){this.oweCapture(options,error);continue;}
|
|
1068
1097
|
this.noteSessionProblem(options,error);
|
|
1069
1098
|
if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${options.harness} session ${options.nativeSessionId} not synced: ${error.message}\n`);
|
|
1070
1099
|
}
|
|
@@ -1111,13 +1140,13 @@ export class TeamsConnector {
|
|
|
1111
1140
|
}
|
|
1112
1141
|
// The turns typed through a door here are labelled before redaction, which then reads them as sent.
|
|
1113
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);
|
|
1114
|
-
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;
|
|
1115
1144
|
if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
|
|
1116
1145
|
// A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
|
|
1117
1146
|
const nativeWanted=configuration.native_capture!==false&&backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
|
|
1118
1147
|
const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
|
|
1119
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]);
|
|
1120
|
-
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;}
|
|
1121
1150
|
let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
|
|
1122
1151
|
// A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
|
|
1123
1152
|
// whatever a session holds when first seen: a session begun after the rule is published from its first message.
|
|
@@ -1183,9 +1212,9 @@ export class TeamsConnector {
|
|
|
1183
1212
|
// translated for this capture alone: its spilled files live while the capture is queued (outbox-only)
|
|
1184
1213
|
try{
|
|
1185
1214
|
const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
|
|
1186
|
-
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)};
|
|
1187
1216
|
}catch(error){
|
|
1188
|
-
if(this.abort.signal.aborted)throw error;
|
|
1217
|
+
if(this.abort.signal.aborted||error?.code==='redaction_basis_changed')throw error;
|
|
1189
1218
|
this.lastError=error;process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} published without its native bundle: ${error.message}\n`);
|
|
1190
1219
|
// The same file is not translated again; a write to it clears this.
|
|
1191
1220
|
if(this.sessionLive(key,ruleId,epoch)){this.state.native_failed[key]=onDisk;this.save();}
|
|
@@ -1195,17 +1224,18 @@ export class TeamsConnector {
|
|
|
1195
1224
|
if(bundle&&fileStamp(statSync(artifactSource))!==onDisk)bundle=null;
|
|
1196
1225
|
if(bundle)({artifacts,nativeBundle}=this.bundleArtifacts(bundle,harness));else nativeDeferred=true;
|
|
1197
1226
|
// A retry that still has no bundle would republish the same messages for nothing.
|
|
1198
|
-
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;}
|
|
1199
1228
|
}else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
|
|
1200
1229
|
const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
|
|
1201
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'});
|
|
1202
|
-
({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json
|
|
1231
|
+
({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`,basis),harness));
|
|
1203
1232
|
}
|
|
1204
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}:{})};
|
|
1205
1234
|
const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
|
|
1206
1235
|
// A session gone or no longer covered while it loaded queues nothing (its spilled bundle, held by nothing, goes at the next start).
|
|
1207
1236
|
if(!this.sessionLive(key,ruleId,epoch))return null;
|
|
1208
|
-
|
|
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;
|
|
1209
1239
|
}
|
|
1210
1240
|
/**
|
|
1211
1241
|
* One session's oldest observation published. A session Teams refused (lineage not declared yet, or any error) is
|
|
@@ -1302,13 +1332,13 @@ export class TeamsConnector {
|
|
|
1302
1332
|
*/
|
|
1303
1333
|
scheduleFlush(error){
|
|
1304
1334
|
if(this.flushTimer||this.abort.signal.aborted||(!this.outbox.length&&!this.owed?.size))return;
|
|
1305
|
-
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`);
|
|
1306
1336
|
this.flushBackoff=Math.min(300000,(this.flushBackoff??2500)*2);
|
|
1307
1337
|
this.flushTimer=setTimeout(()=>{this.flushTimer=null;void this.flush().catch(failure=>{this.lastError=failure;this.scheduleFlush(failure);});},this.flushBackoff);this.flushTimer.unref?.();
|
|
1308
1338
|
}
|
|
1309
1339
|
/**
|
|
1310
|
-
* A capture that failed on the connection, wherever it ran (a pass, a floor's wake,
|
|
1311
|
-
* 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
|
|
1312
1342
|
* for it even when nothing is queued.
|
|
1313
1343
|
*/
|
|
1314
1344
|
oweCapture(options,error){
|
|
@@ -1325,7 +1355,7 @@ export class TeamsConnector {
|
|
|
1325
1355
|
async flushSerial(){
|
|
1326
1356
|
let receipt;
|
|
1327
1357
|
try{receipt=await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}
|
|
1328
|
-
//
|
|
1358
|
+
// Every owed session is re-read, including one whose redaction basis changed before queueing.
|
|
1329
1359
|
if(this.owed?.size){const owed=[...this.owed];this.owed.clear();for(const key of owed)this.resee(key);}
|
|
1330
1360
|
return receipt;
|
|
1331
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",
|