@twinsearth/w2m-dsh-plugin 2.0.4 → 2.0.6
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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +1795 -1779
- package/bin/w2m-joint.mjs +69 -7
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/2.0.5/RELEASE-205.json +21 -0
- package/docs/acceptance/2.0.5/RELEASE-2050.json +22 -0
- package/docs/acceptance/2.0.6/RELEASE-2060.json +22 -0
- package/docs/adr/0001-collaboration-crdt.md +59 -0
- package/lib/client.js +215 -19
- package/package.json +2 -2
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +97 -36
- package/src/agent/artifact-delivery.mjs +89 -0
- package/src/agent/caps.mjs +16 -2
- package/src/agent/sinks/git-sink.mjs +78 -0
- package/src/agent/sinks/ssh-sink.mjs +83 -0
- package/src/agent/sse-cursor.mjs +16 -0
- package/src/agent/sse-watchdog.mjs +71 -0
- package/src/plugin/dataplane-sink-route.mjs +118 -0
- package/src/plugin/fleet-chat-route.mjs +11 -0
- package/src/plugin/joint-dev/dispatch.mjs +5 -1
- package/src/plugin/joint-dev/merge-approval.mjs +115 -0
- package/src/plugin/joint-dev/progress.mjs +107 -0
- package/src/plugin/joint-dev/relay-dispatch.mjs +52 -22
- package/src/plugin/project-stream-route.mjs +58 -0
- package/src/plugin/tools-joint.mjs +6 -1
- package/src/plugin/tools.mjs +25 -5
- package/src/relay/fleet-messages.mjs +48 -24
- package/src/relay/project-room.mjs +47 -2
- package/src/relay/server.mjs +11 -5
- package/src/relay/state.mjs +63 -3
- package/src/util/chat-crypto.mjs +70 -1
- package/docs/COLLAB/COLLABORATION-MODEL.md +0 -240
- package/docs/COLLAB/EXECUTION-AUTH-MODEL.md +0 -361
- package/docs/COLLAB/M1-ACCEPTANCE-RUNBOOK.md +0 -70
- package/docs/COLLAB/M1-MAINTENANCE.md +0 -313
- package/docs/COLLAB/M2-TASK-GRANT.md +0 -231
- package/docs/COLLAB/M3-GIT-NATIVE-TASKS.md +0 -291
- package/docs/COLLAB/M4-PROJECT-ROOM.md +0 -260
- package/docs/COLLAB/M5-MULTI-CLIENT-PRIMITIVES.md +0 -263
- package/docs/COLLAB/M6-CROSS-NETWORK-RELIABILITY.md +0 -231
- package/docs/COLLAB/M7-METRICS-ACCEPTANCE.md +0 -263
- package/docs/COLLAB/TASK-MODEL.md +0 -281
- package/docs/acceptance/1.9.10/RELEASE-1910.json +0 -21
- package/docs/acceptance/1.9.11/RELEASE-1911.json +0 -20
- package/docs/acceptance/1.9.12/RELEASE-1912.json +0 -20
- package/docs/acceptance/1.9.13/RELEASE-1913.json +0 -20
- package/docs/acceptance/1.9.14/RELEASE-1914.json +0 -20
- package/docs/acceptance/1.9.15/RELEASE-1915.json +0 -22
- package/docs/acceptance/1.9.16/RELEASE-1916.json +0 -21
- package/docs/acceptance/1.9.17/RELEASE-1917.json +0 -20
- package/docs/acceptance/1.9.19/RELEASE-1919.json +0 -21
- package/docs/acceptance/1.9.20/RELEASE-1920.json +0 -22
- package/docs/acceptance/1.9.4/RELEASE-194.json +0 -23
- package/docs/acceptance/1.9.5/RELEASE-195.json +0 -23
- package/docs/acceptance/1.9.6/RELEASE-196.json +0 -22
- package/docs/acceptance/1.9.7/RELEASE-197.json +0 -21
- package/docs/acceptance/1.9.8/RELEASE-198.json +0 -21
- package/docs/acceptance/1.9.9/RELEASE-199.json +0 -21
- package/lib/client.js.bak2 +0 -640
- package/src/plugin/dataflow.mjs +0 -72
- package/src/plugin/fleet-target.mjs.bak-arch2 +0 -812
- package/src/plugin/fleet-target.mjs.bak-archive +0 -812
- package/src/plugin/tofuture/report.mjs +0 -44
- package/src/plugin/tools.mjs.bak-c7 +0 -7029
- package/src/relay/tofuture-whitelist.mjs +0 -46
- package/src/util/data-plane.mjs +0 -180
- package/src/util/sealed-object.mjs +0 -141
- package/src/util/tofuture-hmac.mjs +0 -30
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.6",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
package/src/agent/agent.mjs
CHANGED
|
@@ -33,6 +33,11 @@ import { tmpdir } from 'node:os';
|
|
|
33
33
|
import { randomUUID , sign as signBytes } from 'node:crypto';
|
|
34
34
|
import { fileURLToPath } from 'node:url';
|
|
35
35
|
|
|
36
|
+
import { deliverArtifact } from './artifact-delivery.mjs';
|
|
37
|
+
|
|
38
|
+
import { createSseWatchdog } from './sse-watchdog.mjs';
|
|
39
|
+
import { shouldAdvanceCursor } from './sse-cursor.mjs';
|
|
40
|
+
|
|
36
41
|
import {
|
|
37
42
|
EMPTY_SHA256,
|
|
38
43
|
classifyExit,
|
|
@@ -1921,6 +1926,8 @@ export function createAgent(options) {
|
|
|
1921
1926
|
const COMPLETED_CACHE_LIMIT = 64;
|
|
1922
1927
|
let pumping = false;
|
|
1923
1928
|
let streamAbort = null;
|
|
1929
|
+
/** Set when the current attach attempt parsed the relay's `ready` frame. */
|
|
1930
|
+
let attemptAttached = false;
|
|
1924
1931
|
let runAbort = null;
|
|
1925
1932
|
/** @type {Heartbeat|null} */
|
|
1926
1933
|
let heartbeat = null;
|
|
@@ -2247,26 +2254,19 @@ export function createAgent(options) {
|
|
|
2247
2254
|
* @param {string} body
|
|
2248
2255
|
* @returns {Promise<{ok: boolean, status: number, sha256?: string, code?: string}>}
|
|
2249
2256
|
*/
|
|
2250
|
-
async function putBlobBytes(sha256, body) {
|
|
2251
|
-
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
2254
|
-
const
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
const parsed = JSON.parse(await response.text());
|
|
2264
|
-
if (typeof parsed?.error?.code === 'string') code = parsed.error.code;
|
|
2265
|
-
} catch { /* the code is then the HTTP status, which is still a name */ }
|
|
2266
|
-
return { ok: false, status: response.status, code };
|
|
2267
|
-
} catch (error) {
|
|
2268
|
-
return { ok: false, status: 0, code: error instanceof Error ? 'BLOB_UPLOAD_FAILED' : 'BLOB_UPLOAD_FAILED' };
|
|
2269
|
-
}
|
|
2257
|
+
async function putBlobBytes(sha256, body, kind = 'stdout', taskId = null) {
|
|
2258
|
+
// A3/S4: artifacts leave through the user's sink (dir/git/ssh), never through the relay.
|
|
2259
|
+
// Without a sink the bytes are kept locally and the caller reports ARTIFACT_NOT_DELIVERED;
|
|
2260
|
+
// the relay is not contacted at all.
|
|
2261
|
+
const delivered = await deliverArtifact({
|
|
2262
|
+
stateDir,
|
|
2263
|
+
kind,
|
|
2264
|
+
bytes: body,
|
|
2265
|
+
taskId: typeof taskId === 'string' && taskId !== '' ? taskId : 'unknown-task',
|
|
2266
|
+
machineId: identity.machine_id,
|
|
2267
|
+
});
|
|
2268
|
+
if (delivered.ok === true) return { ok: true, status: 200, sha256: delivered.sha256, ref: delivered.ref };
|
|
2269
|
+
return { ok: false, status: 0, code: delivered.code ?? 'ARTIFACT_NOT_DELIVERED', reason: delivered.reason ?? null, local: delivered.local ?? null };
|
|
2270
2270
|
}
|
|
2271
2271
|
|
|
2272
2272
|
/** @param {string} path @param {object} body @param {number} [retries] */
|
|
@@ -3359,8 +3359,12 @@ export function createAgent(options) {
|
|
|
3359
3359
|
// The agent caps what it keeps in memory; if that cap was hit the blob holds the same bytes the hash covers, and
|
|
3360
3360
|
// the warning says so instead of letting a reader assume the stage printed exactly this much.
|
|
3361
3361
|
if (ran.truncated_stdout === true && !warnings.includes('PIPELINE_STDOUT_TRUNCATED')) warnings.push('PIPELINE_STDOUT_TRUNCATED');
|
|
3362
|
-
const uploaded = await putBlobBytes(ran.stdout_sha256, ran.stdout);
|
|
3363
|
-
if (uploaded.ok !== true) {
|
|
3362
|
+
const uploaded = await putBlobBytes(ran.stdout_sha256, ran.stdout, 'stdout', offer.task_id);
|
|
3363
|
+
if (uploaded.ok !== true && uploaded.code === 'ARTIFACT_NOT_DELIVERED') {
|
|
3364
|
+
// No sink configured: the stage itself succeeded and its bytes are kept locally.
|
|
3365
|
+
warnings.push('ARTIFACT_NOT_DELIVERED');
|
|
3366
|
+
blobSha256 = null;
|
|
3367
|
+
} else if (uploaded.ok !== true) {
|
|
3364
3368
|
log("warn", offer.task_id + " stage " + stage.index + ": blob upload failed", { code: uploaded.code });
|
|
3365
3369
|
stageReports.push({
|
|
3366
3370
|
index: stage.index,
|
|
@@ -3377,9 +3381,10 @@ export function createAgent(options) {
|
|
|
3377
3381
|
});
|
|
3378
3382
|
stoppedAt = stage.index;
|
|
3379
3383
|
break;
|
|
3384
|
+
} else {
|
|
3385
|
+
blobSha256 = uploaded.sha256;
|
|
3386
|
+
lastBlobSha256 = blobSha256;
|
|
3380
3387
|
}
|
|
3381
|
-
blobSha256 = uploaded.sha256;
|
|
3382
|
-
lastBlobSha256 = blobSha256;
|
|
3383
3388
|
}
|
|
3384
3389
|
stageReports.push({
|
|
3385
3390
|
index: stage.index,
|
|
@@ -3422,12 +3427,12 @@ export function createAgent(options) {
|
|
|
3422
3427
|
const plainPlan = planStageOutput({ machineId: identity.machine_id, stageIndex: 0, stdoutBytes: execution.stdout_bytes });
|
|
3423
3428
|
if (plainPlan.ok === true && plainPlan.mode === 'blob') {
|
|
3424
3429
|
if (execution.truncated_stdout === true && !warnings.includes('STDOUT_TRUNCATED')) warnings.push('STDOUT_TRUNCATED');
|
|
3425
|
-
const uploaded = await putBlobBytes(execution.stdout_sha256, execution.stdout);
|
|
3430
|
+
const uploaded = await putBlobBytes(execution.stdout_sha256, execution.stdout, 'stdout', offer.task_id);
|
|
3426
3431
|
if (uploaded.ok === true) {
|
|
3427
3432
|
lastBlobSha256 = uploaded.sha256;
|
|
3428
3433
|
} else {
|
|
3429
3434
|
log('warn', offer.task_id + ': plain stdout blob upload failed', { code: uploaded.code });
|
|
3430
|
-
warnings.push('BLOB_UPLOAD_FAILED');
|
|
3435
|
+
warnings.push(uploaded.code === 'ARTIFACT_NOT_DELIVERED' ? 'ARTIFACT_NOT_DELIVERED' : 'BLOB_UPLOAD_FAILED');
|
|
3431
3436
|
}
|
|
3432
3437
|
}
|
|
3433
3438
|
}
|
|
@@ -3451,9 +3456,9 @@ export function createAgent(options) {
|
|
|
3451
3456
|
let patchSha = null;
|
|
3452
3457
|
if (patch.ok === true && typeof patch.patch === 'string' && patch.patch.trim() !== '') {
|
|
3453
3458
|
const sha = sha256Hex(patch.patch);
|
|
3454
|
-
const uploaded = await putBlobBytes(sha, patch.patch);
|
|
3459
|
+
const uploaded = await putBlobBytes(sha, patch.patch, 'patch', offer.task_id);
|
|
3455
3460
|
if (uploaded.ok === true) patchSha = sha;
|
|
3456
|
-
else warnings.push('PATCH_UPLOAD_FAILED');
|
|
3461
|
+
else warnings.push(uploaded.code === 'ARTIFACT_NOT_DELIVERED' ? 'ARTIFACT_NOT_DELIVERED' : 'PATCH_UPLOAD_FAILED');
|
|
3457
3462
|
}
|
|
3458
3463
|
const removed = await removeWorktree({ repoDir: project, worktreeDir: workspaceState.worktreeDir });
|
|
3459
3464
|
if (removed.ok !== true) warnings.push('WORKTREE_CLEANUP_FAILED');
|
|
@@ -3833,7 +3838,24 @@ export function createAgent(options) {
|
|
|
3833
3838
|
// break the "proxy strips the prefix" deployment (see signingHeaders).
|
|
3834
3839
|
Object.assign(headers, signingHeaders('GET', `/v1/stream?${query.toString()}`));
|
|
3835
3840
|
|
|
3836
|
-
|
|
3841
|
+
// Header deadline: a proxy that accepts the socket but never returns headers must not hang the
|
|
3842
|
+
// agent forever. Node >= 20.19 has AbortSignal.any, and every other HTTP call in the tree already
|
|
3843
|
+
// carries a timeout.
|
|
3844
|
+
const headerTimeoutMs = Number(process.env.W2M_SSE_FIRST_FRAME_MS) > 0 ? Number(process.env.W2M_SSE_FIRST_FRAME_MS) : 5000;
|
|
3845
|
+
const headerSignal = typeof AbortSignal.any === 'function'
|
|
3846
|
+
? AbortSignal.any([streamAbort.signal, AbortSignal.timeout(headerTimeoutMs)])
|
|
3847
|
+
: streamAbort.signal;
|
|
3848
|
+
let response;
|
|
3849
|
+
try {
|
|
3850
|
+
response = await fetchImpl(streamUrl, { headers, signal: headerSignal });
|
|
3851
|
+
} catch (error) {
|
|
3852
|
+
if (streamAbort.signal.aborted) throw error;
|
|
3853
|
+
const timeout = new Error('SSE_FIRST_FRAME_TIMEOUT');
|
|
3854
|
+
timeout.code = 'SSE_FIRST_FRAME_TIMEOUT';
|
|
3855
|
+
state.lastRelayError = { at: new Date().toISOString(), http_status: null, code: timeout.code, path: '/v1/stream' };
|
|
3856
|
+
publishState();
|
|
3857
|
+
throw timeout;
|
|
3858
|
+
}
|
|
3837
3859
|
if (!response.ok) {
|
|
3838
3860
|
const body = await response.text().catch(() => '');
|
|
3839
3861
|
let code = null;
|
|
@@ -3855,19 +3877,51 @@ export function createAgent(options) {
|
|
|
3855
3877
|
throw error; }
|
|
3856
3878
|
if (!response.body) throw new Error('stream response had no body');
|
|
3857
3879
|
|
|
3880
|
+
// P0-f: two deadlines on the stream this attempt. A half-open connection must not look
|
|
3881
|
+
// like a healthy one forever; the relay keepalive is 15 s, so 45 s is three missed beats.
|
|
3882
|
+
const envMs = (name) => (Number(process.env[name]) > 0 ? Number(process.env[name]) : undefined);
|
|
3883
|
+
let timeoutCode = null;
|
|
3884
|
+
let source = null;
|
|
3885
|
+
const watchdog = createSseWatchdog({
|
|
3886
|
+
firstFrameMs: envMs('W2M_SSE_FIRST_FRAME_MS'),
|
|
3887
|
+
idleMs: envMs('W2M_SSE_IDLE_MS'),
|
|
3888
|
+
onTimeout: (code) => {
|
|
3889
|
+
timeoutCode = code;
|
|
3890
|
+
state.lastRelayError = { at: new Date().toISOString(), http_status: null, code, path: '/v1/stream' };
|
|
3891
|
+
publishState();
|
|
3892
|
+
const error = new Error(code);
|
|
3893
|
+
error.code = code;
|
|
3894
|
+
try { source?.destroy(error); } catch { /* already gone */ }
|
|
3895
|
+
},
|
|
3896
|
+
});
|
|
3897
|
+
|
|
3858
3898
|
// Both assignments mark the same stream attempt as no longer live: one before the read loop, one
|
|
3859
3899
|
// after it ends. Only one stream runs at a time, so there is nothing to interleave with.
|
|
3860
3900
|
// eslint-disable-next-line require-atomic-updates -- one stream attempt at a time
|
|
3861
3901
|
state.connected = false;
|
|
3862
3902
|
const decoder = new TextDecoder('utf-8');
|
|
3863
3903
|
const parser = new SseParser();
|
|
3864
|
-
|
|
3904
|
+
source = Readable.fromWeb(response.body);
|
|
3865
3905
|
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3906
|
+
try {
|
|
3907
|
+
for await (const chunk of source) {
|
|
3908
|
+
if (state.stopped) break;
|
|
3909
|
+
watchdog.noteBytes();
|
|
3910
|
+
for (const frame of parser.push(decoder.decode(chunk, { stream: true }))) {
|
|
3911
|
+
watchdog.noteFrame();
|
|
3912
|
+
handleFrame(frame);
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3915
|
+
} catch (error) {
|
|
3916
|
+
if (timeoutCode !== null) {
|
|
3917
|
+
const timeout = new Error(timeoutCode);
|
|
3918
|
+
timeout.code = timeoutCode;
|
|
3919
|
+
log('warn', 'event stream watchdog fired; reconnecting', { code: timeoutCode });
|
|
3920
|
+
throw timeout;
|
|
3870
3921
|
}
|
|
3922
|
+
throw error;
|
|
3923
|
+
} finally {
|
|
3924
|
+
watchdog.dispose();
|
|
3871
3925
|
}
|
|
3872
3926
|
state.connected = false;
|
|
3873
3927
|
publishState();
|
|
@@ -3934,7 +3988,9 @@ export function createAgent(options) {
|
|
|
3934
3988
|
*/
|
|
3935
3989
|
function dispatchFrame(payload, source) {
|
|
3936
3990
|
const type = payload?.type ?? source.event;
|
|
3937
|
-
if (source.transport === 'relay') {
|
|
3991
|
+
if (source.transport === 'relay' && shouldAdvanceCursor({ type, event: source.event })) {
|
|
3992
|
+
// P0-f: ready/notice carry sequence numbers that never entered the replay ring; adopting one
|
|
3993
|
+
// would claim events that were never received and a reconnect would skip the real gap.
|
|
3938
3994
|
if (Number.isInteger(payload?.seq)) state.seq = Math.max(state.seq, payload.seq);
|
|
3939
3995
|
else if (source.id && /^\d+$/.test(source.id)) state.seq = Math.max(state.seq, Number(source.id));
|
|
3940
3996
|
}
|
|
@@ -3950,6 +4006,7 @@ export function createAgent(options) {
|
|
|
3950
4006
|
}
|
|
3951
4007
|
// §3.1: nothing counts as connected before `ready` arrives.
|
|
3952
4008
|
state.connected = true;
|
|
4009
|
+
attemptAttached = true;
|
|
3953
4010
|
const relayId =
|
|
3954
4011
|
typeof payload.relay_id === 'string' && payload.relay_id !== '' ? payload.relay_id : null;
|
|
3955
4012
|
const previousRelayId = state.relayId;
|
|
@@ -4296,6 +4353,7 @@ export function createAgent(options) {
|
|
|
4296
4353
|
const response = await postJson('/v1/heartbeat', {
|
|
4297
4354
|
diagnostic: true,
|
|
4298
4355
|
machine_id: identity.machine_id,
|
|
4356
|
+
...(caps === null || caps === undefined ? {} : { caps }),
|
|
4299
4357
|
...heartbeatDiagnostics({
|
|
4300
4358
|
rttMs: state.rttMs,
|
|
4301
4359
|
reconnectAttempts: state.reconnectAttempts,
|
|
@@ -4416,6 +4474,7 @@ export function createAgent(options) {
|
|
|
4416
4474
|
streamAbort = new AbortController();
|
|
4417
4475
|
state.streamRestartRequested = false;
|
|
4418
4476
|
const openedAt = Date.now();
|
|
4477
|
+
attemptAttached = false;
|
|
4419
4478
|
/** @type {Error|null} */
|
|
4420
4479
|
let failure = null;
|
|
4421
4480
|
try {
|
|
@@ -4449,7 +4508,9 @@ export function createAgent(options) {
|
|
|
4449
4508
|
});
|
|
4450
4509
|
}
|
|
4451
4510
|
|
|
4452
|
-
|
|
4511
|
+
// A stream that never delivered `ready` is not stable however long the socket lived: the
|
|
4512
|
+
// watchdog deadlines are seconds, so the old rule reset the backoff to 500 ms forever.
|
|
4513
|
+
const stable = attemptAttached && livedMs >= STABLE_STREAM_MS;
|
|
4453
4514
|
if (stable) {
|
|
4454
4515
|
reconnectAttempt = 0;
|
|
4455
4516
|
restartReconnects = 0;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* S4 data-plane delivery (A3): artifacts leave the agent through a USER sink, never through relay.
|
|
3
|
+
*
|
|
4
|
+
* Order of events:
|
|
5
|
+
* 1. no sink configured -> keep the bytes locally (0600) and report
|
|
6
|
+
* ARTIFACT_NOT_DELIVERED / DATA_PLANE_SINK_REQUIRED; the relay is not contacted;
|
|
7
|
+
* 2. sink configured -> write the sealed bytes to the sink (dir/git/ssh), then build an ArtifactRef
|
|
8
|
+
* (metadata only, <= 4 KiB) that a reader can resolve against the same sink.
|
|
9
|
+
*/
|
|
10
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { createArtifactRef } from '../util/artifact-ref.mjs';
|
|
15
|
+
import { createDirSink } from './sinks/dir-sink.mjs';
|
|
16
|
+
import { createGitSink } from './sinks/git-sink.mjs';
|
|
17
|
+
import { createSshSink } from './sinks/ssh-sink.mjs';
|
|
18
|
+
|
|
19
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
20
|
+
const SAFE_TASK = /^[A-Za-z0-9._-]{1,80}$/;
|
|
21
|
+
|
|
22
|
+
/** Read `<stateDir>/dataplane/sink.json` and build the configured sink, or null (fail closed). */
|
|
23
|
+
export function loadArtifactSink(stateDir) {
|
|
24
|
+
if (typeof stateDir !== 'string' || stateDir === '') return null;
|
|
25
|
+
const file = join(stateDir, 'dataplane', 'sink.json');
|
|
26
|
+
if (!existsSync(file)) return null;
|
|
27
|
+
try {
|
|
28
|
+
const config = JSON.parse(readFileSync(file, 'utf8'));
|
|
29
|
+
if (config?.kind === 'dir' && typeof config.root === 'string') return createDirSink({ root: config.root });
|
|
30
|
+
if (config?.kind === 'lan-git' && typeof config.root === 'string') return createGitSink({ root: config.root, remote: config.remote ?? null, branch: config.branch ?? null });
|
|
31
|
+
if (config?.kind === 'ssh' && typeof config.host === 'string' && typeof config.user === 'string' && typeof config.dir === 'string') {
|
|
32
|
+
return createSshSink({ host: config.host, user: config.user, dir: config.dir, port: Number.isInteger(config.port) ? config.port : null, keyPath: typeof config.keyPath === 'string' ? config.keyPath : null });
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Deliver one artifact.
|
|
42
|
+
*
|
|
43
|
+
* @param {{stateDir: string, kind?: 'stdout'|'stderr'|'patch'|'commit'|'reduce-input', bytes: Buffer|string,
|
|
44
|
+
* taskId: string, machineId: string, stepId?: string|null, sink?: object|null}} input
|
|
45
|
+
* @returns {Promise<{ok: true, ref: object, sha256: string, bytes: number}
|
|
46
|
+
* | {ok: false, code: 'ARTIFACT_NOT_DELIVERED', reason: string, local: object}>}
|
|
47
|
+
*/
|
|
48
|
+
export async function deliverArtifact(input) {
|
|
49
|
+
const bytes = Buffer.isBuffer(input?.bytes) ? input.bytes : Buffer.from(String(input?.bytes ?? ''), 'utf8');
|
|
50
|
+
const digest = sha256(bytes);
|
|
51
|
+
const taskId = typeof input?.taskId === 'string' && input.taskId !== '' ? input.taskId : 'unknown-task';
|
|
52
|
+
const machineId = typeof input?.machineId === 'string' && input.machineId !== '' ? input.machineId : 'unknown-machine';
|
|
53
|
+
const taskDir = SAFE_TASK.test(taskId) ? taskId : 'unknown-task';
|
|
54
|
+
const kind = typeof input?.kind === 'string' ? input.kind : 'stdout';
|
|
55
|
+
const stateDir = typeof input?.stateDir === 'string' ? input.stateDir : '';
|
|
56
|
+
const localPath = join(stateDir, 'artifacts', taskDir, kind + '-' + digest + '.bin');
|
|
57
|
+
const keepLocal = () => {
|
|
58
|
+
try {
|
|
59
|
+
mkdirSync(join(stateDir, 'artifacts', taskDir), { recursive: true, mode: 0o700 });
|
|
60
|
+
writeFileSync(localPath, bytes, { encoding: 'utf8', mode: 0o600 });
|
|
61
|
+
return { sha256: digest, bytes: bytes.length, path: localPath };
|
|
62
|
+
} catch {
|
|
63
|
+
return { sha256: digest, bytes: bytes.length, path: null };
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const sink = input?.sink === undefined ? loadArtifactSink(stateDir) : input.sink;
|
|
67
|
+
if (sink === null || sink === undefined) {
|
|
68
|
+
return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', reason: 'DATA_PLANE_SINK_REQUIRED', local: keepLocal() };
|
|
69
|
+
}
|
|
70
|
+
const objectId = randomBytes(16).toString('hex');
|
|
71
|
+
const written = await sink.writeObject({ objectId, body: bytes, expectSha256: digest });
|
|
72
|
+
if (written.ok !== true) {
|
|
73
|
+
return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', reason: written.code ?? 'SINK_WRITE_FAILED', local: keepLocal() };
|
|
74
|
+
}
|
|
75
|
+
const ref = createArtifactRef({
|
|
76
|
+
objectId,
|
|
77
|
+
taskId: taskId,
|
|
78
|
+
stepId: typeof input?.stepId === 'string' ? input.stepId : null,
|
|
79
|
+
machineId,
|
|
80
|
+
kind,
|
|
81
|
+
sinkKind: sink.kind === 'lan-git' ? 'lan-git' : sink.kind,
|
|
82
|
+
relPath: written.rel_path ?? objectId + '.json',
|
|
83
|
+
cipherSha256: digest,
|
|
84
|
+
cipherBytes: bytes.length,
|
|
85
|
+
plainBytes: bytes.length,
|
|
86
|
+
});
|
|
87
|
+
if (ref.ok !== true) return { ok: false, code: 'ARTIFACT_NOT_DELIVERED', reason: ref.code ?? 'REF_INVALID', local: keepLocal() };
|
|
88
|
+
return { ok: true, ref: ref.ref, sha256: digest, bytes: bytes.length };
|
|
89
|
+
}
|
package/src/agent/caps.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* leaks into an envelope.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { chmodSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
24
24
|
import { tmpdir, release as osRelease } from 'node:os';
|
|
25
25
|
import { basename, extname, join } from 'node:path';
|
|
26
26
|
import { randomBytes } from 'node:crypto';
|
|
@@ -40,7 +40,20 @@ export {
|
|
|
40
40
|
} from './resolve.mjs';
|
|
41
41
|
|
|
42
42
|
/** Keys of the contract's `caps` object — nothing else may be added. */
|
|
43
|
-
export const CAPS_KEYS = ['case_sensitive_fs', 'symlinks', 'exec_bit', 'python', 'npm', 'node'];
|
|
43
|
+
export const CAPS_KEYS = ['case_sensitive_fs', 'symlinks', 'exec_bit', 'python', 'npm', 'node', 'plugin_version'];
|
|
44
|
+
|
|
45
|
+
/** The plugin's own version, read once from the package manifest (the version gate compares it). */
|
|
46
|
+
let pluginVersionCache = null;
|
|
47
|
+
export function pluginVersion() {
|
|
48
|
+
if (pluginVersionCache !== null) return pluginVersionCache;
|
|
49
|
+
try {
|
|
50
|
+
const manifest = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8').replace(/^\uFEFF/, ''));
|
|
51
|
+
pluginVersionCache = typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : null;
|
|
52
|
+
} catch {
|
|
53
|
+
pluginVersionCache = null;
|
|
54
|
+
}
|
|
55
|
+
return pluginVersionCache;
|
|
56
|
+
}
|
|
44
57
|
|
|
45
58
|
/**
|
|
46
59
|
* Run `<exe> <args>` and return the first regex capture of stdout+stderr.
|
|
@@ -403,6 +416,7 @@ export async function probeCapsDetailed(options = {}) {
|
|
|
403
416
|
python: python.version,
|
|
404
417
|
npm: npm.version,
|
|
405
418
|
node: node.version,
|
|
419
|
+
plugin_version: pluginVersion(),
|
|
406
420
|
};
|
|
407
421
|
|
|
408
422
|
return {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git sink (S4/D3): artifacts land in a user-run git worktree and are committed there.
|
|
3
|
+
*
|
|
4
|
+
* The sink never carries plaintext: callers hand it the sealed object bytes. Writes are
|
|
5
|
+
* object-id-addressed (`<object_id>.json`), hash-verified on the way in and out, and committed so
|
|
6
|
+
* the same directory can be synced by the operator's own git remote (no relay data plane).
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { execFileSync } from 'node:child_process';
|
|
10
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const OBJECT_ID = /^[0-9a-f]{32,64}$/;
|
|
14
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{root: string, run?: Function, commitPrefix?: string, remote?: string|null, branch?: string|null}} input
|
|
18
|
+
*/
|
|
19
|
+
export function createGitSink({ root, run = null, commitPrefix = 'w2m artifact', remote = null, branch = null }) {
|
|
20
|
+
if (typeof root !== 'string' || root === '') throw new Error('SINK_ROOT_REQUIRED');
|
|
21
|
+
if (!existsSync(join(root, '.git'))) throw new Error('GIT_SINK_NOT_A_REPO');
|
|
22
|
+
mkdirSync(root, { recursive: true });
|
|
23
|
+
const git = (args) => (run === null ? execFileSync('git', args, { cwd: root, stdio: 'pipe' }) : run(args));
|
|
24
|
+
const pathOf = (objectId) => join(root, objectId + '.json');
|
|
25
|
+
|
|
26
|
+
async function writeObject({ objectId, body, expectSha256 = null }) {
|
|
27
|
+
if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
|
|
28
|
+
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf8');
|
|
29
|
+
const actual = sha256(bytes);
|
|
30
|
+
if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
|
|
31
|
+
return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
|
|
32
|
+
}
|
|
33
|
+
const path = pathOf(objectId);
|
|
34
|
+
const tmp = path + '.tmp-' + process.pid + '-' + Date.now().toString(36);
|
|
35
|
+
const fd = openSync(tmp, 'w', 0o600);
|
|
36
|
+
try { writeSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
|
|
37
|
+
renameSync(tmp, path);
|
|
38
|
+
try {
|
|
39
|
+
git(['add', '--', objectId + '.json']);
|
|
40
|
+
git(['commit', '-m', commitPrefix + ' ' + objectId, '--', objectId + '.json']);
|
|
41
|
+
if (remote !== null && branch !== null) git(['push', remote, 'HEAD:' + branch]);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return { ok: false, code: 'GIT_SINK_COMMIT_FAILED', detail: error instanceof Error ? error.message : String(error) };
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, objectId, path, rel_path: objectId + '.json', sha256: actual, bytes: bytes.length };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readObject({ objectId, expectSha256 = null }) {
|
|
49
|
+
if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
|
|
50
|
+
const path = pathOf(objectId);
|
|
51
|
+
if (!existsSync(path)) return { ok: false, code: 'OBJECT_NOT_FOUND' };
|
|
52
|
+
let bytes;
|
|
53
|
+
try { bytes = readFileSync(path); } catch { return { ok: false, code: 'OBJECT_UNREADABLE' }; }
|
|
54
|
+
const actual = sha256(bytes);
|
|
55
|
+
if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
|
|
56
|
+
return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, objectId, path, sha256: actual, bytes };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
kind: 'lan-git',
|
|
63
|
+
writeObject,
|
|
64
|
+
readObject,
|
|
65
|
+
async listObjects() {
|
|
66
|
+
return readdirSync(root).filter((name) => name.endsWith('.json') && OBJECT_ID.test(name.slice(0, -5))).map((name) => name.slice(0, -5));
|
|
67
|
+
},
|
|
68
|
+
async removeObject(objectId) {
|
|
69
|
+
if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(pathOf(objectId));
|
|
72
|
+
git(['rm', '--cached', '--ignore-unmatch', '--', objectId + '.json']);
|
|
73
|
+
git(['commit', '-m', commitPrefix + ' remove ' + objectId, '--', objectId + '.json']);
|
|
74
|
+
return { ok: true };
|
|
75
|
+
} catch { return { ok: false, code: 'OBJECT_NOT_FOUND' }; }
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSH sink (S4/D3): artifacts are written to a directory on a user-owned host over SSH.
|
|
3
|
+
*
|
|
4
|
+
* No relay data plane: the operator supplies the host and directory; every path component is
|
|
5
|
+
* validated before it is embedded in the remote command, and the local side verifies the object
|
|
6
|
+
* hash again after a read. The transport is delegated to the system `ssh` binary (optionally
|
|
7
|
+
* injected for tests), so key handling stays with the user's existing SSH configuration.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
const OBJECT_ID = /^[0-9a-f]{32,64}$/;
|
|
13
|
+
const HOST = /^[A-Za-z0-9._:[\]-]+$/;
|
|
14
|
+
const USER = /^[A-Za-z0-9._-]+$/;
|
|
15
|
+
const DIR = /^\/[A-Za-z0-9._/-]*$/;
|
|
16
|
+
const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
17
|
+
|
|
18
|
+
function defaultRun(args, input) {
|
|
19
|
+
const result = spawnSync('ssh', args, { input, maxBuffer: 64 * 1024 * 1024, windowsHide: true });
|
|
20
|
+
if (result.error) return { ok: false, code: 'SSH_SPAWN_FAILED', detail: result.error.message };
|
|
21
|
+
if (result.status !== 0) return { ok: false, code: 'SSH_COMMAND_FAILED', detail: String(result.stderr ?? '').slice(0, 400) };
|
|
22
|
+
return { ok: true, stdout: Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? '') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{host: string, user: string, dir: string, port?: number, keyPath?: string|null, run?: Function}} input
|
|
27
|
+
*/
|
|
28
|
+
export function createSshSink({ host, user, dir, port = null, keyPath = null, run = null }) {
|
|
29
|
+
if (typeof host !== 'string' || !HOST.test(host)) throw new Error('SSH_SINK_HOST_INVALID');
|
|
30
|
+
if (typeof user !== 'string' || !USER.test(user)) throw new Error('SSH_SINK_USER_INVALID');
|
|
31
|
+
if (typeof dir !== 'string' || !DIR.test(dir) || dir.includes('..')) throw new Error('SSH_SINK_DIR_INVALID');
|
|
32
|
+
if (port !== null && (!Number.isInteger(port) || port < 1 || port > 65535)) throw new Error('SSH_SINK_PORT_INVALID');
|
|
33
|
+
if (keyPath !== null && (typeof keyPath !== 'string' || keyPath === '')) throw new Error('SSH_SINK_KEY_INVALID');
|
|
34
|
+
const exec = run ?? defaultRun;
|
|
35
|
+
const target = user + '@' + host;
|
|
36
|
+
const baseArgs = ['-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new'];
|
|
37
|
+
if (port !== null) baseArgs.push('-p', String(port));
|
|
38
|
+
if (keyPath !== null) baseArgs.push('-i', keyPath);
|
|
39
|
+
const pathOf = (objectId) => dir.replace(/\/$/, '') + '/' + objectId + '.json';
|
|
40
|
+
const ssh = (remoteCommand, input = undefined) => exec([...baseArgs, '--', target, remoteCommand], input);
|
|
41
|
+
|
|
42
|
+
async function writeObject({ objectId, body, expectSha256 = null }) {
|
|
43
|
+
if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
|
|
44
|
+
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(typeof body === 'string' ? body : JSON.stringify(body), 'utf8');
|
|
45
|
+
const actual = sha256(bytes);
|
|
46
|
+
if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
|
|
47
|
+
return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
|
|
48
|
+
}
|
|
49
|
+
const final = pathOf(objectId);
|
|
50
|
+
const tmp = final + '.tmp-' + process.pid + '-' + Date.now().toString(36);
|
|
51
|
+
const written = ssh('mkdir -p ' + dir + ' && cat > ' + tmp + ' && mv ' + tmp + ' ' + final, bytes);
|
|
52
|
+
if (written.ok !== true) return written;
|
|
53
|
+
return { ok: true, objectId, rel_path: objectId + '.json', remote_path: final, sha256: actual, bytes: bytes.length };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readObject({ objectId, expectSha256 = null }) {
|
|
57
|
+
if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
|
|
58
|
+
const read = ssh('cat ' + pathOf(objectId));
|
|
59
|
+
if (read.ok !== true) return read;
|
|
60
|
+
const bytes = read.stdout;
|
|
61
|
+
const actual = sha256(bytes);
|
|
62
|
+
if (typeof expectSha256 === 'string' && expectSha256 !== '' && expectSha256 !== actual) {
|
|
63
|
+
return { ok: false, code: 'OBJECT_HASH_MISMATCH', detail: { expected: expectSha256, actual } };
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, objectId, sha256: actual, bytes };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
kind: 'ssh',
|
|
70
|
+
writeObject,
|
|
71
|
+
readObject,
|
|
72
|
+
async listObjects() {
|
|
73
|
+
const listed = ssh('ls -1 ' + dir);
|
|
74
|
+
if (listed.ok !== true) return [];
|
|
75
|
+
return listed.stdout.toString('utf8').split('\n').map((name) => name.trim()).filter((name) => name.endsWith('.json') && OBJECT_ID.test(name.slice(0, -5))).map((name) => name.slice(0, -5));
|
|
76
|
+
},
|
|
77
|
+
async removeObject(objectId) {
|
|
78
|
+
if (typeof objectId !== 'string' || !OBJECT_ID.test(objectId)) return { ok: false, code: 'OBJECT_ID_INVALID' };
|
|
79
|
+
const removed = ssh('rm -f ' + pathOf(objectId));
|
|
80
|
+
return removed.ok === true ? { ok: true } : removed;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE cursor rule (P0-f): only replayable frames may move the resume cursor.
|
|
3
|
+
*
|
|
4
|
+
* The relay's `ready` and `notice` frames are assigned a sequence number with `reserveSeq()` but are
|
|
5
|
+
* not written into the replay ring, so a client that adopts those numbers claims to have seen events
|
|
6
|
+
* it never received - and a reconnect then silently skips the gap. The cursor therefore advances
|
|
7
|
+
* only for frames the ring actually replays (`task.*`, `peer.*`, `result.ack`).
|
|
8
|
+
*/
|
|
9
|
+
export const NON_REPLAYABLE_EVENTS = Object.freeze(['ready', 'notice']);
|
|
10
|
+
|
|
11
|
+
/** @param {{type?: string|null, event?: string|null}} input */
|
|
12
|
+
export function shouldAdvanceCursor(input) {
|
|
13
|
+
const type = String(input?.type ?? input?.event ?? '');
|
|
14
|
+
if (type === '') return false;
|
|
15
|
+
return !NON_REPLAYABLE_EVENTS.includes(type);
|
|
16
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE watchdog (P0-f): the stream must not hang forever on a half-open connection.
|
|
3
|
+
*
|
|
4
|
+
* Two deadlines are enforced:
|
|
5
|
+
* · first frame — nothing parsed within 5 s means the stream is not really attached;
|
|
6
|
+
* · idle — 45 s without any byte, which is longer than the relay's 15 s keepalive
|
|
7
|
+
* interval by design (three missed keepalives before we give up).
|
|
8
|
+
*
|
|
9
|
+
* `noteBytes` sustains the idle deadline (keepalive comments prove the socket is alive) but it does
|
|
10
|
+
* NOT satisfy the first-frame deadline: a stream that connects and sends keepalives without a
|
|
11
|
+
* parseable `ready` is exactly the buffered/half-dead case this watchdog exists to catch.
|
|
12
|
+
*/
|
|
13
|
+
export const SSE_FIRST_FRAME_MS = 5000;
|
|
14
|
+
export const SSE_IDLE_MS = 45000;
|
|
15
|
+
export const SSE_FIRST_FRAME_TIMEOUT = 'SSE_FIRST_FRAME_TIMEOUT';
|
|
16
|
+
export const SSE_IDLE_TIMEOUT = 'SSE_IDLE_TIMEOUT';
|
|
17
|
+
|
|
18
|
+
export function createSseWatchdog(input = {}) {
|
|
19
|
+
const firstFrameMs = Number.isInteger(input.firstFrameMs) && input.firstFrameMs > 0 ? input.firstFrameMs : SSE_FIRST_FRAME_MS;
|
|
20
|
+
const idleMs = Number.isInteger(input.idleMs) && input.idleMs > 0 ? input.idleMs : SSE_IDLE_MS;
|
|
21
|
+
const onTimeout = typeof input.onTimeout === 'function' ? input.onTimeout : () => {};
|
|
22
|
+
let disposed = false;
|
|
23
|
+
let firstTimer = null;
|
|
24
|
+
let idleTimer = null;
|
|
25
|
+
|
|
26
|
+
const armIdle = () => {
|
|
27
|
+
if (disposed) return;
|
|
28
|
+
if (idleTimer !== null) clearTimeout(idleTimer);
|
|
29
|
+
idleTimer = setTimeout(() => {
|
|
30
|
+
if (disposed) return;
|
|
31
|
+
disposed = true;
|
|
32
|
+
clearTimers();
|
|
33
|
+
onTimeout(SSE_IDLE_TIMEOUT);
|
|
34
|
+
}, idleMs);
|
|
35
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
36
|
+
};
|
|
37
|
+
const clearTimers = () => {
|
|
38
|
+
if (firstTimer !== null) { clearTimeout(firstTimer); firstTimer = null; }
|
|
39
|
+
if (idleTimer !== null) { clearTimeout(idleTimer); idleTimer = null; }
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
firstTimer = setTimeout(() => {
|
|
43
|
+
if (disposed) return;
|
|
44
|
+
disposed = true;
|
|
45
|
+
clearTimers();
|
|
46
|
+
onTimeout(SSE_FIRST_FRAME_TIMEOUT);
|
|
47
|
+
}, firstFrameMs);
|
|
48
|
+
if (typeof firstTimer.unref === 'function') firstTimer.unref();
|
|
49
|
+
armIdle();
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
/** Any byte (headers, keepalive comment, frame) proves the socket is alive: it clears the
|
|
53
|
+
* first-frame deadline and re-arms the idle deadline. */
|
|
54
|
+
noteBytes() {
|
|
55
|
+
if (disposed) return;
|
|
56
|
+
armIdle();
|
|
57
|
+
},
|
|
58
|
+
/** A parsed frame clears the first-frame deadline. */
|
|
59
|
+
noteFrame() {
|
|
60
|
+
if (disposed) return;
|
|
61
|
+
if (firstTimer !== null) { clearTimeout(firstTimer); firstTimer = null; }
|
|
62
|
+
armIdle();
|
|
63
|
+
},
|
|
64
|
+
get disposed() { return disposed; },
|
|
65
|
+
dispose() {
|
|
66
|
+
if (disposed) return;
|
|
67
|
+
disposed = true;
|
|
68
|
+
clearTimers();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|