@volter/supercode-teams 0.2.77 → 0.2.79
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/client.mjs +5 -16
- package/connector/index.mjs +26 -2
- package/lines.mjs +19 -0
- package/machine.mjs +8 -20
- package/package.json +1 -1
package/client.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
7
7
|
import { EventEmitter } from 'node:events';
|
|
8
8
|
import { Channel } from '@volter/teams/channel';
|
|
9
9
|
import { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage } from '@volter/teams/client';
|
|
10
|
+
import { lineSplitter } from './lines.mjs';
|
|
10
11
|
|
|
11
12
|
export { openLocalMachine, openRemoteMachine, StreamRpc, fromWebPage };
|
|
12
13
|
|
|
@@ -32,15 +33,9 @@ export async function execServerBridge(channel, harness, { host = '127.0.0.1' }
|
|
|
32
33
|
catch (error) { if (own) ch?.close?.(); socket.close(1011, String(error.message ?? error).slice(0, 120)); return; }
|
|
33
34
|
if (own) stream.on('close', () => ch.close?.());
|
|
34
35
|
if (socket.readyState !== socket.OPEN) { stream.close('client closed'); return; }
|
|
35
|
-
|
|
36
|
+
const lines = lineSplitter();
|
|
36
37
|
stream.on('data', (chunk) => {
|
|
37
|
-
|
|
38
|
-
let nl;
|
|
39
|
-
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
40
|
-
const line = buffer.slice(0, nl);
|
|
41
|
-
buffer = buffer.slice(nl + 1);
|
|
42
|
-
if (line) socket.send(line);
|
|
43
|
-
}
|
|
38
|
+
for (const line of lines(Buffer.from(chunk, 'base64').toString('utf8'))) if (line) socket.send(line);
|
|
44
39
|
});
|
|
45
40
|
stream.on('close', (reason) => socket.close(1000, String(reason ?? '').slice(0, 120)));
|
|
46
41
|
for (const data of early.splice(0)) send(data);
|
|
@@ -62,15 +57,9 @@ export function harnessTransport(channel) {
|
|
|
62
57
|
get running() { return stream !== null && !stream.closed; },
|
|
63
58
|
async start(handlers) {
|
|
64
59
|
stream = await channel.open('harness');
|
|
65
|
-
|
|
60
|
+
const lines = lineSplitter();
|
|
66
61
|
stream.on('data', (chunk) => {
|
|
67
|
-
|
|
68
|
-
let index;
|
|
69
|
-
while ((index = buffer.indexOf('\n')) >= 0) {
|
|
70
|
-
const line = buffer.slice(0, index);
|
|
71
|
-
buffer = buffer.slice(index + 1);
|
|
72
|
-
if (line) handlers.onLine(line);
|
|
73
|
-
}
|
|
62
|
+
for (const line of lines(chunk)) if (line) handlers.onLine(line);
|
|
74
63
|
});
|
|
75
64
|
stream.on('close', (reason) => handlers.onExit(null, reason ?? null));
|
|
76
65
|
},
|
package/connector/index.mjs
CHANGED
|
@@ -52,6 +52,20 @@ function settledToolTail(previous,messages){
|
|
|
52
52
|
return messages.slice(0,count).map(digestOf);
|
|
53
53
|
}
|
|
54
54
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
55
|
+
// A capture part is one request, and the server refuses a body over 8 MiB (volter-teams request-json.mjs): a part
|
|
56
|
+
// holds at most PART_MESSAGES messages (the contract's bound) and PART_BYTES of them, so a run of large tool results
|
|
57
|
+
// splits across parts instead of failing the whole session. A single message larger than that goes alone.
|
|
58
|
+
const PART_MESSAGES=500,PART_BYTES=6*1024*1024;
|
|
59
|
+
function captureParts(captureId,messages){
|
|
60
|
+
const parts=[];let current=[],bytes=0;
|
|
61
|
+
for(const message of messages){
|
|
62
|
+
const size=Buffer.byteLength(JSON.stringify(message));
|
|
63
|
+
if(current.length&&(current.length>=PART_MESSAGES||bytes+size>PART_BYTES)){parts.push({capture_id:captureId,index:parts.length,messages:current});current=[];bytes=0;}
|
|
64
|
+
current.push(message);bytes+=size;
|
|
65
|
+
}
|
|
66
|
+
if(current.length)parts.push({capture_id:captureId,index:parts.length,messages:current});
|
|
67
|
+
return parts;
|
|
68
|
+
}
|
|
55
69
|
const CHUNK_BYTES=1024*1024;
|
|
56
70
|
// A native file at least NATIVE_IDLE_BYTES long gets its native bundle only once it has gone NATIVE_IDLE_MS
|
|
57
71
|
// without a write. Translating reads the whole file, takes minutes for a 100 MB transcript, and holds the
|
|
@@ -565,7 +579,7 @@ export class TeamsConnector {
|
|
|
565
579
|
const carried=this.carriedPrefix(effectivePrevious,messages,{ruleRevision,backfillMode:backfillPolicy.mode,clearBaseline});
|
|
566
580
|
if(carried)begin.carry={count:carried,digest:carryDigest(messages.slice(0,carried))};
|
|
567
581
|
const sent=carried?messages.slice(carried):messages;
|
|
568
|
-
const parts=
|
|
582
|
+
const parts=captureParts(captureId,sent);
|
|
569
583
|
let artifacts=[];
|
|
570
584
|
// Native stores normally live in harness homes outside the repository. The locator is
|
|
571
585
|
// accepted only when it came from this configured core discovery, and raw bytes are safe
|
|
@@ -662,6 +676,16 @@ export class TeamsConnector {
|
|
|
662
676
|
item.artifacts=[];item.commit.artifacts=[];delete item.commit.native_bundle;item.nativeDeferred=true;
|
|
663
677
|
const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.save();
|
|
664
678
|
}
|
|
679
|
+
// A capture queued before parts were bounded by bytes can hold a part the server refuses as too large, and the
|
|
680
|
+
// outbox is flushed in order: it is split again, under a new capture id (the server drops the old reservation of
|
|
681
|
+
// the same revision) and its digest resealed, so it neither fails nor holds every later capture behind it.
|
|
682
|
+
refitParts(item){
|
|
683
|
+
if(item.parts.every(part=>part.messages.length<=PART_MESSAGES&&Buffer.byteLength(JSON.stringify(part))<=PART_BYTES+1024*1024))return;
|
|
684
|
+
const captureId=`cap_${randomBytes(16).toString('hex')}`;
|
|
685
|
+
item.begin={...item.begin,capture_id:captureId};item.parts=captureParts(captureId,item.parts.flatMap(part=>part.messages));
|
|
686
|
+
item.commit={...item.commit,capture_id:captureId,parts:item.parts.length};
|
|
687
|
+
const digestCommit={...item.commit};delete digestCommit.digest;item.commit.digest=hash(canonicalJson({begin:item.begin,parts:item.parts,commit:digestCommit}));this.save();
|
|
688
|
+
}
|
|
665
689
|
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);
|
|
666
690
|
// A capture queued by an older connector holds its chunks inline; a current one reads them from its spill file.
|
|
667
691
|
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);}
|
|
@@ -677,6 +701,6 @@ export class TeamsConnector {
|
|
|
677
701
|
if(!Array.isArray(configuration?.rules))return false;
|
|
678
702
|
return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
|
|
679
703
|
}
|
|
680
|
-
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(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 goes whole next time\n`);this.state.outbox.shift();if(this.state.origins[key])this.state.origins[key]={...this.state.origins[key],observed_keys:[],observed_digests:[],carry_refused:true};this.save();continue;}if(error.status===400&&error.code==='invalid_request'){this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused as malformed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}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.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.state.origins[key]=next;this.state.outbox.shift();this.save();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
|
|
704
|
+
async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);this.refitParts(item);try{receipt=await this.sendCapture(item);}catch(error){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 goes whole next time\n`);this.state.outbox.shift();if(this.state.origins[key])this.state.origins[key]={...this.state.origins[key],observed_keys:[],observed_digests:[],carry_refused:true};this.save();continue;}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);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.state.outbox.shift();this.save();continue;}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.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.state.origins[key]=next;this.state.outbox.shift();this.save();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
|
|
681
705
|
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();}
|
|
682
706
|
}
|
package/lines.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Splits a stream's chunks into its complete lines, in time linear in its length: each chunk is scanned once and a
|
|
2
|
+
* line joined once, when its newline arrives. Appending to one buffer and searching it from the start again on
|
|
3
|
+
* every chunk is quadratic in a long line, and one answer (a large session's `sessions.load`, a whole machine's
|
|
4
|
+
* `sessions.discover`) is a line of tens of megabytes. */
|
|
5
|
+
export function lineSplitter() {
|
|
6
|
+
let pending = [];
|
|
7
|
+
return (chunk) => {
|
|
8
|
+
const text = String(chunk), lines = [];
|
|
9
|
+
let from = 0, at;
|
|
10
|
+
while ((at = text.indexOf('\n', from)) >= 0) {
|
|
11
|
+
pending.push(text.slice(from, at));
|
|
12
|
+
lines.push(pending.join(''));
|
|
13
|
+
pending = [];
|
|
14
|
+
from = at + 1;
|
|
15
|
+
}
|
|
16
|
+
if (from < text.length) pending.push(text.slice(from));
|
|
17
|
+
return lines;
|
|
18
|
+
};
|
|
19
|
+
}
|
package/machine.mjs
CHANGED
|
@@ -37,6 +37,7 @@ import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composer
|
|
|
37
37
|
// than failing the load: a POSIX daemon never needs it. Windows needs a supercode-terminal with PtyTerminalHost.
|
|
38
38
|
import * as terminal from '@volter/supercode-terminal';
|
|
39
39
|
import { findLiveReceipt, mintFrontendCredential } from '@volter/supercode-harness-sdk/live-runtime';
|
|
40
|
+
import { lineSplitter } from './lines.mjs';
|
|
40
41
|
import { NODE_PROTOCOL } from '@volter/teams/channel';
|
|
41
42
|
import { holds, observesPanes } from '@volter/teams/admission';
|
|
42
43
|
import { MachineCore, MACHINE_VERSION } from '@volter/teams/machine-core';
|
|
@@ -163,7 +164,6 @@ export class TeamsMachine extends MachineCore {
|
|
|
163
164
|
// How many pane attachments a principal may hold (limits.json); every refusal is logged.
|
|
164
165
|
this.attachmentsByPrincipal = new Map(); // principal → live pane attachments
|
|
165
166
|
this.serve = null;
|
|
166
|
-
this.serveBuffer = '';
|
|
167
167
|
this.routes = new Map(); // routing key (subscription/connection id) → connection index
|
|
168
168
|
this.portsDir = join(home, 'ports');
|
|
169
169
|
// Panes whose REMOTE input is refused. It survives a daemon restart:
|
|
@@ -228,14 +228,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
228
228
|
#startServe() {
|
|
229
229
|
const child = spawn(this.supercodeBin, ['harness', 'serve'], { stdio: ['pipe', 'pipe', 'pipe'], env: this.env });
|
|
230
230
|
child.stdout.setEncoding('utf8');
|
|
231
|
+
// A restarted serve starts on a line of its own, never on the tail its predecessor left unfinished.
|
|
232
|
+
const serveLines = lineSplitter();
|
|
231
233
|
child.stdout.on('data', (chunk) => {
|
|
232
|
-
|
|
233
|
-
let index;
|
|
234
|
-
while ((index = this.serveBuffer.indexOf('\n')) >= 0) {
|
|
235
|
-
const line = this.serveBuffer.slice(0, index);
|
|
236
|
-
this.serveBuffer = this.serveBuffer.slice(index + 1);
|
|
237
|
-
if (line) this.#routeServeLine(line);
|
|
238
|
-
}
|
|
234
|
+
for (const line of serveLines(chunk)) if (line) this.#routeServeLine(line);
|
|
239
235
|
});
|
|
240
236
|
child.stderr.setEncoding('utf8');
|
|
241
237
|
child.stderr.on('data', (text) => this.emit('serve-stderr', text));
|
|
@@ -350,13 +346,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
350
346
|
entry.harness = stream;
|
|
351
347
|
accept();
|
|
352
348
|
this.log.append({ principal, cap: 'harness', target: '*', outcome: 'opened' });
|
|
353
|
-
|
|
349
|
+
const lines = lineSplitter();
|
|
354
350
|
stream.on('data', (chunk) => {
|
|
355
|
-
|
|
356
|
-
let nl;
|
|
357
|
-
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
358
|
-
const line = buffer.slice(0, nl);
|
|
359
|
-
buffer = buffer.slice(nl + 1);
|
|
351
|
+
for (const line of lines(chunk)) {
|
|
360
352
|
if (!line) continue;
|
|
361
353
|
let message;
|
|
362
354
|
try { message = JSON.parse(line); } catch { continue; }
|
|
@@ -1048,13 +1040,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
1048
1040
|
}
|
|
1049
1041
|
|
|
1050
1042
|
#serveNodeRpc(entry, stream) {
|
|
1051
|
-
|
|
1043
|
+
const lines = lineSplitter();
|
|
1052
1044
|
stream.on('data', async (chunk) => {
|
|
1053
|
-
|
|
1054
|
-
let nl;
|
|
1055
|
-
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
1056
|
-
const line = buffer.slice(0, nl);
|
|
1057
|
-
buffer = buffer.slice(nl + 1);
|
|
1045
|
+
for (const line of lines(chunk)) {
|
|
1058
1046
|
if (!line) continue;
|
|
1059
1047
|
let request;
|
|
1060
1048
|
try { request = JSON.parse(line); } catch { continue; }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.79",
|
|
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",
|