@volter/supercode-teams 0.3.157 → 0.3.159
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 +22 -1
- package/bin/workspace/sync-status.mjs +9 -0
- package/bin/workspace.mjs +2 -2
- package/connector/index.d.ts +2 -1
- package/connector/index.mjs +121 -42
- package/connector/redaction-holds.mjs +59 -0
- package/connector/redactor.mjs +27 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -61,11 +61,32 @@ 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.
|
|
74
|
+
[Source boundary and qualification](../../docs/evidence/connector-redaction-basis-2026-10-08.md).
|
|
75
|
+
|
|
76
|
+
Session captures reread the custody registry and declared files before each
|
|
77
|
+
transfer, including rate-limit retries. A stale queued capture is retained
|
|
78
|
+
locally and replaced by a fresh full capture; its carried prefix, parts and
|
|
79
|
+
native references are not reused. Original artifact files stay held until a
|
|
80
|
+
replacement commit acknowledges the native bundle too. Other queued sessions
|
|
81
|
+
can proceed. `supercode teams status` and `supercode auth status` show the holds
|
|
82
|
+
and explain that registration does not purge already transferred or published
|
|
83
|
+
data. A Teams administrator uses `TeamsClient.purgeSessionText` / `POST
|
|
84
|
+
/api/v1/teams/:team/sessions/purge` for retrospective session-text cleanup.
|
|
85
|
+
Direct writes after the last check and requests already in flight have **no
|
|
86
|
+
bounded exposure time**. This is capture-transfer source behavior, with runtime
|
|
87
|
+
and single-machine qualification still open.
|
|
88
|
+
[Transfer scope and retained obligations](../../docs/evidence/connector-custody-egress-2026-10-08.md).
|
|
89
|
+
|
|
69
90
|
`supercode teams rpc mail.statistics` reads local storage counts and import/retention receipts. It is refused remotely
|
|
70
91
|
and through the app door. Writer groups count stored envelope copies, not call attempts or quotas; database page
|
|
71
92
|
counts exclude physical WAL size. Successful imports record their actual source reads, bytes, batch sizes and time.
|
|
@@ -8,6 +8,15 @@ const reason = item => `${item.code ? `${item.code}: ` : ''}${item.message}`;
|
|
|
8
8
|
export function syncStatusLines(sync, { indent = ' ' } = {}) {
|
|
9
9
|
if (!sync) return [];
|
|
10
10
|
const lines = [];
|
|
11
|
+
if(sync.custody_notice)lines.push(`Custody ${sync.custody_notice}`);
|
|
12
|
+
const holds=sync.redaction_held??[];
|
|
13
|
+
if(holds.length){
|
|
14
|
+
lines.push(`Custody holds (${holds.length} capture${holds.length===1?'':'s'} retained locally):`);
|
|
15
|
+
const labels={owed:'fresh full capture owed',native_owed:'replacement native bundle owed',blocked:'payload refused; waiting for source, rule or custody change',cleanup:'replacement committed; local cleanup pending'};
|
|
16
|
+
for(const item of holds.slice(0,SHOWN))lines.push(`${indent}${item.harness} ${item.session_id} · ${item.capture_id} · ${when(item.at)} · ${labels[item.state]??item.state}${item.replacement?` · replacement ${item.replacement}`:''}`);
|
|
17
|
+
if(holds.length>SHOWN)lines.push(`${indent}… and ${holds.length-SHOWN} more (--json lists them all)`);
|
|
18
|
+
}
|
|
19
|
+
if(sync.redaction_held_unread?.length)lines.push(`Custody holds unread (${sync.redaction_held_unread.length}); original files and all artifact spills retained, inspect --json`);
|
|
11
20
|
const problems = sync.not_syncing ?? [];
|
|
12
21
|
if (problems.length) {
|
|
13
22
|
lines.push(`Not syncing (${problems.length} session${problems.length === 1 ? '' : 's'}, most recent first):`);
|
package/bin/workspace.mjs
CHANGED
|
@@ -402,9 +402,9 @@ async function authCommand({ flags, sub = 'status', rest, home, contexts, output
|
|
|
402
402
|
if (!account.context) return say(account.others.length ? `Team none current · saved: ${account.others.join(', ')} (supercode auth switch NAME)` : 'Team none (supercode login)');
|
|
403
403
|
say(`Team ${account.team} · ${new URL(account.origin).host}${account.reachable ? '' : ' · unreachable'}${account.others.length ? ` · also: ${account.others.join(', ')} (supercode auth switch)` : ''}`);
|
|
404
404
|
say(account.machine ? `Machine ${account.machine.name} · ${account.machine.connection ?? 'unknown'} · sharing ${account.machine.folder ?? '?'}${account.machine.service ? ` (${account.machine.service})` : ' (runs while `teams connect` does)'}` : 'Machine not connected (supercode teams connect)');
|
|
405
|
-
//
|
|
405
|
+
// Custody's published-history boundary is part of account status even without a failed capture.
|
|
406
406
|
const sync = account.machine?.sync;
|
|
407
|
-
if (sync?.not_syncing.length || sync?.not_bound?.length || sync?.last_error?.at > Date.now() - 86_400_000) for (const line of syncStatusLines(sync)) say(line);
|
|
407
|
+
if (sync?.custody_notice || sync?.not_syncing.length || sync?.not_bound?.length || sync?.last_error?.at > Date.now() - 86_400_000) for (const line of syncStatusLines(sync)) say(line);
|
|
408
408
|
return;
|
|
409
409
|
}
|
|
410
410
|
if (sub === 'switch') {
|
package/connector/index.d.ts
CHANGED
|
@@ -65,8 +65,9 @@ export interface ConnectorConfigurationValue {
|
|
|
65
65
|
|
|
66
66
|
export interface SyncErrorSummary { code: string | null; message: string; at: number }
|
|
67
67
|
export interface SyncProblem extends SyncErrorSummary { harness: string; session_id: string; rule_id: string | null }
|
|
68
|
+
export interface RedactionHoldStatus { capture_id: string; harness: string; session_id: string; rule_id: string | null; at: number | null; state: 'owed' | 'native_owed' | 'blocked' | 'cleanup'; replacement?: string }
|
|
68
69
|
/** What `supercode teams status` shows: sessions not syncing (most recent first) and the last error; null without state. */
|
|
69
|
-
export declare function readSyncStatus(stateDir: string): { not_syncing: SyncProblem[]; not_bound: { harness: string; session_id: string; reason: string }[]; not_bound_count: number; last_error: SyncErrorSummary | null; queued_captures: number } | null;
|
|
70
|
+
export declare function readSyncStatus(stateDir: string): { custody_notice: string; redaction_held: RedactionHoldStatus[]; redaction_held_unread: { name: string; error: string }[]; not_syncing: SyncProblem[]; not_bound: { harness: string; session_id: string; reason: string }[]; not_bound_count: number; last_error: SyncErrorSummary | null; queued_captures: number } | null;
|
|
70
71
|
|
|
71
72
|
export declare class TeamsConnector {
|
|
72
73
|
constructor(options: TeamsConnectorOptions);
|
package/connector/index.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSy
|
|
|
2
2
|
import { realpathSync } from './path.mjs';
|
|
3
3
|
import { redactionBasis } from '@volter/teams/redact';
|
|
4
4
|
import { Redactor } from './redactor.mjs';
|
|
5
|
+
import { RedactionHolds } from './redaction-holds.mjs';
|
|
5
6
|
import { basename, dirname, join, sep } from 'node:path';
|
|
6
7
|
|
|
7
8
|
/** A path in its real form: its nearest existing ancestor resolved (a file inside a folder this process may not read is
|
|
@@ -46,7 +47,9 @@ const covers=(root,cwd)=>{
|
|
|
46
47
|
};
|
|
47
48
|
// An error of the connection itself (the outbox, authentication, the server), not of one session.
|
|
48
49
|
const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
|
|
50
|
+
const retryCapture=error=>connectionError(error)||['redaction_basis_changed','custody_unreadable'].includes(error?.code);
|
|
49
51
|
const sameError=(a,b)=>a!=null&&b!=null&&a.code===b.code&&a.message===b.message;
|
|
52
|
+
const CUSTODY_NOTICE='Custody registration does not purge data already transferred or published. A Teams administrator can purge session text through POST /api/v1/teams/:team/sessions/purge (TeamsClient.purgeSessionText). A direct file edit after the last transfer check, or a request already in flight, has no bounded exposure time.';
|
|
50
53
|
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
54
|
/** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
|
|
52
55
|
export function readSyncStatus(stateDir){
|
|
@@ -54,7 +57,7 @@ export function readSyncStatus(stateDir){
|
|
|
54
57
|
const status=state?.sync_status;
|
|
55
58
|
// Sessions this machine could not bind to the agent their launch named (bindAgentSessions): waiting, or refused for good.
|
|
56
59
|
const agentSessions=Object.entries(state?.agent_sessions??{}).filter(([,item])=>item?.state==='pending'||item?.state==='refused').map(([session,item])=>({session,agent:item.agent,state:item.state,reason:item.error?.message??null}));
|
|
57
|
-
return {agent_sessions_unbound:agentSessions,index_unread:Object.values(status?.index_unread??{}).flat(),not_syncing:Array.isArray(status?.problems)?status.problems:[],not_bound:Array.isArray(status?.unbound)?status.unbound:[],not_bound_count:Number.isSafeInteger(status?.unbound_count)?status.unbound_count:(Array.isArray(status?.unbound)?status.unbound.length:0),last_error:status?.last_error??null,queued_captures:Array.isArray(state?.outbox)?state.outbox.length:(()=>{try{return readdirSync(join(stateDir,'outbox')).filter(name=>name.endsWith('.json')).length;}catch{return 0;}})()};
|
|
60
|
+
return {custody_notice:CUSTODY_NOTICE,redaction_held:status?.redaction_held??[],redaction_held_unread:status?.redaction_held_unread??[],agent_sessions_unbound:agentSessions,index_unread:Object.values(status?.index_unread??{}).flat(),not_syncing:Array.isArray(status?.problems)?status.problems:[],not_bound:Array.isArray(status?.unbound)?status.unbound:[],not_bound_count:Number.isSafeInteger(status?.unbound_count)?status.unbound_count:(Array.isArray(status?.unbound)?status.unbound.length:0),last_error:status?.last_error??null,queued_captures:Array.isArray(state?.outbox)?state.outbox.length:(()=>{try{return readdirSync(join(stateDir,'outbox')).filter(name=>name.endsWith('.json')).length;}catch{return 0;}})()};
|
|
58
61
|
}
|
|
59
62
|
// A tool call still running when the core reads a session has no result yet; the core answers it with a synthesized
|
|
60
63
|
// placeholder (crates/interchange session/helpers.rs ensure_tool_results_paired: a `tool` message for that call id,
|
|
@@ -92,11 +95,14 @@ const itemSpills=item=>(item.artifacts??[]).filter(artifact=>artifact.spill).map
|
|
|
92
95
|
* folded state writes them out again (TeamsConnector#loadEntries).
|
|
93
96
|
*/
|
|
94
97
|
export function foldConnectorState(stateDir){
|
|
98
|
+
if(existsSync(join(stateDir,'redaction-held'))&&readdirSync(join(stateDir,'redaction-held')).some(name=>/^\d+\.json$/.test(name)))throw new Error('retained custody captures prevent folding state for an older connector; resolve the visible redaction holds first');
|
|
95
99
|
const path=join(stateDir,'connector.json'),state=JSON.parse(readFileSync(path,'utf8'));
|
|
96
100
|
const origins=join(stateDir,'origins'),outbox=join(stateDir,'outbox'),files=dir=>existsSync(dir)?readdirSync(dir).filter(name=>name.endsWith('.json')).sort():[];
|
|
101
|
+
if(state.outbox?.some(item=>item.redaction_hold))throw new Error('a custody-flagged capture prevents folding state for an older connector');
|
|
97
102
|
if(state.origins||state.outbox)return {origins:Object.keys(state.origins??{}).length,captures:(state.outbox??[]).length,folded:false};
|
|
98
103
|
state.origins={};for(const name of files(origins)){const {key,origin}=JSON.parse(readFileSync(join(origins,name),'utf8'));state.origins[key]=origin;}
|
|
99
104
|
state.outbox=files(outbox).map(name=>JSON.parse(readFileSync(join(outbox,name),'utf8')));delete state.outbox_sequence;
|
|
105
|
+
if(state.outbox.some(item=>item.redaction_hold))throw new Error('a custody-flagged capture prevents folding state for an older connector');
|
|
100
106
|
atomic(path,JSON.stringify(state,null,2)+'\n');
|
|
101
107
|
for(const dir of [origins,outbox])rmSync(dir,{recursive:true,force:true});
|
|
102
108
|
return {origins:Object.keys(state.origins).length,captures:state.outbox.length,folded:true};
|
|
@@ -105,12 +111,22 @@ export function foldConnectorState(stateDir){
|
|
|
105
111
|
// observed, a baseline, the carried prefix) is of that form: a digest of the local text named a prefix the server never
|
|
106
112
|
// held, every carry was refused, and each session holding a secret went whole on every pass. A message is redacted
|
|
107
113
|
// 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.
|
|
114
|
+
// 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
115
|
// sent) is taken once and read where needed. The byte budget counts two bytes per retained string code unit, including
|
|
110
116
|
// keys and digests; the entry bound separately limits Map/record overhead. Neither is a total-process heap bound.
|
|
111
117
|
const REDACTED_MEMO=200000,REDACTED_MEMO_BYTES=32*1024*1024,REDACTED_MEMO_ENTRY_BYTES=1024*1024;
|
|
112
118
|
const redactions=new Map();
|
|
113
|
-
let redactionBytes=0;
|
|
119
|
+
let redactionBytes=0,redactionMemoBasis=null;
|
|
120
|
+
const freshRedactionBasis=()=>redactionBasis(undefined,{fresh:true});
|
|
121
|
+
function useRedactionMemo(basis){
|
|
122
|
+
if(redactionMemoBasis!==basis){redactions.clear();redactionBytes=0;redactionMemoBasis=basis;}
|
|
123
|
+
}
|
|
124
|
+
function requireRedactionBasis(basis){
|
|
125
|
+
if(freshRedactionBasis()!==basis)throw Object.assign(new Error('redaction basis changed; capture needs fresh redaction'),{code:'redaction_basis_changed'});
|
|
126
|
+
}
|
|
127
|
+
// Older queued/committed captures could label a result with the basis read after its worker returned. They do not
|
|
128
|
+
// establish the checked observation needed for held-digest reuse, even when that old label equals today's basis.
|
|
129
|
+
const checkedRedactionBasis=basis=>`checked-v1:${basis}`;
|
|
114
130
|
const sentDigests=new WeakMap();
|
|
115
131
|
const digestOf=message=>{let digest=sentDigests.get(message);if(digest===undefined){digest=hash(canonicalJson(message.value));sentDigests.set(message,digest);}return digest;};
|
|
116
132
|
function remember(digest,redacted){
|
|
@@ -236,7 +252,7 @@ export class TeamsConnector {
|
|
|
236
252
|
for(const dir of [origins,outbox])rmSync(dir,{recursive:true,force:true});
|
|
237
253
|
for(const dir of [origins,outbox])mkdirSync(dir,{recursive:true,mode:0o700});
|
|
238
254
|
for(const [key,origin] of Object.entries(this.state.origins??{}))atomic(join(origins,`${hash(key).slice(0,32)}.json`),JSON.stringify({key,origin}));
|
|
239
|
-
this.state.outbox_sequence=0;
|
|
255
|
+
this.state.outbox_sequence=existsSync(join(this.stateDir,'redaction-held'))?readdirSync(join(this.stateDir,'redaction-held')).filter(name=>/^\d+\.json$/.test(name)).reduce((sequence,name)=>Math.max(sequence,Number(name.slice(0,-5))),0):0;
|
|
240
256
|
for(const item of this.state.outbox??[])atomic(join(outbox,`${String(++this.state.outbox_sequence).padStart(12,'0')}.json`),JSON.stringify(item));
|
|
241
257
|
delete this.state.origins;delete this.state.outbox;atomic(this.statePath,JSON.stringify(this.state,null,2)+'\n');
|
|
242
258
|
}
|
|
@@ -253,6 +269,9 @@ export class TeamsConnector {
|
|
|
253
269
|
for(const name of readdirSync(outbox).filter(name=>name.endsWith('.json')).sort()){try{this.outbox.push(Object.assign(JSON.parse(readFileSync(join(outbox,name),'utf8')),{[QUEUED_FILE]:name}));}catch(error){this.outboxUnread.push({name,error:String(error?.message??error)});}}
|
|
254
270
|
// the sequence is past every queued capture's name on disk, read or unread, so no new one takes an unread one's
|
|
255
271
|
for(const name of readdirSync(outbox)){const n=Number(/^(\d+)\.json$/.exec(name)?.[1]);if(Number.isSafeInteger(n)&&n>(this.state.outbox_sequence??0))this.state.outbox_sequence=n;}
|
|
272
|
+
this.redactionHolds=new RedactionHolds({stateDir:this.stateDir,atomic,saved:this.state.redaction_holds});
|
|
273
|
+
for(const name of [...this.redactionHolds.items.keys(),...this.redactionHolds.unread.map(item=>item.name)]){const n=Number(name.slice(0,-5));if(Number.isSafeInteger(n)&&n>(this.state.outbox_sequence??0))this.state.outbox_sequence=n;}
|
|
274
|
+
this.updateRedactionHolds();
|
|
256
275
|
}
|
|
257
276
|
/**
|
|
258
277
|
* Where a Codex rollout past the display window is read from: the origin's window_start while it holds (the same
|
|
@@ -281,6 +300,33 @@ export class TeamsConnector {
|
|
|
281
300
|
// every beat does not rewrite connector.json every beat. Its time stays that of its first occurrence in the run.
|
|
282
301
|
set lastError(error){this.lastErrorValue=error;const status=this.syncStatus(),summary=errorSummary(error,this.clock.nowMs());if(sameError(status.last_error,summary))return;status.last_error=summary;this.saveQuietly();}
|
|
283
302
|
syncStatus(){const status=this.state.sync_status??={problems:[],last_error:null};status.problems??=[];return status;}
|
|
303
|
+
updateRedactionHolds(){
|
|
304
|
+
this.state.redaction_holds=this.redactionHolds.saved();
|
|
305
|
+
const status=this.syncStatus();status.redaction_held=this.redactionHolds.status();status.redaction_held_unread=this.redactionHolds.unread;
|
|
306
|
+
}
|
|
307
|
+
observeCustodyBasis(){
|
|
308
|
+
const basis=freshRedactionBasis();
|
|
309
|
+
if(this.state.custody_basis!==basis){this.state.custody_basis=basis;process.stderr.write(`supercode teams: ${CUSTODY_NOTICE}\n`);this.saveQuietly();}
|
|
310
|
+
return basis;
|
|
311
|
+
}
|
|
312
|
+
requireCaptureTransfer(item){
|
|
313
|
+
const basis=this.observeCustodyBasis();
|
|
314
|
+
if(item.redaction_hold||item.redactionBasis!==checkedRedactionBasis(basis))throw Object.assign(new Error('queued capture needs fresh custody redaction; retained locally for fresh capture'),{code:item.redaction_hold?.code??'redaction_basis_changed'});
|
|
315
|
+
return basis;
|
|
316
|
+
}
|
|
317
|
+
async guardCaptureTransfer(item,json){
|
|
318
|
+
const basis=this.requireCaptureTransfer(item);
|
|
319
|
+
this.redactor??=new Redactor();
|
|
320
|
+
await this.redactor.run('custody_assert',[json],{basis,fresh:true});
|
|
321
|
+
// request invokes this synchronously after the worker answer, with no intervening await before fetch.
|
|
322
|
+
return ()=>this.requireCaptureTransfer(item);
|
|
323
|
+
}
|
|
324
|
+
holdQueuedCapture(item,error){
|
|
325
|
+
this.redactionHolds.hold(item,item[QUEUED_FILE],this.clock.nowMs(),error.code);
|
|
326
|
+
this.outbox.shift(); // Its spill references move to the hold; unqueue would release them.
|
|
327
|
+
this.updateRedactionHolds();this.noteSessionProblem({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id,ruleId:item.begin.rule_id},error);
|
|
328
|
+
this.oweCapture({harness:item.begin.harness,nativeSessionId:item.begin.native_session_id},error);this.save();
|
|
329
|
+
}
|
|
284
330
|
saveQuietly(){try{this.save();}catch{}}
|
|
285
331
|
noteSessionProblem({harness,nativeSessionId,ruleId=null},error){
|
|
286
332
|
const status=this.syncStatus(),at=this.clock.nowMs(),summary=errorSummary(error,at);
|
|
@@ -295,7 +341,7 @@ export class TeamsConnector {
|
|
|
295
341
|
clearSessionProblem(harness,nativeSessionId){
|
|
296
342
|
const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
|
|
297
343
|
status.problems=status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId));this.saveQuietly();
|
|
298
|
-
} async request(method,path,body){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);let response;for(let attempt=0;;attempt++){response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)])});
|
|
344
|
+
} async request(method,path,body,{beforeTransfer}={}){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);let response;for(let attempt=0;;attempt++){if(beforeTransfer){const finalCheck=await beforeTransfer(json);finalCheck();}response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:AbortSignal.any([this.abort.signal, AbortSignal.timeout(30_000)])});
|
|
299
345
|
// A rate limit is waited out (the server says how long; the same idempotency key makes the retry safe), so a
|
|
300
346
|
// large first sync slows down instead of failing its pass and restarting the connector.
|
|
301
347
|
if(response.status!==429||attempt>=5)break;// The server names the wait in error.details (wire.mjs validationFailure); a Retry-After header is the fallback.
|
|
@@ -654,15 +700,16 @@ export class TeamsConnector {
|
|
|
654
700
|
const {locator,descriptor,harness,nativeSessionId,ruleId,epoch}=options,key=`${harness}:${nativeSessionId}`;
|
|
655
701
|
if(!this.sessionLive(key,ruleId,epoch))return;
|
|
656
702
|
// A session whose store has not changed since this rule last committed it is in sync, whatever it last failed.
|
|
657
|
-
let captured=true
|
|
703
|
+
let captured=true,changed;
|
|
704
|
+
try{changed=this.sessionChanged(options);}catch(error){if(retryCapture(error))this.oweCapture(options,error);else this.noteSessionProblem(options,error);return;}
|
|
705
|
+
if(!changed)this.clearSessionProblem(harness,nativeSessionId);
|
|
658
706
|
// A store this process may not read (macOS denies a launchd job a removable volume, whatever the owner granted the
|
|
659
707
|
// terminal) is said once with its path and skipped.
|
|
660
708
|
if(this.unreadable?.has(key))captured=false;
|
|
661
709
|
else if(changed&&!this.predatesWindow(options))try{await this.capture({...options,live:Boolean(this.watched?.has(key))});}catch(error){
|
|
662
710
|
if(this.abort.signal.aborted)return;
|
|
663
|
-
//
|
|
664
|
-
|
|
665
|
-
if(connectionError(error)){this.oweCapture(options,error);return;}
|
|
711
|
+
// A connection failure or a changed redaction basis keeps this session owed; the existing retry wake re-reads it.
|
|
712
|
+
if(retryCapture(error)){this.oweCapture(options,error);return;}
|
|
666
713
|
captured=false;this.noteSessionProblem(options,error);
|
|
667
714
|
if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
|
|
668
715
|
}
|
|
@@ -782,6 +829,9 @@ export class TeamsConnector {
|
|
|
782
829
|
}
|
|
783
830
|
sessionChanged(options){
|
|
784
831
|
const stamp=this.sessionStamp(options);options.stamp=stamp;
|
|
832
|
+
const custodyKey=`${options.harness}:${options.nativeSessionId}`;
|
|
833
|
+
if(this.redactionHolds.items.size&&this.redactionHolds.blocked(custodyKey,stamp,options.ruleRevision,checkedRedactionBasis(this.observeCustodyBasis())))return false;
|
|
834
|
+
if(this.redactionHolds.needed(custodyKey))return true;
|
|
785
835
|
if(stamp===null||this.outbox.length)return true;
|
|
786
836
|
const rule=options.configuration?.rules?.find(item=>item.id===options.ruleId&&item.revision===options.ruleRevision&&item.state==='active');if(!rule)return true;
|
|
787
837
|
const configured=rule.backfill??options.backfill??'all',mode=typeof configured==='string'?configured:configured.mode;
|
|
@@ -834,6 +884,7 @@ export class TeamsConnector {
|
|
|
834
884
|
if(this.spillRefs)return this.spillRefs;
|
|
835
885
|
const refs=this.spillRefs=new Map(),add=name=>refs.set(name,(refs.get(name)??0)+1);
|
|
836
886
|
for(const item of this.outbox)for(const name of itemSpills(item))add(name);
|
|
887
|
+
for(const name of this.redactionHolds.spills())add(name);
|
|
837
888
|
return refs;
|
|
838
889
|
}
|
|
839
890
|
retainSpills(names){const refs=this.spillHolders();for(const name of names){refs.set(name,(refs.get(name)??0)+1);this.freshSpills?.delete(name);}}
|
|
@@ -847,7 +898,7 @@ export class TeamsConnector {
|
|
|
847
898
|
* whoever else held it. A file kept so is held by nothing counted and goes at the first start whose inventory is whole.
|
|
848
899
|
*/
|
|
849
900
|
removeSpill(name){
|
|
850
|
-
if(this.outboxUnread?.length){this.spillProblem(`${name} was kept:
|
|
901
|
+
if(this.outboxUnread?.length||this.redactionHolds.unread.length){this.spillProblem(`${name} was kept: a queued or custody-held capture could not be read and may name it`);return false;}
|
|
851
902
|
try{unlinkSync(join(this.spillDir(),name));return true;}
|
|
852
903
|
catch(error){if(error?.code==='ENOENT')return true;this.spillProblem(`${name} could not be removed: ${error?.message??error}`);return false;}
|
|
853
904
|
}
|
|
@@ -865,9 +916,9 @@ export class TeamsConnector {
|
|
|
865
916
|
removeUnheldSpills(){
|
|
866
917
|
const status=this.syncStatus();delete status.spill_cleanup;
|
|
867
918
|
// only against a complete inventory of the outbox: a queued capture this process could not read may name any file
|
|
868
|
-
if(this.outboxUnread.length){
|
|
919
|
+
if(this.outboxUnread.length||this.redactionHolds.unread.length){
|
|
869
920
|
status.outbox_unread=this.outboxUnread.map(({name,error})=>`${name}: ${error}`);
|
|
870
|
-
this.spillProblem(
|
|
921
|
+
this.spillProblem('a queued or custody-held capture could not be read; no spill file is removed until their inventory is complete');
|
|
871
922
|
return;
|
|
872
923
|
}
|
|
873
924
|
delete status.outbox_unread;
|
|
@@ -892,8 +943,9 @@ export class TeamsConnector {
|
|
|
892
943
|
// Each loaded message in the form sent: the memo's; the loaded one itself when the server holds it unchanged under
|
|
893
944
|
// this redaction basis (its digest is among those last observed); else what the redaction worker makes of it, all of
|
|
894
945
|
// 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
|
-
|
|
946
|
+
async sentMessages(raw,previous,basis=freshRedactionBasis()){
|
|
947
|
+
requireRedactionBasis(basis);useRedactionMemo(basis);
|
|
948
|
+
const held=previous?.redaction_basis===checkedRedactionBasis(basis)&&previous?.observed_digests?.length?new Set(previous.observed_digests):null;
|
|
897
949
|
const out=new Array(raw.length),missing=[];
|
|
898
950
|
raw.forEach((message,index)=>{
|
|
899
951
|
const text=canonicalJson(message.value),digest=hash(text);
|
|
@@ -905,8 +957,10 @@ export class TeamsConnector {
|
|
|
905
957
|
this.redactor??=new Redactor();
|
|
906
958
|
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
959
|
// 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;
|
|
960
|
+
let started=performance.now();const texts=missing.map(([,,text])=>text),pending=this.redactor.run('canonical',texts,{basis,fresh:true});let mainMs=performance.now()-started;
|
|
909
961
|
const changed=await pending;started=performance.now();
|
|
962
|
+
// Another connector/capture can run while this worker answers. Never store its result under a later basis.
|
|
963
|
+
requireRedactionBasis(basis);useRedactionMemo(basis);
|
|
910
964
|
for(const [index,digest] of missing){remember(digest,null);out[index]=raw[index];sentDigests.set(raw[index],digest);}
|
|
911
965
|
for(const {index:at,value,digest:sent} of changed){
|
|
912
966
|
const [index,digest]=missing[at];
|
|
@@ -920,15 +974,17 @@ export class TeamsConnector {
|
|
|
920
974
|
cost.batches+=1;cost.messages+=missing.length;cost.bytes+=texts.reduce((n,text)=>n+text.length,0);
|
|
921
975
|
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
976
|
}
|
|
923
|
-
return out;
|
|
977
|
+
requireRedactionBasis(basis);return out;
|
|
924
978
|
}
|
|
925
|
-
async spillTranslated(translated,defaultName){
|
|
979
|
+
async spillTranslated(translated,defaultName,basis){
|
|
926
980
|
const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
|
|
927
981
|
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
982
|
let total=0;const spilled=[];
|
|
929
|
-
this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content));
|
|
930
|
-
|
|
931
|
-
|
|
983
|
+
this.redactor??=new Redactor();const texts=await this.redactor.run('texts',files.map(file=>file.content),{basis,fresh:true});
|
|
984
|
+
const [metadata]=await this.redactor.run('values',[{paths:files.map(file=>file.path),residue:translated.residue??[]}],{basis,fresh:true});
|
|
985
|
+
if(basis!==undefined)requireRedactionBasis(basis);
|
|
986
|
+
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:metadata.paths[index],role:file.role,...this.spill(bytes)});}
|
|
987
|
+
return {fidelity:translated.fidelity,residue:metadata.residue,files:spilled};
|
|
932
988
|
}
|
|
933
989
|
// The outbox artifacts and native_bundle of one capture from a spilled bundle; every capture gets fresh upload ids.
|
|
934
990
|
bundleArtifacts(bundle,harness){
|
|
@@ -956,7 +1012,7 @@ export class TeamsConnector {
|
|
|
956
1012
|
const wait=this.watched?.has(key)||options.paced?0:CAPTURE_FLOOR_MS-(now-(this.lastCaptures.get(key)??0));
|
|
957
1013
|
// one retry wake per session, holding each rule's latest obligation: a newer generation of a rule replaces that
|
|
958
1014
|
// 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(
|
|
1015
|
+
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
1016
|
this.lastCaptures.set(key,now);
|
|
961
1017
|
const {locator}=options,path=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
|
|
962
1018
|
const artifactBefore=path?statSync(path):null,anchor=this.codexAnchor(key,artifactBefore);
|
|
@@ -1080,7 +1136,7 @@ export class TeamsConnector {
|
|
|
1080
1136
|
if(this.abort.signal.aborted)return;
|
|
1081
1137
|
// A load that failed or timed out waits like a slow one, so it does not hold its lane on every pass.
|
|
1082
1138
|
if(started!==null)this.largeCaptures.set(key,{at:started,ms:this.clock.nowMs()-started});
|
|
1083
|
-
if(
|
|
1139
|
+
if(retryCapture(error)){this.oweCapture(options,error);continue;}
|
|
1084
1140
|
this.noteSessionProblem(options,error);
|
|
1085
1141
|
if(!this.noteUnreadable(options,error))process.stderr.write(`supercode teams: ${options.harness} session ${options.nativeSessionId} not synced: ${error.message}\n`);
|
|
1086
1142
|
}
|
|
@@ -1127,13 +1183,14 @@ export class TeamsConnector {
|
|
|
1127
1183
|
}
|
|
1128
1184
|
// The turns typed through a door here are labelled before redaction, which then reads them as sent.
|
|
1129
1185
|
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
|
|
1186
|
+
const basis=this.observeCustodyBasis();let messages=await this.sentMessages(raw,previous,basis);const loadedMessages=messages;
|
|
1187
|
+
const recapturing=this.redactionHolds.needed(key);
|
|
1131
1188
|
if (harness === 'codex' && loaded.total_message_count > messages.length) coverage = { ...coverage, completeness: 'partial', reasons: [...new Set([...coverage.reasons, 'prefix_not_captured'])] };
|
|
1132
1189
|
// A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
|
|
1133
1190
|
const nativeWanted=configuration.native_capture!==false&&backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function'&&!((artifactBefore?.size??0)>NATIVE_MAX_BYTES);
|
|
1134
1191
|
const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
|
|
1135
1192
|
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;}
|
|
1193
|
+
if(unchanged&&!retryNative&&!recapturing){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
1194
|
let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
|
|
1138
1195
|
// A `none` rule keeps back the history recorded before it became active (the server's capture_after), not
|
|
1139
1196
|
// whatever a session holds when first seen: a session begun after the rule is published from its first message.
|
|
@@ -1172,7 +1229,7 @@ export class TeamsConnector {
|
|
|
1172
1229
|
const begin={native_session_id:nativeSessionId,harness,generation,sequence,capture_id:captureId,previous_digest:effectivePrevious?.digest??null,visibility,rule_id:ruleId,rule_revision:ruleRevision};
|
|
1173
1230
|
// Only what the server does not hold goes over the wire: the longest prefix this source last committed unchanged
|
|
1174
1231
|
// (the outbox is empty here, so that is the session's current version) is named by its digest, not sent again.
|
|
1175
|
-
const carried=this.carriedPrefix(effectivePrevious,messages,{ruleRevision,backfillMode:backfillPolicy.mode,clearBaseline});
|
|
1232
|
+
const carried=recapturing?0:this.carriedPrefix(effectivePrevious,messages,{ruleRevision,backfillMode:backfillPolicy.mode,clearBaseline});
|
|
1176
1233
|
if(carried)begin.carry={count:carried,digest:carryDigest(messages.slice(0,carried))};
|
|
1177
1234
|
const sent=carried?messages.slice(carried):messages;
|
|
1178
1235
|
const parts=captureParts(captureId,sent);
|
|
@@ -1199,9 +1256,9 @@ export class TeamsConnector {
|
|
|
1199
1256
|
// translated for this capture alone: its spilled files live while the capture is queued (outbox-only)
|
|
1200
1257
|
try{
|
|
1201
1258
|
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))};
|
|
1259
|
+
if(fileStamp(statSync(artifactSource))===onDisk)bundle={stamp:onDisk,...await this.spillTranslated(translated,basename(artifactSource),basis)};
|
|
1203
1260
|
}catch(error){
|
|
1204
|
-
if(this.abort.signal.aborted)throw error;
|
|
1261
|
+
if(this.abort.signal.aborted||['redaction_basis_changed','custody_unreadable'].includes(error?.code))throw error;
|
|
1205
1262
|
this.lastError=error;process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} published without its native bundle: ${error.message}\n`);
|
|
1206
1263
|
// The same file is not translated again; a write to it clears this.
|
|
1207
1264
|
if(this.sessionLive(key,ruleId,epoch)){this.state.native_failed[key]=onDisk;this.save();}
|
|
@@ -1211,17 +1268,21 @@ export class TeamsConnector {
|
|
|
1211
1268
|
if(bundle&&fileStamp(statSync(artifactSource))!==onDisk)bundle=null;
|
|
1212
1269
|
if(bundle)({artifacts,nativeBundle}=this.bundleArtifacts(bundle,harness));else nativeDeferred=true;
|
|
1213
1270
|
// 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;}
|
|
1271
|
+
if(unchanged&&!nativeBundle&&!recapturing){requireRedactionBasis(basis);this.origins[key]={...previous,redaction_basis:checkedRedactionBasis(basis)};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);this.armNativeIdle(key);return null;}
|
|
1215
1272
|
}else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
|
|
1216
1273
|
const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
|
|
1217
1274
|
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
|
|
1275
|
+
({artifacts,nativeBundle}=this.bundleArtifacts(await this.spillTranslated(translated,`${nativeSessionId}.json`,basis),harness));
|
|
1219
1276
|
}
|
|
1220
|
-
|
|
1277
|
+
// Descriptive metadata shares the capture's custody snapshot too. Structural identifiers cannot be rewritten;
|
|
1278
|
+
// the request guard refuses them if they themselves contain a custody value.
|
|
1279
|
+
this.redactor??=new Redactor();const [metadata]=await this.redactor.run('values',[{title:descriptor?.title??null,coverage:effectivePrevious?.baseline&&!clearBaseline?{...coverage,completeness:'partial',reasons:[...new Set([...coverage.reasons,'prefix_not_captured'])]}:coverage}],{basis,fresh:true});
|
|
1280
|
+
const recordedTimes=messages.map(message=>message.recorded_at).filter(Number.isSafeInteger),commit={capture_id:captureId,parts:parts.length,digest:'',...metadata,repository:null,reported_user:null,recorded_activity_at:recordedTimes.length?Math.max(...recordedTimes):null,parent_native_id:descriptor?.parent_session_id??null,artifacts:artifacts.map(item=>item.manifest),...(nativeBundle?{native_bundle:nativeBundle}:{})};
|
|
1221
1281
|
const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
|
|
1222
1282
|
// A session gone or no longer covered while it loaded queues nothing (its spilled bundle, held by nothing, goes at the next start).
|
|
1223
1283
|
if(!this.sessionLive(key,ruleId,epoch))return null;
|
|
1224
|
-
|
|
1284
|
+
requireRedactionBasis(basis);
|
|
1285
|
+
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
1286
|
}
|
|
1226
1287
|
/**
|
|
1227
1288
|
* One session's oldest observation published. A session Teams refused (lineage not declared yet, or any error) is
|
|
@@ -1318,13 +1379,13 @@ export class TeamsConnector {
|
|
|
1318
1379
|
*/
|
|
1319
1380
|
scheduleFlush(error){
|
|
1320
1381
|
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
|
|
1382
|
+
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
1383
|
this.flushBackoff=Math.min(300000,(this.flushBackoff??2500)*2);
|
|
1323
1384
|
this.flushTimer=setTimeout(()=>{this.flushTimer=null;void this.flush().catch(failure=>{this.lastError=failure;this.scheduleFlush(failure);});},this.flushBackoff);this.flushTimer.unref?.();
|
|
1324
1385
|
}
|
|
1325
1386
|
/**
|
|
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
|
|
1387
|
+
* A capture that failed on the connection or a changed redaction basis, wherever it ran (a pass, a floor's wake,
|
|
1388
|
+
* 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
1389
|
* for it even when nothing is queued.
|
|
1329
1390
|
*/
|
|
1330
1391
|
oweCapture(options,error){
|
|
@@ -1341,7 +1402,7 @@ export class TeamsConnector {
|
|
|
1341
1402
|
async flushSerial(){
|
|
1342
1403
|
let receipt;
|
|
1343
1404
|
try{receipt=await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}
|
|
1344
|
-
//
|
|
1405
|
+
// Every owed session is re-read, including one whose redaction basis changed before queueing.
|
|
1345
1406
|
if(this.owed?.size){const owed=[...this.owed];this.owed.clear();for(const key of owed)this.resee(key);}
|
|
1346
1407
|
return receipt;
|
|
1347
1408
|
}
|
|
@@ -1405,19 +1466,19 @@ export class TeamsConnector {
|
|
|
1405
1466
|
item.commit={...item.commit,capture_id:captureId,parts:item.parts.length};
|
|
1406
1467
|
const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.rewriteQueued(item);this.save();
|
|
1407
1468
|
}
|
|
1408
|
-
async sendCapture(item){const begun=await
|
|
1469
|
+
async sendCapture(item){const request=(method,path,body)=>this.request(method,path,body,{beforeTransfer:json=>this.guardCaptureTransfer(item,json)});const begun=await request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);
|
|
1409
1470
|
// A capture queued by an older connector holds its chunks inline; a current one reads them from its spill file.
|
|
1410
1471
|
for(const artifact of item.artifacts??[])for(const chunk of artifact.spill?this.spilledChunks(artifact):artifact.chunks){
|
|
1411
1472
|
if(!chunk)throw Object.assign(new Error('a spilled native artifact changed on disk while it was sent'),{code:'resync_required'});
|
|
1412
1473
|
const path=`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`;
|
|
1413
1474
|
let reused=false;
|
|
1414
1475
|
if(this.chunkReuse!==false)try{
|
|
1415
|
-
await
|
|
1476
|
+
await request('PUT',path,{upload_id:chunk.upload_id,index:chunk.index,sha256:chunk.sha256,reuse:true});reused=true;this.chunkReuse=true;
|
|
1416
1477
|
}catch(error){
|
|
1417
1478
|
if(error.code==='invalid_request'&&error.status===400)this.chunkReuse=false; // older server
|
|
1418
1479
|
else if(error.code!=='chunk_missing')throw error;
|
|
1419
1480
|
}
|
|
1420
|
-
if(!reused)await
|
|
1481
|
+
if(!reused)await request('PUT',path,chunk);
|
|
1421
1482
|
this.state.native_transfer??={sent_bytes:0,reused_bytes:0,chunks:0};
|
|
1422
1483
|
this.state.native_transfer[reused?'reused_bytes':'sent_bytes']+=Buffer.byteLength(chunk.bytes_base64,'base64');
|
|
1423
1484
|
this.state.native_transfer.chunks++;
|
|
@@ -1428,7 +1489,7 @@ export class TeamsConnector {
|
|
|
1428
1489
|
transfer.chunks++;transfer.at=this.clock.nowMs();
|
|
1429
1490
|
for(const expired of Object.keys(transfers).sort((a,b)=>transfers[b].at-transfers[a].at).slice(128))delete transfers[expired];
|
|
1430
1491
|
}
|
|
1431
|
-
const receipt=await
|
|
1492
|
+
const receipt=await request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);
|
|
1432
1493
|
const transfer=this.state.native_transfers?.[`${item.begin.harness}:${item.begin.native_session_id}`];
|
|
1433
1494
|
if(transfer&&item.artifacts?.length){transfer.captures++;transfer.last_bundle_bytes=item.commit.artifacts.reduce((sum,a)=>sum+a.byte_length,0);}
|
|
1434
1495
|
return receipt;}
|
|
@@ -1443,15 +1504,33 @@ export class TeamsConnector {
|
|
|
1443
1504
|
if(!Array.isArray(configuration?.rules))return false;
|
|
1444
1505
|
return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
|
|
1445
1506
|
}
|
|
1446
|
-
async flushOutbox(){let latest=null;while(this.outbox.length){const item=this.outbox[0];let receipt;this.dropLostArtifacts(item);this.refitParts(item);
|
|
1507
|
+
async flushOutbox(){let latest=null;while(this.outbox.length){const item=this.outbox[0];let receipt;try{this.requireCaptureTransfer(item);this.dropLostArtifacts(item);this.refitParts(item);receipt=await this.sendCapture(item);}catch(error){
|
|
1508
|
+
// A failed request may have awaited long enough to observe a different policy. Retain its whole original
|
|
1509
|
+
// before an existing server-error branch can drop a part, native reference or queued item.
|
|
1510
|
+
if(!['redaction_basis_changed','redaction_capture_unsafe'].includes(error.code))try{this.requireCaptureTransfer(item);}catch(changed){error=changed;}
|
|
1511
|
+
if(['redaction_basis_changed','redaction_capture_unsafe'].includes(error.code)){this.holdQueuedCapture(item,error);continue;}
|
|
1512
|
+
if(error.code==='storage_pressure'&&this.deferNativeUnderPressure(item))continue;
|
|
1513
|
+
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 is sent whole again\n`);this.unqueue();if(this.origins[key])this.origins[key]={...this.origins[key],observed_keys:[],observed_digests:[],carry_refused:true,capture_stamps:{}};this.save();this.resee(key);continue;}if(error.status===409&&error.code==='batch_gap'&&error.details&&Number.isSafeInteger(error.details.sequence)){
|
|
1447
1514
|
// The server holds a different last capture of this session than this connector recorded (a capture it
|
|
1448
1515
|
// committed while the connector stopped before recording it, for one): this connector takes the server's
|
|
1449
1516
|
// revision, drops the stale capture and sends the session whole again at once (resee).
|
|
1450
1517
|
const key=`${item.begin.harness}:${item.begin.native_session_id}`,expected=error.details;this.lastError=error;
|
|
1451
1518
|
process.stderr.write(`supercode teams: ${item.begin.harness} session ${item.begin.native_session_id} resumes at the server's revision ${expected.generation}/${expected.sequence}; it is sent whole again\n`);
|
|
1452
1519
|
this.unqueue();this.origins[key]={...this.origins[key],generation:expected.generation,sequence:expected.sequence-1,digest:expected.digest??null,observed_keys:[],observed_digests:[],carry_refused:true,capture_stamps:{}};this.save();this.resee(key);continue;}
|
|
1453
|
-
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}`);if(error.status===413)(this.state.set_aside??={})[`${item.begin.harness}:${item.begin.native_session_id}`]={at:this.clock.nowMs(),rule_revision:item.begin.rule_revision??null,reason:error.message};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.unqueue();this.save();continue;}
|
|
1454
|
-
this.
|
|
1520
|
+
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}`);if(error.status===413)(this.state.set_aside??={})[`${item.begin.harness}:${item.begin.native_session_id}`]={at:this.clock.nowMs(),rule_revision:item.begin.rule_revision??null,reason:error.message};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.unqueue();this.save();continue;}
|
|
1521
|
+
const rejected=await this.ruleRejected(item,error);
|
|
1522
|
+
try{this.requireCaptureTransfer(item);}catch(changed){if(['redaction_basis_changed','redaction_capture_unsafe'].includes(changed.code)){this.holdQueuedCapture(item,changed);continue;}throw changed;}
|
|
1523
|
+
if(!rejected)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.unqueue();this.save();continue;}
|
|
1524
|
+
// A commit already issued may finish after custody changes. Its receipt advances the origin, but its local
|
|
1525
|
+
// original remains held and owed; registration gives this source no authority to purge the published revision.
|
|
1526
|
+
let current=true;
|
|
1527
|
+
try{this.requireCaptureTransfer(item);}catch(error){if(!['redaction_basis_changed','redaction_capture_unsafe'].includes(error.code))throw error;this.holdQueuedCapture(item,error);current=false;}
|
|
1528
|
+
const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.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,redaction_basis:item.redactionBasis??null,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.windowStart)next.window_start=item.windowStart;else delete next.window_start;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.origins[key]=next;
|
|
1529
|
+
if(current){
|
|
1530
|
+
if(receipt?.capture_id===item.begin.capture_id&&receipt.digest===item.commit.digest)this.redactionHolds.settle(key,item,receipt,names=>this.releaseSpills(names));
|
|
1531
|
+
this.updateRedactionHolds();this.unqueue();this.clearSessionProblem(item.begin.harness,item.begin.native_session_id);
|
|
1532
|
+
}
|
|
1533
|
+
this.save();
|
|
1455
1534
|
// The commit is what records (or settles) a native bundle still owed: its wait is armed here.
|
|
1456
1535
|
this.armNativeIdle(key);latest=receipt;}this.flushBackoff=null;return latest;}
|
|
1457
1536
|
stop(){void this.redactor?.close();this.daemon?.off?.('sessionActivityEvent',this.activityEvent);this.daemon?.off?.('sessionIndexEvent',this.indexEvent);this.daemon?.off?.('exit',this.daemonExit);clearTimeout(this.flushTimer);if(this.activitySubscription)void this.daemon?.unsubscribeSessionActivity?.(this.activitySubscription).catch(()=>{});for(const feed of this.feeds?.values()??[])void this.daemon?.unsubscribeSessionIndex?.(feed.subscription).catch(()=>{});for(const timer of this.idleTimers?.values()??[])clearTimeout(timer);try{this.flushState();}catch{}if(this.timer)clearInterval(this.timer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();return this.nativeEffects.close();}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Stale captures stay on this machine while a fresh source observation replaces them. Only headers stay in memory;
|
|
2
|
+
// the original parts and artifact references remain in owner-only files and are never a send queue.
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export class RedactionHolds {
|
|
7
|
+
constructor({ stateDir, atomic, saved = {} }) {
|
|
8
|
+
this.directory = join(stateDir, 'redaction-held'); this.outbox = join(stateDir, 'outbox');
|
|
9
|
+
this.atomic = atomic; this.items = new Map(); this.unread = [];
|
|
10
|
+
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
|
|
11
|
+
for (const name of readdirSync(this.directory).filter(name => /^\d+\.json$/.test(name)).sort()) {
|
|
12
|
+
try {
|
|
13
|
+
const item = JSON.parse(readFileSync(join(this.directory, name), 'utf8'));
|
|
14
|
+
const header = this.header(item, name), prior = saved[name];
|
|
15
|
+
if (prior?.capture_id === header.capture_id && ['native_owed', 'cleanup', 'blocked'].includes(prior.state)) Object.assign(header, { state: prior.state, replacement: prior.replacement });
|
|
16
|
+
this.items.set(name, header);
|
|
17
|
+
} catch (error) { this.unread.push({ name, error: error?.code ?? 'unreadable_hold' }); }
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
header(item, name) {
|
|
22
|
+
return { name, capture_id: item.begin.capture_id, key: `${item.begin.harness}:${item.begin.native_session_id}`,
|
|
23
|
+
harness: item.begin.harness, session_id: item.begin.native_session_id, rule_id: item.begin.rule_id,
|
|
24
|
+
rule_revision: item.begin.rule_revision, at: item.redaction_hold?.at ?? null, state: item.redaction_hold?.code==='redaction_capture_unsafe'?'blocked':'owed',
|
|
25
|
+
stamp: item.stamp??null, basis: item.redactionBasis, code: item.redaction_hold?.code??'redaction_basis_changed',
|
|
26
|
+
native: Boolean(item.artifacts?.length || item.nativeDeferred),
|
|
27
|
+
spills: (item.artifacts ?? []).filter(artifact => artifact.spill).map(artifact => artifact.spill.file) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
hold(item, name, at, code) {
|
|
31
|
+
// Persist the no-send flag before moving: a crash between these steps restarts with a flagged outbox item, which
|
|
32
|
+
// the transfer guard also refuses. The move transfers its spill ownership; it must not release any references.
|
|
33
|
+
item.redaction_hold = { at, code };
|
|
34
|
+
this.atomic(join(this.outbox, name), JSON.stringify(item));
|
|
35
|
+
if(existsSync(join(this.directory,name)))throw new Error('a custody-held capture already owns this filename; original outbox item retained');
|
|
36
|
+
renameSync(join(this.outbox, name), join(this.directory, name));
|
|
37
|
+
this.items.set(name, this.header(item, name));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
needed(key) { return [...this.items.values()].some(item => item.key === key && ['owed','blocked'].includes(item.state)); }
|
|
41
|
+
blocked(key, stamp, revision, basis) { return [...this.items.values()].some(item => item.key === key && item.state === 'blocked' && item.stamp === stamp && item.rule_revision === revision && item.basis === basis); }
|
|
42
|
+
spills() { return [...this.items.values()].flatMap(item => item.spills); }
|
|
43
|
+
saved() { return Object.fromEntries(this.items); }
|
|
44
|
+
status() { return [...this.items.values()].map(({ capture_id, harness, session_id, rule_id, at, state, replacement }) => ({ capture_id, harness, session_id, rule_id, at, state, replacement })); }
|
|
45
|
+
|
|
46
|
+
settle(key, capture, receipt, releaseSpills) {
|
|
47
|
+
for (const [name, held] of this.items) {
|
|
48
|
+
if (held.key !== key || held.rule_id !== capture.begin.rule_id || held.rule_revision !== capture.begin.rule_revision) continue;
|
|
49
|
+
if(capture.begin.carry&&['owed','blocked'].includes(held.state))continue;
|
|
50
|
+
held.replacement = receipt.capture_id;
|
|
51
|
+
// A receipt for messages alone does not discharge a held native bundle. The normal native-deferred retry
|
|
52
|
+
// remains owed; its original files stay held until a replacement artifact is acknowledged too.
|
|
53
|
+
if (held.native && !(capture.artifacts?.length)) { held.state = 'native_owed'; continue; }
|
|
54
|
+
try { unlinkSync(join(this.directory, name)); }
|
|
55
|
+
catch (error) { if (error?.code !== 'ENOENT') { held.state = 'cleanup'; continue; } }
|
|
56
|
+
this.items.delete(name); releaseSpills(held.spills);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/connector/redactor.mjs
CHANGED
|
@@ -4,26 +4,40 @@
|
|
|
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
|
-
|
|
22
|
+
const holdsCustody = (value, custody) => {
|
|
23
|
+
if (typeof value === 'string') return custody.some(({ value: secret }) => value.includes(secret));
|
|
24
|
+
if (Array.isArray(value)) return value.some((item) => holdsCustody(item, custody));
|
|
25
|
+
return value && typeof value === 'object' && Object.entries(value).some(([key, item]) => holdsCustody(key, custody) || holdsCustody(item, custody));
|
|
26
|
+
};
|
|
27
|
+
parentPort.on('message', ({ id, kind, items, basis, fresh }) => {
|
|
23
28
|
// Its own busy time rides back with each answer: what redaction costs, apart from the connector's other work.
|
|
24
29
|
const started = performance.now();
|
|
25
|
-
|
|
26
|
-
|
|
30
|
+
const requireBasis=()=>{if(basis!==undefined&&redactionBasis(undefined,{fresh})!==basis)throw Object.assign(new Error('redaction worker basis differs from the capture; capture needs fresh redaction'),{code:'redaction_basis_changed'});};
|
|
31
|
+
try {
|
|
32
|
+
// All strings in this checked batch use the same custody observation, even if a batch crosses its refresh interval.
|
|
33
|
+
const snapshot=basis===undefined?null:redactionSnapshot(undefined,{fresh});
|
|
34
|
+
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'});
|
|
35
|
+
requireBasis();
|
|
36
|
+
if(kind==='custody_assert'&&(!snapshot||items.some(text=>holdsCustody(JSON.parse(text),snapshot.values))))throw Object.assign(new Error('capture still contains a registered custody value; fresh capture required'),{code:'redaction_capture_unsafe'});
|
|
37
|
+
const answer = kind === 'custody_assert' ? [] : kind === 'canonical' ? changedOf(items,snapshot?.values) : items.map((item) => (kind === 'texts' ? redactText(item,undefined,snapshot?.values) : redactValue(item,undefined,snapshot?.values)));
|
|
38
|
+
requireBasis();
|
|
39
|
+
parentPort.postMessage({ id, items: answer, busy: performance.now() - started });
|
|
40
|
+
} catch (error) { parentPort.postMessage({ id, error: String(error?.message ?? error), code: error?.code }); }
|
|
27
41
|
});
|
|
28
42
|
}
|
|
29
43
|
|
|
@@ -33,11 +47,11 @@ export class Redactor {
|
|
|
33
47
|
start() {
|
|
34
48
|
// The connector's own node flags are not the worker's (one meant for the entry script refuses to start it).
|
|
35
49
|
const worker = new Worker(new URL(import.meta.url), { execArgv: [] });
|
|
36
|
-
worker.on('message', ({ id, items, error, busy }) => {
|
|
50
|
+
worker.on('message', ({ id, items, error, code, busy }) => {
|
|
37
51
|
const waiter = this.waiting.get(id); if (!waiter) return;
|
|
38
52
|
this.waiting.delete(id);
|
|
39
53
|
this.workerMs += busy ?? 0;
|
|
40
|
-
if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: 'redaction_failed' }));
|
|
54
|
+
if (error) waiter.reject(Object.assign(new Error(`redaction failed: ${error}`), { code: ['redaction_basis_changed','redaction_capture_unsafe','custody_unreadable'].includes(code) ? code : 'redaction_failed' }));
|
|
41
55
|
else waiter.resolve(items);
|
|
42
56
|
if (!this.waiting.size) worker.unref();
|
|
43
57
|
});
|
|
@@ -54,13 +68,15 @@ export class Redactor {
|
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
/** `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
|
-
|
|
71
|
+
* JSON texts) only the changed ones, as `{ index, value, digest }`. An optional basis must match the worker before
|
|
72
|
+
* and after redaction; a mismatch rejects the request rather than returning stale decisions. `custody_assert`
|
|
73
|
+
* accepts serialized request bodies and refuses a custody value anywhere in their parsed strings or keys. */
|
|
74
|
+
run(kind, items, { basis, fresh = false } = {}) {
|
|
59
75
|
if (!items.length) return Promise.resolve([]);
|
|
60
76
|
if (!this.worker) this.start();
|
|
61
77
|
const id = this.next++;
|
|
62
78
|
this.worker.ref();
|
|
63
|
-
return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items }); });
|
|
79
|
+
return new Promise((resolve, reject) => { this.waiting.set(id, { resolve, reject }); this.worker.postMessage({ id, kind, items, basis, fresh }); });
|
|
64
80
|
}
|
|
65
81
|
|
|
66
82
|
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.159",
|
|
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.44",
|
|
79
79
|
"@xterm/addon-serialize": "^0.14.0",
|
|
80
80
|
"@xterm/headless": "^6.0.0",
|
|
81
81
|
"protobufjs": "8.8.0",
|