@volter/supercode-teams 0.2.24 → 0.2.25
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/bin/workspace.mjs +6 -1
- package/connector/index.d.ts +6 -0
- package/connector/index.mjs +198 -34
- package/connector/native-continuation.mjs +2 -2
- package/package.json +2 -2
- package/server/otlp/wire-codec.mjs +6 -2
package/bin/workspace.mjs
CHANGED
|
@@ -865,4 +865,9 @@ Use --help with a command for its required arguments and options.
|
|
|
865
865
|
`);
|
|
866
866
|
}
|
|
867
867
|
export function errorExit(error) { return error.exitCode ?? ERROR_EXITS[error.code] ?? 1; }
|
|
868
|
-
if (import.meta.url === new URL(`file://${process.argv[1]}`).href) workspaceMain().catch(error => { process.stderr.write(`${JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } })}\n`); process.
|
|
868
|
+
if (import.meta.url === new URL(`file://${process.argv[1]}`).href) workspaceMain().catch(error => { process.stderr.write(`${JSON.stringify({ version: 1, error: { code: error.code ?? 'invalid_request', message: error.message } })}\n`); process.exitCode = errorExit(error);
|
|
869
|
+
// Drain rather than process.exit(): on Windows, exiting while a just-used fetch connection is still closing aborts
|
|
870
|
+
// Node inside libuv (src\win\async.c, `!(handle->flags & UV_HANDLE_CLOSING)`) and loses the exit code. Whatever
|
|
871
|
+
// still holds the event loop open gets a moment, then the exit is forced.
|
|
872
|
+
setTimeout(() => process.exit(process.exitCode), 2_000).unref();
|
|
873
|
+
});
|
package/connector/index.d.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface TeamsConnectorOptions {
|
|
|
36
36
|
effectExecutor?: (effect: NativeEffect, context: {core: ConnectorCore; signal: AbortSignal}) => Promise<{native_session_id?: string | null; runtime_id?: string | null} | void>;
|
|
37
37
|
clock?: ConnectorClock;
|
|
38
38
|
fetch?: typeof globalThis.fetch;
|
|
39
|
+
/** Milliseconds between heartbeats, sent on their own timer independent of the capture pass (default 15000). */
|
|
40
|
+
heartbeatMs?: number;
|
|
41
|
+
/** A native file at least nativeIdleBytes long gets its native bundle only after this long without a write (default 60000). */
|
|
42
|
+
nativeIdleMs?: number;
|
|
43
|
+
/** Size from which a native file's bundle waits for it to be idle (default 8 MiB). */
|
|
44
|
+
nativeIdleBytes?: number;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
export interface CaptureOptions {
|
package/connector/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, realpathSync, statSync, openSync, closeSync, fsyncSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, realpathSync, statSync, openSync, closeSync, fsyncSync, unlinkSync, readSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import { randomBytes, createHash } from 'node:crypto';
|
|
4
4
|
import { canonicalJson } from '../contracts/index.mjs';
|
|
@@ -10,12 +10,15 @@ const SYNC_PROBLEM_LIMIT=20;
|
|
|
10
10
|
// core.mjs MAX_PENDING_RUNTIME_OPENS; more reject with SUBSCRIPTION_OPEN_CAPACITY) and every live follow is a follower
|
|
11
11
|
// the service polls, while a subtree rule can cover hundreds of sessions. Only the most recently active sessions (at
|
|
12
12
|
// most FOLLOW_LIMIT, half the SDK's limit for headroom, active within FOLLOW_ACTIVE_WINDOW_MS) are followed live; the
|
|
13
|
-
// rest are covered by each beat's stamp-based capture. Followers rotate as activity changes
|
|
14
|
-
|
|
13
|
+
// rest are covered by each beat's stamp-based capture. Followers rotate as activity changes, with hysteresis: a
|
|
14
|
+
// follower is kept while it stays in the window and ranks within FOLLOW_KEEP_RANK, so sessions near the cutoff do not
|
|
15
|
+
// stop and reopen (each reopen a full load and capture) on alternate beats; new follows only fill up to FOLLOW_LIMIT.
|
|
16
|
+
const FOLLOW_LIMIT=32,FOLLOW_KEEP_RANK=40,FOLLOW_ACTIVE_WINDOW_MS=30*60*1000,FOLLOW_RETRY_MS=60*1000;
|
|
15
17
|
const CAPACITY_CODE='SUBSCRIPTION_OPEN_CAPACITY';
|
|
16
18
|
const capacityError=error=>error?.code===CAPACITY_CODE||/Too many unresolved subscriptions/.test(String(error?.message??''));
|
|
17
19
|
// An error of the connection itself (the outbox, authentication, the server), not of one session.
|
|
18
20
|
const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
|
|
21
|
+
const sameError=(a,b)=>a!=null&&b!=null&&a.code===b.code&&a.message===b.message;
|
|
19
22
|
const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='string'?error.code:null,message:String(error.message??error).slice(0,500),at};
|
|
20
23
|
/** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
|
|
21
24
|
export function readSyncStatus(stateDir){
|
|
@@ -49,6 +52,15 @@ function settledToolTail(previous,messages){
|
|
|
49
52
|
return messages.slice(0,count).map(digestOf);
|
|
50
53
|
}
|
|
51
54
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
55
|
+
const CHUNK_BYTES=1024*1024;
|
|
56
|
+
// A native file at least NATIVE_IDLE_BYTES long gets its native bundle only once it has gone NATIVE_IDLE_MS
|
|
57
|
+
// without a write. Translating reads the whole file, takes minutes for a 100 MB transcript, and holds the
|
|
58
|
+
// harness server's only request reader while it runs; a live Claude Code transcript is appended to on every
|
|
59
|
+
// message and tool result, so translating it mid-turn is wasted (the file has moved by the time it finishes).
|
|
60
|
+
// A minute is four beats with no write: the turn is over, or waiting on the user or one long tool call.
|
|
61
|
+
// Smaller files translate in well under a second and keep getting a bundle with every capture.
|
|
62
|
+
const NATIVE_IDLE_MS=60000,NATIVE_IDLE_BYTES=8*1024*1024;
|
|
63
|
+
const fileStamp=stat=>`${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
|
|
52
64
|
const recordedAt=entry=>{if(Number.isSafeInteger(entry.recorded_at))return entry.recorded_at;if(Number.isSafeInteger(entry.timestamp))return entry.timestamp;const value=entry.metadata?.timestamp??entry.value?.metadata?.timestamp??entry.timestamp;if(typeof value!=='string')return null;const parsed=Date.parse(value);return Number.isSafeInteger(parsed)?parsed:null;};
|
|
53
65
|
const serverOrigin=value=>{const url=new URL(value),loopback=url.hostname==='localhost'||url.hostname==='::1'||url.hostname==='[::1]'||/^127(?:\.\d{1,3}){3}$/.test(url.hostname);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||(url.pathname!==''&&url.pathname!=='/')||(url.protocol==='http:'&&!loopback))throw new TypeError('server must be an HTTPS origin (HTTP is loopback-only)');return url.origin;};
|
|
54
66
|
function atomic(path, value) {
|
|
@@ -67,19 +79,24 @@ function atomic(path, value) {
|
|
|
67
79
|
}
|
|
68
80
|
}
|
|
69
81
|
export class TeamsConnector {
|
|
70
|
-
constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};if(Array.isArray(this.state.sync_status?.problems))this.state.sync_status.problems=this.state.sync_status.problems.filter(item=>item?.code!==CAPACITY_CODE);this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
|
|
82
|
+
constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch,heartbeatMs=15000,nativeIdleMs=NATIVE_IDLE_MS,nativeIdleBytes=NATIVE_IDLE_BYTES}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl,heartbeatMs,nativeIdleMs,nativeIdleBytes});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};if(Array.isArray(this.state.sync_status?.problems))this.state.sync_status.problems=this.state.sync_status.problems.filter(item=>item?.code!==CAPACITY_CODE);this.state.native_cache??={};this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
|
|
71
83
|
save(){atomic(this.statePath,this.state);}
|
|
72
84
|
// What `supercode teams status` shows about this connector: its last error, and the sessions it could not capture
|
|
73
85
|
// (most recent first, bounded; an entry clears when that session next captures). They are kept in connector.json,
|
|
74
86
|
// so another process reads them while this one runs; the error itself stays in memory as lastError.
|
|
75
87
|
get lastError(){return this.lastErrorValue;}
|
|
76
|
-
|
|
88
|
+
// The same error (code and message) again changes nothing on disk, so it is not saved again: an error that repeats
|
|
89
|
+
// every beat does not rewrite connector.json every beat. Its time stays that of its first occurrence in the run.
|
|
90
|
+
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();}
|
|
77
91
|
syncStatus(){const status=this.state.sync_status??={problems:[],last_error:null};status.problems??=[];return status;}
|
|
78
92
|
saveQuietly(){try{this.save();}catch{}}
|
|
79
93
|
noteSessionProblem({harness,nativeSessionId,ruleId=null},error){
|
|
80
|
-
const status=this.syncStatus(),at=this.clock.nowMs();
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
const status=this.syncStatus(),at=this.clock.nowMs(),summary=errorSummary(error,at);
|
|
95
|
+
// The same session failing the same way on every beat keeps its entry where it is.
|
|
96
|
+
const listed=status.problems.some(item=>item.harness===harness&&item.session_id===nativeSessionId&&item.rule_id===ruleId&&sameError(item,summary));
|
|
97
|
+
if(!listed)status.problems=[{harness,session_id:nativeSessionId,rule_id:ruleId,...summary},...status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId))].slice(0,SYNC_PROBLEM_LIMIT);
|
|
98
|
+
this.lastErrorValue=error;const known=sameError(status.last_error,summary);if(!known)status.last_error=summary;
|
|
99
|
+
if(!listed||!known)this.saveQuietly();
|
|
83
100
|
}
|
|
84
101
|
clearSessionProblem(harness,nativeSessionId){
|
|
85
102
|
const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
|
|
@@ -143,7 +160,7 @@ export class TeamsConnector {
|
|
|
143
160
|
this.heartbeatTimer = setInterval(() => {
|
|
144
161
|
if (this.abort.signal.aborted) return;
|
|
145
162
|
void heartbeat().catch(error => { this.lastError = error; });
|
|
146
|
-
},
|
|
163
|
+
}, this.heartbeatMs);
|
|
147
164
|
return this.configurationValue;
|
|
148
165
|
}
|
|
149
166
|
async effectFetch(method, path, {body, contentType='application/json', raw=false, freshKey=false}={}) {
|
|
@@ -175,7 +192,7 @@ export class TeamsConnector {
|
|
|
175
192
|
// A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
|
|
176
193
|
// reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
|
|
177
194
|
// machine; rules over the same root and harnesses share the scan.
|
|
178
|
-
const discoveries=new Map(),followCandidates=[];
|
|
195
|
+
const discoveries=new Map(),followCandidates=[],discovered=new Set();
|
|
179
196
|
for(const rule of activeRules.values()){
|
|
180
197
|
const root=roots.find(item=>item.id===rule.selector.root_id);
|
|
181
198
|
if(!root)continue;
|
|
@@ -195,6 +212,7 @@ export class TeamsConnector {
|
|
|
195
212
|
for(const descriptor of descriptors){
|
|
196
213
|
const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
|
|
197
214
|
if(!locator||!nativeSessionId||!harness)continue;
|
|
215
|
+
discovered.add(`${harness}:${nativeSessionId}`);
|
|
198
216
|
const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:workspace,configuration};
|
|
199
217
|
// A session whose native store has not changed since this rule last captured it is not reloaded.
|
|
200
218
|
// One session that cannot be captured (a transcript that rewrote its history needs an explicit resync)
|
|
@@ -213,6 +231,7 @@ export class TeamsConnector {
|
|
|
213
231
|
}
|
|
214
232
|
}
|
|
215
233
|
this.nativeEffects.endDiscovery();
|
|
234
|
+
this.pruneNativeCache(discovered);
|
|
216
235
|
this.rotateFollowers(followCandidates);
|
|
217
236
|
}
|
|
218
237
|
// When a session was last active: its descriptor's updated_at_ms, else its native file's modification time.
|
|
@@ -222,14 +241,16 @@ export class TeamsConnector {
|
|
|
222
241
|
if(path)try{activity=Math.max(activity??0,statSync(path).mtimeMs);}catch{}
|
|
223
242
|
return activity;
|
|
224
243
|
}
|
|
225
|
-
// Follows the
|
|
226
|
-
//
|
|
244
|
+
// Follows the most recently active candidates (active within the window): a follower is kept while it ranks
|
|
245
|
+
// within FOLLOW_KEEP_RANK, every other one stops, and the FOLLOW_LIMIT most recent open new follows while fewer
|
|
246
|
+
// than FOLLOW_LIMIT are live. A capacity refusal is not a session's failure: the connector backs off for a while.
|
|
227
247
|
rotateFollowers(candidates){
|
|
228
|
-
const now=this.clock.nowMs(),seen=new Set(),
|
|
248
|
+
const now=this.clock.nowMs(),seen=new Set(),ranked=[];
|
|
229
249
|
for(const candidate of candidates.filter(item=>item.activity!==null&&now-item.activity<=FOLLOW_ACTIVE_WINDOW_MS).sort((a,b)=>b.activity-a.activity)){
|
|
230
250
|
if(seen.has(candidate.key)||typeof this.core.session(candidate.options.locator).follow!=='function')continue;
|
|
231
|
-
seen.add(candidate.key);
|
|
251
|
+
seen.add(candidate.key);ranked.push(candidate);if(ranked.length>=FOLLOW_KEEP_RANK)break;
|
|
232
252
|
}
|
|
253
|
+
const wanted=ranked.slice(0,FOLLOW_LIMIT);
|
|
233
254
|
for(const key of this.followers.keys())if(!seen.has(key))this.stopFollower(key);
|
|
234
255
|
for(const [key,until] of this.followRetry)if(until<=now)this.followRetry.delete(key);
|
|
235
256
|
for(const {key,options} of wanted){
|
|
@@ -282,8 +303,77 @@ export class TeamsConnector {
|
|
|
282
303
|
if(stamp===null||this.state.outbox.length)return true;
|
|
283
304
|
const rule=options.configuration?.rules?.find(item=>item.id===options.ruleId&&item.revision===options.ruleRevision&&item.state==='active');if(!rule)return true;
|
|
284
305
|
const configured=rule.backfill??options.backfill??'all',mode=typeof configured==='string'?configured:configured.mode;
|
|
285
|
-
const
|
|
286
|
-
|
|
306
|
+
const key=`${options.harness}:${options.nativeSessionId}`,origin=this.state.origins[key],seen=origin?.capture_stamps?.[options.ruleId];
|
|
307
|
+
if(!(seen&&seen.stamp===stamp&&seen.rule_revision===options.ruleRevision&&seen.backfill_mode===mode))return true;
|
|
308
|
+
// Published without its native bundle while it was live: captured again once it has been idle long enough.
|
|
309
|
+
if(origin?.native_deferred&&options.locator?.storage?.kind==='file')try{return this.nativeReady(key,statSync(realpathSync(options.locator.storage.path)));}catch{return false;}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
// Whether a capture may attach the native bundle of a file in this state: its translation is cached (and did
|
|
313
|
+
// not fail), or it is small, or it has been idle for nativeIdleMs.
|
|
314
|
+
nativeReady(key,stat){
|
|
315
|
+
const cached=this.state.native_cache[key];
|
|
316
|
+
if(cached?.stamp===fileStamp(stat))return !cached.failed;
|
|
317
|
+
return stat.size<this.nativeIdleBytes||this.clock.nowMs()-stat.mtimeMs>=this.nativeIdleMs;
|
|
318
|
+
}
|
|
319
|
+
// Native artifact bytes are spilled to content-addressed files under the state directory, never into
|
|
320
|
+
// connector.json: a 129 MB transcript was ~172 MB of base64 held in the outbox and rewritten on every save().
|
|
321
|
+
spillDir(){return join(this.stateDir,'artifacts');}
|
|
322
|
+
spill(bytes){
|
|
323
|
+
const sha256=hash(bytes),name=`${sha256}.bin`,path=join(this.spillDir(),name);
|
|
324
|
+
if(!existsSync(path)){
|
|
325
|
+
mkdirSync(this.spillDir(),{recursive:true,mode:0o700});
|
|
326
|
+
const temporary=`${path}.${process.pid}.${randomBytes(12).toString('hex')}.tmp`,fd=openSync(temporary,'wx',0o600);
|
|
327
|
+
try{writeFileSync(fd,bytes);fsyncSync(fd);}catch(error){closeSync(fd);try{unlinkSync(temporary);}catch{}throw error;}
|
|
328
|
+
closeSync(fd);renameSync(temporary,path);
|
|
329
|
+
}
|
|
330
|
+
const chunk_sha256=[];for(let at=0;at<bytes.length||chunk_sha256.length===0;at+=CHUNK_BYTES)chunk_sha256.push(hash(bytes.subarray(at,Math.min(bytes.length,at+CHUNK_BYTES))));
|
|
331
|
+
return {spill:name,sha256,byte_length:bytes.length,chunk_sha256};
|
|
332
|
+
}
|
|
333
|
+
openSpill(name){return openSync(join(this.spillDir(),name),'r');}
|
|
334
|
+
// Each chunk of a spilled artifact, read back one at a time; null when the file is gone or no longer matches.
|
|
335
|
+
*spilledChunks(artifact){
|
|
336
|
+
let fd;try{fd=this.openSpill(artifact.spill.file);}catch{yield null;return;}
|
|
337
|
+
try{
|
|
338
|
+
for(let index=0;index<artifact.manifest.chunks;index++){
|
|
339
|
+
const length=Math.max(0,Math.min(CHUNK_BYTES,artifact.manifest.byte_length-index*CHUNK_BYTES)),bytes=Buffer.alloc(length);
|
|
340
|
+
const read=length?readSync(fd,bytes,0,length,index*CHUNK_BYTES):0;
|
|
341
|
+
if(read!==length||hash(bytes)!==artifact.spill.chunk_sha256[index]){yield null;return;}
|
|
342
|
+
yield {upload_id:artifact.manifest.upload_id,index,sha256:artifact.spill.chunk_sha256[index],bytes_base64:bytes.toString('base64')};
|
|
343
|
+
}
|
|
344
|
+
}finally{closeSync(fd);}
|
|
345
|
+
}
|
|
346
|
+
// Spill files no queued capture and no cached translation refers to are removed.
|
|
347
|
+
pruneSpill(){
|
|
348
|
+
const kept=new Set();
|
|
349
|
+
for(const item of this.state.outbox)for(const artifact of item.artifacts??[])if(artifact.spill)kept.add(artifact.spill.file);
|
|
350
|
+
for(const cached of Object.values(this.state.native_cache))for(const file of cached.files??[])kept.add(file.spill);
|
|
351
|
+
let names=[];try{names=readdirSync(this.spillDir());}catch{return;}
|
|
352
|
+
for(const name of names)if(!kept.has(name)&&!name.endsWith('.tmp'))try{unlinkSync(join(this.spillDir(),name));}catch{}
|
|
353
|
+
}
|
|
354
|
+
// A cached translation of a session no active rule discovers any more (deleted, moved, its rule gone) is dropped
|
|
355
|
+
// with its spill files, so a deleted 129 MB transcript's bundle does not stay on disk for good.
|
|
356
|
+
pruneNativeCache(discovered){
|
|
357
|
+
// A beat that discovers nothing while translations are cached (a home on a drive that is briefly offline) says
|
|
358
|
+
// nothing about those sessions, so it keeps them rather than re-translating every large session later.
|
|
359
|
+
if(!discovered.size&&Object.keys(this.state.native_cache).length)return;
|
|
360
|
+
const stale=Object.keys(this.state.native_cache).filter(key=>!discovered.has(key));if(!stale.length)return;
|
|
361
|
+
for(const key of stale)delete this.state.native_cache[key];
|
|
362
|
+
this.save();this.pruneSpill();
|
|
363
|
+
}
|
|
364
|
+
// A translated bundle, spilled, in the form the cache keeps: one entry per file, primary first or not.
|
|
365
|
+
spillTranslated(translated,defaultName){
|
|
366
|
+
const files=translated.files?.length?translated.files:[{path:translated.suggested_filename??defaultName,content:translated.content,role:'primary'}];
|
|
367
|
+
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'});
|
|
368
|
+
let total=0;const spilled=[];
|
|
369
|
+
for(const file of files){const bytes=Buffer.from(file.content,'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)});}
|
|
370
|
+
return {fidelity:translated.fidelity,residue:translated.residue??[],files:spilled};
|
|
371
|
+
}
|
|
372
|
+
// The outbox artifacts and native_bundle of one capture from a spilled bundle; every capture gets fresh upload ids.
|
|
373
|
+
bundleArtifacts(bundle,harness){
|
|
374
|
+
const artifacts=[],files=[];
|
|
375
|
+
for(const file of bundle.files){const uploadId=`upload_${randomBytes(16).toString('hex')}`;artifacts.push({manifest:{upload_id:uploadId,relative_name:file.path,sha256:file.sha256,byte_length:file.byte_length,chunks:file.chunk_sha256.length,media_type:'application/octet-stream',role:file.role==='source_recovery'?'sidecar':'native'},spill:{file:file.spill,chunk_sha256:file.chunk_sha256}});files.push({upload_id:uploadId,role:file.role});}
|
|
376
|
+
return {artifacts,nativeBundle:{id:files.find(file=>file.role==='primary').upload_id,harness,fidelity:bundle.fidelity,residue:bundle.residue,files}};
|
|
287
377
|
}
|
|
288
378
|
recordStamp(key,ruleId,ruleRevision,backfillMode,stamp){
|
|
289
379
|
const origin=this.state.origins[key];
|
|
@@ -305,7 +395,11 @@ export class TeamsConnector {
|
|
|
305
395
|
const loaded=await this.core.session(locator).load(),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
|
|
306
396
|
const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
|
|
307
397
|
let messages=(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};});
|
|
308
|
-
|
|
398
|
+
// A session published without its native bundle is captured again, with the same messages, once the bundle can be built.
|
|
399
|
+
const nativeWanted=backfillPolicy.mode==='all'&&typeof this.core.translateSession==='function';
|
|
400
|
+
const retryNative=Boolean(previous?.native_deferred&&nativeWanted&&artifactBefore?.isFile()&&!previous?.baseline&&this.nativeReady(key,artifactBefore));
|
|
401
|
+
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]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index]);
|
|
402
|
+
if(unchanged&&!retryNative){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
309
403
|
let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
|
|
310
404
|
if(!previous&&backfillPolicy.mode==='none'){const toolTail=trailingToolResults(messages);this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>hash(canonicalJson(x.value))),...(toolTail?{baseline_tool_tail:toolTail}:{}),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>hash(canonicalJson(x.value))),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
311
405
|
if(!previous&&backfillPolicy.mode==='since'){
|
|
@@ -330,7 +424,9 @@ export class TeamsConnector {
|
|
|
330
424
|
if(baselineMoved&&backfillPolicy.mode==='since'&&Number.isSafeInteger(backfillPolicy.since)){
|
|
331
425
|
const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
|
|
332
426
|
effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
|
|
333
|
-
|
|
427
|
+
// A tool tail recorded for the old baseline describes positions that no longer hold; it goes with it.
|
|
428
|
+
delete effectivePrevious.baseline_tool_tail;
|
|
429
|
+
this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};delete this.state.origins[key].baseline_tool_tail;
|
|
334
430
|
}else if(baselineMoved)throw Object.assign(new Error(`the history kept back when rule ${ruleId} started was rewritten; run \`supercode teams sync resume ${ruleId} --backfill all\` (or a since window) to republish this session`),{code:'resync_required'});
|
|
335
431
|
}
|
|
336
432
|
if(effectivePrevious?.baseline&&!clearBaseline)messages=messages.filter(x=>!effectivePrevious.baseline.includes(x.native_key));
|
|
@@ -338,29 +434,55 @@ export class TeamsConnector {
|
|
|
338
434
|
const captureId=`cap_${randomBytes(16).toString('hex')}`,generation=effectivePrevious?.generation??1,sequence=(effectivePrevious?.sequence??0)+1;
|
|
339
435
|
const begin={native_session_id:nativeSessionId,harness,generation,sequence,capture_id:captureId,previous_digest:effectivePrevious?.digest??null,visibility,rule_id:ruleId,rule_revision:ruleRevision};
|
|
340
436
|
const parts=[];for(let index=0;index<messages.length;index+=500)parts.push({capture_id:captureId,index:parts.length,messages:messages.slice(index,index+500)});
|
|
341
|
-
|
|
437
|
+
let artifacts=[];
|
|
342
438
|
// Native stores normally live in harness homes outside the repository. The locator is
|
|
343
439
|
// accepted only when it came from this configured core discovery, and raw bytes are safe
|
|
344
440
|
// to publish only when the complete native history is in the permitted projection.
|
|
345
|
-
let nativeBundle=null;
|
|
441
|
+
let nativeBundle=null,nativeDeferred=false;
|
|
346
442
|
// A session in a shared SQLite store (OpenCode, Goose) has no file of its own to stat; it is
|
|
347
443
|
// stable when the core reads back the same messages this capture publishes.
|
|
348
444
|
const sqliteSource=locator?.storage?.kind==='sqlite';
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
445
|
+
// Translating reads the whole native file, so its time grows with the file: a 129 MB Claude Code transcript
|
|
446
|
+
// outlasted the client's 30 s default on every beat and never synced. About 2 s per MB, from 30 s to 10 min.
|
|
447
|
+
const translateTimeoutMs=Math.min(600000,Math.max(30000,Math.ceil((artifactBefore?.size??0)/1e6)*2000));
|
|
448
|
+
if(artifactSource&&nativeWanted){
|
|
449
|
+
if(!artifactBefore.isFile())throw Object.assign(new Error('native artifact locator is not a regular file'),{code:'invalid_request'});
|
|
450
|
+
// A file source's bundle is best effort: when it cannot be attached (the file is live, it moved while it was
|
|
451
|
+
// read, the rule's kept-back baseline is still being cleared, or translating it failed) the messages are
|
|
452
|
+
// published without it and nativeDeferred brings the session back once the file is idle. Coverage speaks
|
|
453
|
+
// of the message history, which is whole either way, so it is unchanged; the capture simply names no
|
|
454
|
+
// artifacts, and the server refuses a native export or continue of that revision (fidelity_unavailable).
|
|
455
|
+
const onDisk=fileStamp(artifactBefore);let bundle=null;
|
|
456
|
+
if(!effectivePrevious?.baseline&&this.nativeReady(key,artifactBefore)){
|
|
457
|
+
const cached=this.state.native_cache[key];
|
|
458
|
+
if(cached?.stamp===onDisk&&cached.files.every(file=>existsSync(join(this.spillDir(),file.spill))))bundle=cached;
|
|
459
|
+
else try{
|
|
460
|
+
const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
|
|
461
|
+
if(fileStamp(statSync(artifactSource))===onDisk){
|
|
462
|
+
bundle={stamp:onDisk,...this.spillTranslated(translated,basename(artifactSource))};
|
|
463
|
+
// Only a translation that took real time is worth keeping; a small file translates again in no time.
|
|
464
|
+
if(artifactBefore.size>=this.nativeIdleBytes){this.state.native_cache[key]=bundle;this.save();this.pruneSpill();}
|
|
465
|
+
}
|
|
466
|
+
}catch(error){
|
|
467
|
+
if(this.abort.signal.aborted)throw error;
|
|
468
|
+
this.lastError=error;process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} published without its native bundle: ${error.message}\n`);
|
|
469
|
+
// The same file is not translated again; a write to it clears this.
|
|
470
|
+
this.state.native_cache[key]={stamp:onDisk,failed:true,files:[]};this.save();this.pruneSpill();
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// The messages were loaded after artifactBefore was read, so they match the bundle only if the file is still that file.
|
|
474
|
+
if(bundle&&fileStamp(statSync(artifactSource))!==onDisk)bundle=null;
|
|
475
|
+
if(bundle)({artifacts,nativeBundle}=this.bundleArtifacts(bundle,harness));else nativeDeferred=true;
|
|
476
|
+
// A retry that still has no bundle would republish the same messages for nothing.
|
|
477
|
+
if(unchanged&&!nativeBundle){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
478
|
+
}else if(sqliteSource&&nativeWanted&&!effectivePrevious?.baseline){
|
|
479
|
+
const translated=(await this.core.translateSession({locator,target_harness:harness},{timeoutMs:translateTimeoutMs})).artifact;
|
|
480
|
+
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'});
|
|
481
|
+
({artifacts,nativeBundle}=this.bundleArtifacts(this.spillTranslated(translated,`${nativeSessionId}.json`),harness));
|
|
360
482
|
}
|
|
361
483
|
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}:{})};
|
|
362
484
|
const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
|
|
363
|
-
this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
|
|
485
|
+
this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,nativeDeferred,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
|
|
364
486
|
}
|
|
365
487
|
flush() {
|
|
366
488
|
const work=this.work.then(()=>this.flushSerial());
|
|
@@ -369,7 +491,49 @@ export class TeamsConnector {
|
|
|
369
491
|
}
|
|
370
492
|
// Every outbox error is the connection's, not one session's: a capture's own catch passes it on (syncConfiguredSessions).
|
|
371
493
|
async flushSerial(){try{return await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}}
|
|
372
|
-
|
|
494
|
+
// A spill file is hashed once per version on disk (its stat stamp) and only stat'ed after that. It is lost only
|
|
495
|
+
// when it is missing, the wrong size or damaged; any other failure to read it (a Windows antivirus lock) is thrown,
|
|
496
|
+
// which keeps the capture queued with its bundle for the next flush.
|
|
497
|
+
// A read error that never clears (a lock never released, a lasting EACCES) is retried on five flushes, then the
|
|
498
|
+
// file counts as lost: the outbox is flushed in order, so one unreadable file would otherwise stop every session.
|
|
499
|
+
spillLost(artifact){
|
|
500
|
+
const failures=this.spillReadFailures??=new Map();
|
|
501
|
+
try{const lost=this.spillLostOnce(artifact);failures.delete(artifact.spill.file);return lost;}
|
|
502
|
+
catch(error){
|
|
503
|
+
const count=(failures.get(artifact.spill.file)??0)+1;failures.set(artifact.spill.file,count);
|
|
504
|
+
if(count<5)throw error;
|
|
505
|
+
failures.delete(artifact.spill.file);
|
|
506
|
+
process.stderr.write(`supercode teams: a spilled native artifact could not be read on ${count} flushes (${error.message}); treating it as lost\n`);
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
spillLostOnce(artifact){
|
|
511
|
+
const path=join(this.spillDir(),artifact.spill.file),missing=error=>error?.code==='ENOENT'||error?.code==='ENOTDIR';
|
|
512
|
+
let stat;try{stat=statSync(path);}catch(error){if(missing(error))return true;throw error;}
|
|
513
|
+
if(!stat.isFile()||stat.size!==artifact.manifest.byte_length)return true;
|
|
514
|
+
const stamp=fileStamp(stat),verified=this.verifiedSpills??=new Map();
|
|
515
|
+
if(verified.get(artifact.spill.file)===stamp)return false;
|
|
516
|
+
let fd;try{fd=this.openSpill(artifact.spill.file);}catch(error){if(missing(error))return true;throw error;}
|
|
517
|
+
try{
|
|
518
|
+
for(let index=0;index<artifact.manifest.chunks;index++){
|
|
519
|
+
const length=Math.max(0,Math.min(CHUNK_BYTES,artifact.manifest.byte_length-index*CHUNK_BYTES)),bytes=Buffer.alloc(length);
|
|
520
|
+
if((length?readSync(fd,bytes,0,length,index*CHUNK_BYTES):0)!==length||hash(bytes)!==artifact.spill.chunk_sha256[index])return true;
|
|
521
|
+
}
|
|
522
|
+
}finally{closeSync(fd);}
|
|
523
|
+
verified.set(artifact.spill.file,stamp);return false;
|
|
524
|
+
}
|
|
525
|
+
// A queued capture whose spilled artifact is gone or damaged on disk would never flush: it is sent without
|
|
526
|
+
// its native bundle instead (its digest resealed), and the session is captured again for one.
|
|
527
|
+
dropLostArtifacts(item){
|
|
528
|
+
if(!(item.artifacts??[]).some(artifact=>artifact.spill&&this.spillLost(artifact)))return;
|
|
529
|
+
process.stderr.write(`supercode teams: a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} lost its native artifact on disk; sending it without one\n`);
|
|
530
|
+
item.artifacts=[];item.commit.artifacts=[];delete item.commit.native_bundle;item.nativeDeferred=true;
|
|
531
|
+
const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.save();
|
|
532
|
+
}
|
|
533
|
+
async sendCapture(item){const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);
|
|
534
|
+
// A capture queued by an older connector holds its chunks inline; a current one reads them from its spill file.
|
|
535
|
+
for(const artifact of item.artifacts??[])for(const chunk of artifact.spill?this.spilledChunks(artifact):artifact.chunks){if(!chunk)throw Object.assign(new Error('a spilled native artifact changed on disk while it was sent'),{code:'resync_required'});await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);}
|
|
536
|
+
return this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}
|
|
373
537
|
// True when the server refused a queued capture because its sync rule was removed, paused or revised since it
|
|
374
538
|
// was queued: the capture can never be accepted, so it must not hold every later capture behind it. The
|
|
375
539
|
// refusal is confirmed against a fresh configuration; any other refusal (a revoked credential, a paused
|
|
@@ -381,6 +545,6 @@ export class TeamsConnector {
|
|
|
381
545
|
if(!Array.isArray(configuration?.rules))return false;
|
|
382
546
|
return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
|
|
383
547
|
}
|
|
384
|
-
async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;try{receipt=await this.sendCapture(item);}catch(error){if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();latest=receipt;}return latest;}
|
|
548
|
+
async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);try{receipt=await this.sendCapture(item);}catch(error){if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.nativeDeferred)next.native_deferred=true;else delete next.native_deferred;if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
|
|
385
549
|
stop(){if(this.timer)clearInterval(this.timer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
|
|
386
550
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
2
|
+
import { join, resolve, sep } from 'node:path';
|
|
3
3
|
|
|
4
4
|
// A continuation materializes a new native session (new ids), so value_lossless is the only
|
|
5
5
|
// fidelity it delivers.
|
|
@@ -19,7 +19,7 @@ export function createNativeContinuationAdapter({ core }) {
|
|
|
19
19
|
if (signal?.aborted) throw signal.reason ?? new Error('native continuation was aborted');
|
|
20
20
|
const files = ticket.artifact.files.map(file => {
|
|
21
21
|
const path = resolve(directory, file.relative_name);
|
|
22
|
-
if (!path.startsWith(`${resolve(directory)}
|
|
22
|
+
if (!path.startsWith(`${resolve(directory)}${sep}`)) throw unsupported('native continuation artifact escapes its verified directory');
|
|
23
23
|
return { path: file.relative_name, content: readFileSync(path, 'utf8'), role: file.role };
|
|
24
24
|
});
|
|
25
25
|
const primary = files.filter(file => file.role === 'primary');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.25",
|
|
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",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@volter/supercode-frontend": "^0.2.2",
|
|
62
62
|
"@volter/supercode-harness-sdk": "^0.3.43",
|
|
63
63
|
"@volter/supercode-terminal": "^0.2.30",
|
|
64
|
-
"@volter/identity": "^0.4.
|
|
64
|
+
"@volter/identity": "^0.4.1",
|
|
65
65
|
"@volter/teams": "^0.1.8",
|
|
66
66
|
"@xterm/addon-serialize": "^0.14.0",
|
|
67
67
|
"@xterm/headless": "^6.0.0",
|
|
@@ -46,6 +46,10 @@ class Reader {
|
|
|
46
46
|
const signed64 = (value) => BigInt.asIntN(64, value);
|
|
47
47
|
const zigzag64 = (value) => (value >> 1n) ^ -(value & 1n);
|
|
48
48
|
|
|
49
|
+
// As `json: true`: ProtoJSON spells non-finite floats and doubles "NaN", "Infinity" and "-Infinity"
|
|
50
|
+
// (JSON has no such numbers; JSON.stringify would turn them into null).
|
|
51
|
+
const protoJsonDouble = (value) => Number.isFinite(value) ? value : String(value);
|
|
52
|
+
|
|
49
53
|
function scalar(type, wire, reader) {
|
|
50
54
|
switch (type) {
|
|
51
55
|
case 'string': return utf8.decode(reader.take(Number(reader.varint())));
|
|
@@ -59,10 +63,10 @@ function scalar(type, wire, reader) {
|
|
|
59
63
|
case 'sint64': return zigzag64(reader.varint()).toString();
|
|
60
64
|
case 'fixed32': return reader.fixed32();
|
|
61
65
|
case 'sfixed32': return reader.fixed32() | 0;
|
|
62
|
-
case 'float': { const at = reader.pos; reader.take(4); return reader.view.getFloat32(at, true); }
|
|
66
|
+
case 'float': { const at = reader.pos; reader.take(4); return protoJsonDouble(reader.view.getFloat32(at, true)); }
|
|
63
67
|
case 'fixed64': return reader.fixed64().toString();
|
|
64
68
|
case 'sfixed64': return signed64(reader.fixed64()).toString();
|
|
65
|
-
case 'double': { const at = reader.pos; reader.take(8); return reader.view.getFloat64(at, true); }
|
|
69
|
+
case 'double': { const at = reader.pos; reader.take(8); return protoJsonDouble(reader.view.getFloat64(at, true)); }
|
|
66
70
|
default: throw new TypeError(`unsupported protobuf scalar ${type} (wire ${wire})`);
|
|
67
71
|
}
|
|
68
72
|
}
|