deadbyte-mcp 0.15.36 → 0.15.38
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/MANIFEST.SHA256 +70 -32
- package/PROVENANCE.json +127 -87
- package/README.txt +12 -12
- package/RELEASE.json +226 -187
- package/SBOM.cdx.json +10 -25
- package/TEST-MATRIX.json +305 -258
- package/bridge/package.json +1 -1
- package/bridge/src/bridge-health-r59.mjs +4 -3
- package/bridge/src/bridge.mjs +75 -18
- package/bridge/src/canonical-json-r60.mjs +13 -0
- package/bridge/src/r40-governor.mjs +109 -19
- package/bridge/src/reconnect-delay-r60.mjs +31 -0
- package/bridge/src/remote-call-claims-r60.mjs +157 -0
- package/bridge/src/remote-dispatch-r60.mjs +75 -0
- package/controller/deadbyte-controller.ps1 +6 -1
- package/controller/deadbyte-process-policy.json +4 -4
- package/docs/R59-PROVENANCE.json +87 -0
- package/docs/R59-RELEASE.json +187 -0
- package/docs/R59-SBOM.cdx.json +68 -0
- package/docs/R59-TEST-MATRIX.json +258 -0
- package/docs/R59-bridge-package.json +13 -0
- package/docs/R59-package-lock.json +184 -0
- package/docs/R59-package.json +106 -0
- package/docs/R59-version.mjs +6 -0
- package/docs/R60-TASK-CONTRACT.md +22 -0
- package/docs/R61-TASK-CONTRACT.md +11 -0
- package/docs/r61-reference-evidence.md +18 -0
- package/package.json +6 -3
- package/scripts/full-regression-r60.mjs +32 -0
- package/scripts/gate-windows-r60-prepublish.ps1 +74 -0
- package/scripts/gate-windows-r61-prepublish.ps1 +45 -0
- package/scripts/npm-artifact-identity-r61.mjs +22 -0
- package/scripts/npm-release-r48.mjs +21 -5
- package/scripts/r61-mixed-lane-benchmark.mjs +98 -0
- package/src/capability-snapshot.mjs +6 -2
- package/src/deadbyte-cli.mjs +89 -40
- package/src/remote-authority.mjs +56 -0
- package/src/remote-enforcement-probe-r60.mjs +17 -0
- package/src/remote-live-log.mjs +47 -7
- package/src/remote-log-decode-r60.mjs +32 -0
- package/src/remote-mcp-machine-smoke-client.mjs +19 -1
- package/src/remote-session-guardian.mjs +20 -3
- package/src/runtime-update.mjs +13 -1
- package/src/tool-audit.mjs +5 -0
- package/src/version.mjs +1 -1
- package/test/bridge-runtime-r59.test.mjs +5 -5
- package/test/commander-schema-differential-r48.test.mjs +1 -1
- package/test/r48-release-contract.test.mjs +1 -1
- package/test/r51-runtime-coherence.test.mjs +2 -2
- package/test/r58-cleanup-retention.test.mjs +9 -3
- package/test/r58-concurrent-recovery.test.mjs +9 -3
- package/test/r58-controller-live-local.test.mjs +22 -3
- package/test/r58-stale-owner.test.mjs +2 -2
- package/test/r59-release-contract.test.mjs +6 -6
- package/test/r60-authority-coherence.test.mjs +36 -0
- package/test/r60-authority-enforcement.test.mjs +28 -0
- package/test/r60-bridge-dispatch.test.mjs +119 -0
- package/test/r60-clock-discipline.test.mjs +15 -0
- package/test/r60-live-timeline.test.mjs +29 -0
- package/test/r60-reconnect-errors.test.mjs +21 -0
- package/test/r60-reconnect-jitter.test.mjs +18 -0
- package/test/r60-release-contract.test.mjs +82 -0
- package/test/r60-remote-call-recovery.test.mjs +170 -0
- package/test/r60-windows-log-decode.test.mjs +25 -0
- package/test/r61-control-lane.test.mjs +154 -0
- package/test/r61-live-timeline-bounds.test.mjs +31 -0
- package/test/r61-npm-artifact-identity.test.mjs +17 -0
- package/test/r61-release-bridge-binding.test.mjs +32 -0
- package/test/r61-release-contract.test.mjs +44 -0
- package/test/remote-live-log-r37.test.mjs +24 -7
- package/test/remote-session-cli.test.mjs +200 -43
|
@@ -6,8 +6,8 @@ function bounded(value,max=512){
|
|
|
6
6
|
.replace(/(?:Bearer\s+\S+|token|secret|password|[A-Za-z]:\\[^ ]+)/gi,'<redacted>').slice(0,max);
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
export function createBridgeHealth({clock=Date.now,maxCapabilityAgeMs=15000}={}){
|
|
10
|
-
if(typeof clock!=='function'||!Number.isInteger(maxCapabilityAgeMs)||maxCapabilityAgeMs<5000||maxCapabilityAgeMs>60000) throw new Error('bridge health options invalid');
|
|
9
|
+
export function createBridgeHealth({clock=Date.now,elapsedClock=clock===Date.now?()=>performance.now():clock,maxCapabilityAgeMs=15000}={}){
|
|
10
|
+
if(typeof clock!=='function'||typeof elapsedClock!=='function'||!Number.isInteger(maxCapabilityAgeMs)||maxCapabilityAgeMs<5000||maxCapabilityAgeMs>60000) throw new Error('bridge health options invalid');
|
|
11
11
|
let phase='STARTING';
|
|
12
12
|
let diagnostic='downstream initialization pending';
|
|
13
13
|
let core=null;
|
|
@@ -24,6 +24,7 @@ export function createBridgeHealth({clock=Date.now,maxCapabilityAgeMs=15000}={})
|
|
|
24
24
|
const observed=Date.parse(manifest.observed_at_utc),validUntil=Date.parse(manifest.valid_until_utc);
|
|
25
25
|
if(!Number.isFinite(observed)||!Number.isFinite(validUntil)||validUntil<observed) throw new Error('bridge capability timestamps invalid');
|
|
26
26
|
capability=Object.freeze({snapshot_id:manifest.snapshot_id,state_epoch:manifest.state_epoch,observed_at_ms:observed,
|
|
27
|
+
received_elapsed_ms:Number(elapsedClock()),age_at_receive_ms:Math.max(0,Number(clock())-observed),
|
|
27
28
|
valid_until_ms:validUntil,release_manifest_sha256:HEX64.test(String(manifest.bindings?.release_manifest_sha256))?manifest.bindings.release_manifest_sha256:null,
|
|
28
29
|
generation_manifest_sha256:HEX64.test(String(manifest.bindings?.generation_manifest_sha256))?manifest.bindings.generation_manifest_sha256:null});
|
|
29
30
|
const unavailable=Object.entries(manifest.planes??{}).filter(([,value])=>value?.state==='DEGRADED'||value?.state==='UNAVAILABLE');
|
|
@@ -38,7 +39,7 @@ export function createBridgeHealth({clock=Date.now,maxCapabilityAgeMs=15000}={})
|
|
|
38
39
|
function snapshot(){
|
|
39
40
|
const now=Number(clock());
|
|
40
41
|
let state=phase,reason=diagnostic;
|
|
41
|
-
const stale=capability&&(
|
|
42
|
+
const stale=capability&&(capability.age_at_receive_ms+Math.max(0,Number(elapsedClock())-capability.received_elapsed_ms)>maxCapabilityAgeMs||now>capability.valid_until_ms);
|
|
42
43
|
if(phase==='READY'&&(!core||!capability||stale)){state='DEGRADED';reason=stale?'capability snapshot stale':'downstream attestation incomplete';}
|
|
43
44
|
if(!PHASES.has(state)) throw new Error('bridge health phase invalid');
|
|
44
45
|
const ready=state==='READY'&&Boolean(core)&&Boolean(capability);
|
package/bridge/src/bridge.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
3
|
import { createServer } from 'node:http';
|
|
3
4
|
import { readFileSync } from 'node:fs';
|
|
4
5
|
import os from 'node:os';
|
|
@@ -10,8 +11,23 @@ import {preparePublicToolDefinitions} from './public-tool-definitions.mjs';
|
|
|
10
11
|
import {createDeadbyteNodeHandler} from './node-http-adapter.mjs';
|
|
11
12
|
import { bridgeCatalogSha256, bridgeDeadlineForTool, createBridgeGovernor, enforceDesktopBridgeResult, verifyCompactCatalogAttestation } from './r40-governor.mjs';
|
|
12
13
|
import { createBridgeHealth } from './bridge-health-r59.mjs';
|
|
14
|
+
import { reconnectDelayMs, waitForReconnect, RECONNECT_ATTEMPT_BUDGET, classifyReconnectFailure } from './reconnect-delay-r60.mjs';
|
|
15
|
+
import {openRemoteCallClaims} from './remote-call-claims-r60.mjs';
|
|
16
|
+
import {dispatchBridgeCall,classifyRemoteOperation} from './remote-dispatch-r60.mjs';
|
|
17
|
+
import {canonicalJson} from './canonical-json-r60.mjs';
|
|
13
18
|
|
|
14
19
|
const coreRoot = path.resolve(process.env.DEADBYTE_CORE_ROOT ?? 'C:/Users/DEADBYTE/Downloads/deadbyte-mcp-v0.6.0');
|
|
20
|
+
const configuredStateRoot=process.env.DEADBYTE_STATE_ROOT;
|
|
21
|
+
if(configuredStateRoot&&(!path.isAbsolute(configuredStateRoot)||configuredStateRoot.trim()===''))throw new Error('bridge state root must be absolute');
|
|
22
|
+
const stateRoot=path.resolve(configuredStateRoot||path.join(os.homedir(),'.deadbyte-mcp'));
|
|
23
|
+
const remoteClaims=openRemoteCallClaims({root:path.join(stateRoot,'runtime','remote-call-claims')});
|
|
24
|
+
let recoveryCursor=null,recoveredClaims=0;
|
|
25
|
+
do{
|
|
26
|
+
const batch=await remoteClaims.recover({after:recoveryCursor,limit:100});
|
|
27
|
+
recoveredClaims+=batch.records.length;
|
|
28
|
+
if(recoveredClaims>100000)throw new Error('remote claim recovery exceeds bounded startup inventory');
|
|
29
|
+
recoveryCursor=batch.next_cursor;
|
|
30
|
+
}while(recoveryCursor!==null);
|
|
15
31
|
const coreServer = path.join(coreRoot, 'src', 'mcp-server.mjs');
|
|
16
32
|
const workspaceRoot = path.resolve(process.env.DEADBYTE_WORKSPACE_ROOT ?? path.join(coreRoot, 'workspaces'));
|
|
17
33
|
const port = Number(process.env.DEADBYTE_BRIDGE_PORT ?? '8787');
|
|
@@ -285,17 +301,23 @@ capabilityRefreshTimer.unref?.();
|
|
|
285
301
|
let shuttingDown=false;
|
|
286
302
|
let reconnecting=false;
|
|
287
303
|
let downstreamGeneration=1;
|
|
288
|
-
const
|
|
289
|
-
const wait=ms=>new Promise(resolve=>setTimeout(resolve,ms));
|
|
304
|
+
const reconnectAbort=new AbortController();
|
|
290
305
|
|
|
291
306
|
async function attestReplacement(client){
|
|
292
|
-
if(client.getProtocolEra()!=='modern') throw new Error('replacement core protocol is not modern');
|
|
307
|
+
if(client.getProtocolEra()!=='modern') throw Object.assign(new Error('replacement core protocol is not modern'),{kind:'terminal',code:'DEADBYTE_SCHEMA_INVALID'});
|
|
293
308
|
const replacement=(await client.listTools()).tools.map(tool=>tool.name).sort();
|
|
294
|
-
if(JSON.stringify(replacement)!==JSON.stringify(actualNames)) throw new Error('replacement core catalog drift');
|
|
309
|
+
if(JSON.stringify(replacement)!==JSON.stringify(actualNames)) throw Object.assign(new Error('replacement core catalog drift'),{kind:'terminal',code:'DEADBYTE_SCHEMA_INVALID'});
|
|
295
310
|
const result=await client.callTool({name:'capability_manifest',arguments:{}},bridgeDeadlineForTool('capability_manifest'));
|
|
296
311
|
verifyCompactCatalogAttestation(actualNames,result);
|
|
297
|
-
|
|
298
|
-
|
|
312
|
+
const manifest=result?.structuredContent?.result;
|
|
313
|
+
if(manifest?.schema!=='deadbyte.capability-manifest.v3') throw Object.assign(new Error('replacement core capability manifest v3 missing'),{kind:'terminal',code:'DEADBYTE_SCHEMA_INVALID'});
|
|
314
|
+
const status=(await client.callTool({name:'machine_status',arguments:{}},bridgeDeadlineForTool('machine_status')))?.structuredContent;
|
|
315
|
+
if(status?.status!=='ok'||!HEX64.test(String(status.machine_id??''))||!HEX64.test(String(status.online_epoch??'')))
|
|
316
|
+
throw Object.assign(new Error('replacement core execution probe failed'),{kind:'transient',code:'DEADBYTE_EXECUTION_PROBE_FAILED'});
|
|
317
|
+
if(status.release_manifest_sha256!==manifest.bindings?.release_manifest_sha256||
|
|
318
|
+
status.generation_manifest_sha256!==manifest.bindings?.generation_manifest_sha256)
|
|
319
|
+
throw Object.assign(new Error('replacement core execution binding mismatch'),{kind:'terminal',code:'DEADBYTE_GENERATION_MISMATCH'});
|
|
320
|
+
return manifest;
|
|
299
321
|
}
|
|
300
322
|
function bindDownstreamClose(transport){
|
|
301
323
|
const sdkClose=transport.onclose;
|
|
@@ -311,8 +333,8 @@ async function reconcileDownstream(){
|
|
|
311
333
|
if(reconnecting||shuttingDown)return;
|
|
312
334
|
reconnecting=true;
|
|
313
335
|
try{
|
|
314
|
-
for(let attempt=0;attempt<
|
|
315
|
-
await
|
|
336
|
+
for(let attempt=0;attempt<RECONNECT_ATTEMPT_BUDGET&&!shuttingDown;attempt++){
|
|
337
|
+
if(!(await waitForReconnect(reconnectDelayMs(attempt),reconnectAbort.signal))||shuttingDown)return;
|
|
316
338
|
const nextClient=newDownstreamClient(),nextTransport=newDownstreamTransport();
|
|
317
339
|
pipeCoreStderr(nextTransport);
|
|
318
340
|
try{
|
|
@@ -326,7 +348,11 @@ async function reconcileDownstream(){
|
|
|
326
348
|
return;
|
|
327
349
|
}catch(error){
|
|
328
350
|
await nextClient.close().catch(()=>{});
|
|
329
|
-
bridgeHealth.reconciling(`core reconnect ${attempt+1}/${
|
|
351
|
+
bridgeHealth.reconciling(`core reconnect ${attempt+1}/${RECONNECT_ATTEMPT_BUDGET} failed: ${error?.message??String(error)}`);
|
|
352
|
+
if(classifyReconnectFailure(error)==='TERMINAL'){
|
|
353
|
+
bridgeHealth.degraded(`core reconnect terminal failure: ${error?.message??String(error)}`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
330
356
|
}
|
|
331
357
|
}
|
|
332
358
|
bridgeHealth.degraded('core restart budget exhausted; bridge quarantined');
|
|
@@ -345,6 +371,36 @@ function plannerTextFromInput(ctx) {
|
|
|
345
371
|
|
|
346
372
|
const publicToolDefinitions=preparePublicToolDefinitions(listed.tools);
|
|
347
373
|
|
|
374
|
+
const remoteDeviceId=createHash('sha256').update(endpointPath,'utf8').digest('hex');
|
|
375
|
+
const ingressDelivery=new AsyncLocalStorage();
|
|
376
|
+
const HEX64=/^[0-9a-f]{64}$/;
|
|
377
|
+
async function dispatchDownstream(tool,args,ctx,budget){
|
|
378
|
+
if(ctx.mcpReq.signal?.aborted)throw new Error('remote request cancelled before claim');
|
|
379
|
+
const observed=(await downstream.callTool({name:'machine_status',arguments:{}},bridgeDeadlineForTool('machine_status')))?.structuredContent;
|
|
380
|
+
const capability=bridgeHealth.snapshot().capability;
|
|
381
|
+
if(observed?.status!=='ok'||!HEX64.test(String(observed.machine_id??''))||!HEX64.test(String(observed.online_epoch??''))||
|
|
382
|
+
!HEX64.test(String(observed.generation_manifest_sha256??''))||typeof observed.release_id!=='string'||
|
|
383
|
+
observed.generation_manifest_sha256!==capability?.generation_manifest_sha256||
|
|
384
|
+
observed.release_manifest_sha256!==capability?.release_manifest_sha256)
|
|
385
|
+
throw new Error('remote claim machine/release/generation observation invalid');
|
|
386
|
+
const requestId=createHash('sha256').update(canonicalJson(ctx.mcpReq.id),'utf8').digest('hex');
|
|
387
|
+
const metadata=ctx.mcpReq._meta;
|
|
388
|
+
const idempotencyKey=metadata?.idempotency_key;
|
|
389
|
+
if(idempotencyKey!==undefined&&typeof idempotencyKey!=='string')throw new Error('remote idempotency_key invalid');
|
|
390
|
+
const classification=classifyRemoteOperation(tool,args);
|
|
391
|
+
// A stateless HTTP delivery is not a durable logical session. Only an
|
|
392
|
+
// explicit idempotency key may identify a mutation across deliveries.
|
|
393
|
+
const deliveryId=ingressDelivery.getStore()?.delivery_id;
|
|
394
|
+
if(!deliveryId)throw new Error('remote ingress delivery identity missing');
|
|
395
|
+
const binding={device_id:remoteDeviceId,session_id:deliveryId,request_id:requestId,
|
|
396
|
+
...(idempotencyKey===undefined?{}:{idempotency_key:idempotencyKey}),tool:tool.name,arguments:args,
|
|
397
|
+
expires_at_utc:new Date(Date.now()+budget.maxTotalTimeout).toISOString(),
|
|
398
|
+
machine_id:observed.machine_id,online_epoch:observed.online_epoch,release_id:observed.release_id,
|
|
399
|
+
generation_manifest_sha256:observed.generation_manifest_sha256,classification};
|
|
400
|
+
return dispatchBridgeCall({claims:remoteClaims,binding,classification,signal:ctx.mcpReq.signal,
|
|
401
|
+
invoke:()=>downstream.callTool({name:tool.name,arguments:args},budget)});
|
|
402
|
+
}
|
|
403
|
+
|
|
348
404
|
function buildFrontServer() {
|
|
349
405
|
const front = new McpServer(
|
|
350
406
|
{ name: 'deadbyte-chatgpt-bridge', version: '0.2.0' },
|
|
@@ -363,16 +419,16 @@ function buildFrontServer() {
|
|
|
363
419
|
if(!currentHealth.core||['STARTING','RECONCILING','STOPPING'].includes(currentHealth.state)) throw new Error(`bridge correlation generation=${downstreamGeneration}; downstream ${currentHealth.state.toLowerCase()}; request not dispatched`);
|
|
364
420
|
if (tool.name !== 'autonomous_goal_run') {
|
|
365
421
|
const audit = auditToolStart(tool.name, args);
|
|
366
|
-
const ticket = await bridgeGovernor.acquire(tool.name,{signal:ctx.mcpReq.signal});
|
|
422
|
+
const ticket = await bridgeGovernor.acquire(tool.name,{signal:ctx.mcpReq.signal,arguments:args});
|
|
367
423
|
try {
|
|
368
|
-
const budget = bridgeDeadlineForTool(tool.name);
|
|
369
|
-
const result = await
|
|
424
|
+
const budget = bridgeDeadlineForTool(tool.name,args);
|
|
425
|
+
const result = await dispatchDownstream(tool,args,ctx,budget);
|
|
370
426
|
enforceDesktopBridgeResult(tool.name, result);
|
|
371
427
|
auditToolFinish(audit, result);
|
|
372
428
|
return result;
|
|
373
429
|
} catch (error) {
|
|
374
430
|
auditToolFail(audit, error);
|
|
375
|
-
if(tool
|
|
431
|
+
if(classifyRemoteOperation(tool,args)!=='read_only'&&['RECONCILING','DEGRADED'].includes(bridgeHealth.snapshot().state)) throw new Error(`bridge correlation call=${audit.id} generation=${downstreamGeneration}; mutation outcome unknown; reconcile before retry`,{cause:error});
|
|
376
432
|
throw error;
|
|
377
433
|
} finally {
|
|
378
434
|
ticket.release();
|
|
@@ -386,11 +442,11 @@ function buildFrontServer() {
|
|
|
386
442
|
callArgs.planner_response = { request_id: state.request_id, raw_text: planner.raw_text, model: planner.model };
|
|
387
443
|
}
|
|
388
444
|
const audit = auditToolStart(tool.name, callArgs);
|
|
389
|
-
const ticket = await bridgeGovernor.acquire(tool.name,{signal:ctx.mcpReq.signal});
|
|
445
|
+
const ticket = await bridgeGovernor.acquire(tool.name,{signal:ctx.mcpReq.signal,arguments:callArgs});
|
|
390
446
|
let result;
|
|
391
447
|
try {
|
|
392
|
-
const budget = bridgeDeadlineForTool(tool.name);
|
|
393
|
-
result = await
|
|
448
|
+
const budget = bridgeDeadlineForTool(tool.name,callArgs);
|
|
449
|
+
result = await dispatchDownstream(tool,callArgs,ctx,budget);
|
|
394
450
|
auditToolFinish(audit, result);
|
|
395
451
|
} catch (error) {
|
|
396
452
|
auditToolFail(audit, error);
|
|
@@ -451,7 +507,7 @@ const httpServer = createServer(async (req, res) => {
|
|
|
451
507
|
res.writeHead(413, { 'content-type': 'text/plain; charset=utf-8' });
|
|
452
508
|
return res.end('request too large');
|
|
453
509
|
}
|
|
454
|
-
await nodeMcpHandler(req, res);
|
|
510
|
+
await ingressDelivery.run({delivery_id:randomUUID()},()=>nodeMcpHandler(req, res));
|
|
455
511
|
} catch (error) {
|
|
456
512
|
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
457
513
|
if (!res.writableEnded) res.end('bridge error');
|
|
@@ -474,6 +530,7 @@ console.log(`BRIDGE_AUTONOMOUS_RUNTIME ${autonomousConfigured ? 'ENABLED' : 'DIS
|
|
|
474
530
|
|
|
475
531
|
async function shutdown() {
|
|
476
532
|
shuttingDown=true;
|
|
533
|
+
reconnectAbort.abort();
|
|
477
534
|
bridgeHealth.stopping();
|
|
478
535
|
clearInterval(capabilityRefreshTimer);
|
|
479
536
|
httpServer.close();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Keep byte-for-byte canonicalization compatible with src/canonical-json.mjs.
|
|
2
|
+
// Bridge dependencies are pinned inside bridge/, so this local copy is tested
|
|
3
|
+
// against the Core primitive rather than reaching outside the Bridge root.
|
|
4
|
+
function canonicalize(value){
|
|
5
|
+
if(Array.isArray(value))return value.map(canonicalize);
|
|
6
|
+
if(value!==null&&typeof value==='object'){
|
|
7
|
+
const out={};
|
|
8
|
+
for(const key of Object.keys(value).sort())out[key]=canonicalize(value[key]);
|
|
9
|
+
return out;
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
export function canonicalJson(value){return JSON.stringify(canonicalize(value));}
|
|
@@ -4,24 +4,39 @@ const DESKTOP_RAW_KEYS=new Set([
|
|
|
4
4
|
'pixels','pixel_bytes','frame_bytes','raw_bgra','raw_rgba','raw_image','screenshot_bytes','bitmap_bytes'
|
|
5
5
|
]);
|
|
6
6
|
const DEFAULT_LIMITS=Object.freeze({
|
|
7
|
+
control:Object.freeze({max_active:8,max_queue:16,queue_timeout_ms:5000}),
|
|
7
8
|
normal:Object.freeze({max_active:8,max_queue:32,queue_timeout_ms:30000}),
|
|
9
|
+
bulk:Object.freeze({max_active:8,max_queue:16,queue_timeout_ms:30000}),
|
|
8
10
|
autonomous:Object.freeze({max_active:2,max_queue:8,queue_timeout_ms:60000}),
|
|
9
11
|
desktop_observe:Object.freeze({max_active:2,max_queue:4,queue_timeout_ms:10000}),
|
|
10
12
|
desktop_action:Object.freeze({max_active:1,max_queue:2,queue_timeout_ms:5000})
|
|
11
13
|
});
|
|
14
|
+
const DEFAULT_SHARED_LIMITS=Object.freeze({max_active:9,max_non_control_active:8});
|
|
12
15
|
const DEADLINES=Object.freeze({
|
|
16
|
+
control:Object.freeze({timeout:10000,maxTotalTimeout:15000}),
|
|
13
17
|
normal:Object.freeze({timeout:120000,maxTotalTimeout:180000}),
|
|
18
|
+
bulk:Object.freeze({timeout:120000,maxTotalTimeout:180000}),
|
|
14
19
|
autonomous:Object.freeze({timeout:900000,maxTotalTimeout:1200000}),
|
|
15
20
|
desktop_observe:Object.freeze({timeout:30000,maxTotalTimeout:60000}),
|
|
16
21
|
desktop_action:Object.freeze({timeout:30000,maxTotalTimeout:45000})
|
|
17
22
|
});
|
|
18
23
|
|
|
19
24
|
function insist(condition,message){ if(!condition) throw new Error(message); }
|
|
20
|
-
function toolClass(name){
|
|
25
|
+
function toolClass(name,args={}){
|
|
21
26
|
const value=String(name??'');
|
|
22
27
|
if(value==='autonomous_goal_run') return 'autonomous';
|
|
23
|
-
if(value==='desktop_action') return 'desktop_action';
|
|
28
|
+
if(value==='desktop_action'||(value==='desktop_control'&&['action','target'].includes(args?.op))) return 'desktop_action';
|
|
24
29
|
if(value.startsWith('desktop_')) return 'desktop_observe';
|
|
30
|
+
if(value==='desktop_control') return 'desktop_observe';
|
|
31
|
+
if(['machine_ping','machine_status','machine_revoke','machine_list','capability_manifest',
|
|
32
|
+
'autonomous_session_status','coding_session_status'].includes(value))return 'control';
|
|
33
|
+
if(value==='machine_observe'&&['machine_ping','machine_status','machine_list'].includes(args?.op))return 'control';
|
|
34
|
+
if(['coding_search','coding_command_run','coding_benchmark_run','coding_task_run',
|
|
35
|
+
'process_session_start','process_session_read','process_session_action',
|
|
36
|
+
'machine_search_start','machine_search_page'].includes(value))return 'bulk';
|
|
37
|
+
if(value==='coding_inspect'&&['search','symbol_references','symbol_implementations'].includes(args?.op))return 'bulk';
|
|
38
|
+
if(value==='process_session'&&['start','read','action'].includes(args?.op))return 'bulk';
|
|
39
|
+
if(value==='machine_observe'&&['search_start','search_page','process_list'].includes(args?.op))return 'bulk';
|
|
25
40
|
return 'normal';
|
|
26
41
|
}
|
|
27
42
|
function estimateBase64Bytes(value){
|
|
@@ -57,8 +72,8 @@ function scanDesktopPayload(value,{maxPreviewBytes},path='result',seen=new Set()
|
|
|
57
72
|
seen.delete(value);
|
|
58
73
|
}
|
|
59
74
|
|
|
60
|
-
export function bridgeToolClass(name){ return toolClass(name); }
|
|
61
|
-
export function bridgeDeadlineForTool(name){ return DEADLINES[toolClass(name)]; }
|
|
75
|
+
export function bridgeToolClass(name,args){ return toolClass(name,args); }
|
|
76
|
+
export function bridgeDeadlineForTool(name,args){ return DEADLINES[toolClass(name,args)]; }
|
|
62
77
|
export function bridgeCatalogSha256(toolNames){
|
|
63
78
|
insist(Array.isArray(toolNames)&&toolNames.every(x=>typeof x==='string'),'bridge catalog names invalid');
|
|
64
79
|
const sorted=[...new Set(toolNames)].sort();
|
|
@@ -95,8 +110,12 @@ export function enforceDesktopBridgeResult(toolName,result,{maxPreviewBytes=1024
|
|
|
95
110
|
return result;
|
|
96
111
|
}
|
|
97
112
|
|
|
98
|
-
export function createBridgeGovernor({limits=
|
|
99
|
-
const
|
|
113
|
+
export function createBridgeGovernor({limits:overrides={},shared:sharedOverrides={}}={}){
|
|
114
|
+
const limits={...DEFAULT_LIMITS,...overrides};
|
|
115
|
+
const shared={...DEFAULT_SHARED_LIMITS,...sharedOverrides};
|
|
116
|
+
insist(Number.isInteger(shared.max_active)&&shared.max_active>=2&&shared.max_active<=64,'bridge shared max_active invalid');
|
|
117
|
+
insist(Number.isInteger(shared.max_non_control_active)&&shared.max_non_control_active>=1&&shared.max_non_control_active<shared.max_active,'bridge shared max_non_control_active invalid');
|
|
118
|
+
const classes=['control','normal','bulk','autonomous','desktop_observe','desktop_action'];
|
|
100
119
|
for(const name of classes){
|
|
101
120
|
const cfg=limits[name];
|
|
102
121
|
insist(cfg&&Number.isInteger(cfg.max_active)&&cfg.max_active>=1&&cfg.max_active<=64,`bridge limit ${name}.max_active invalid`);
|
|
@@ -104,6 +123,7 @@ export function createBridgeGovernor({limits=DEFAULT_LIMITS}={}){
|
|
|
104
123
|
insist(Number.isInteger(cfg.queue_timeout_ms)&&cfg.queue_timeout_ms>=100&&cfg.queue_timeout_ms<=120000,`bridge limit ${name}.queue_timeout_ms invalid`);
|
|
105
124
|
}
|
|
106
125
|
const state=new Map(classes.map(name=>[name,{active:0,queue:[],admitted:0,completed:0,rejected:0,timed_out:0,cancelled:0}]));
|
|
126
|
+
let sharedActive=0,sharedNonControlActive=0;
|
|
107
127
|
let sequence=0;
|
|
108
128
|
let closed=false;
|
|
109
129
|
const drainWaiters=[];
|
|
@@ -113,13 +133,75 @@ export function createBridgeGovernor({limits=DEFAULT_LIMITS}={}){
|
|
|
113
133
|
while(drainWaiters.length) drainWaiters.shift()();
|
|
114
134
|
}
|
|
115
135
|
|
|
116
|
-
function
|
|
136
|
+
function canAdmit(name){
|
|
117
137
|
const cfg=limits[name],s=state.get(name);
|
|
118
|
-
|
|
138
|
+
if(s.active>=cfg.max_active)return false;
|
|
139
|
+
if(sharedActive>=shared.max_active)return false;
|
|
140
|
+
if(name!=='control'&&sharedNonControlActive>=shared.max_non_control_active)return false;
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function noteAdmit(name){
|
|
145
|
+
sharedActive++;
|
|
146
|
+
if(name!=='control')sharedNonControlActive++;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function noteRelease(name){
|
|
150
|
+
sharedActive=Math.max(0,sharedActive-1);
|
|
151
|
+
if(name!=='control')sharedNonControlActive=Math.max(0,sharedNonControlActive-1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function dispatchOne(name){
|
|
155
|
+
const s=state.get(name);
|
|
156
|
+
if(!canAdmit(name))return false;
|
|
157
|
+
|
|
158
|
+
while(s.queue.length){
|
|
119
159
|
const waiter=s.queue.shift();
|
|
120
|
-
if(waiter.done)
|
|
121
|
-
|
|
122
|
-
waiter.
|
|
160
|
+
if(waiter.done)continue;
|
|
161
|
+
|
|
162
|
+
waiter.done=true;
|
|
163
|
+
clearTimeout(waiter.timer);
|
|
164
|
+
waiter.cleanup();
|
|
165
|
+
|
|
166
|
+
s.active++;
|
|
167
|
+
noteAdmit(name);
|
|
168
|
+
s.admitted++;
|
|
169
|
+
|
|
170
|
+
waiter.resolve(
|
|
171
|
+
makeTicket(name,waiter.tool,waiter.id,waiter.queuedAt)
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function oldestAdmissibleQueuedClass(){
|
|
181
|
+
let selected=null;
|
|
182
|
+
|
|
183
|
+
for(const name of classes){
|
|
184
|
+
if(!canAdmit(name))continue;
|
|
185
|
+
|
|
186
|
+
const s=state.get(name);
|
|
187
|
+
const waiter=s.queue.find(item=>!item.done);
|
|
188
|
+
|
|
189
|
+
if(!waiter)continue;
|
|
190
|
+
|
|
191
|
+
if(selected===null||waiter.id<selected.id){
|
|
192
|
+
selected={name,id:waiter.id};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return selected?.name??null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function dispatchAvailable(){
|
|
200
|
+
while(true){
|
|
201
|
+
const name=oldestAdmissibleQueuedClass();
|
|
202
|
+
|
|
203
|
+
if(name===null)break;
|
|
204
|
+
if(!dispatchOne(name))break;
|
|
123
205
|
}
|
|
124
206
|
}
|
|
125
207
|
function makeTicket(name,tool,id,queuedAt){
|
|
@@ -130,19 +212,19 @@ export function createBridgeGovernor({limits=DEFAULT_LIMITS}={}){
|
|
|
130
212
|
if(released) return false;
|
|
131
213
|
released=true;
|
|
132
214
|
const s=state.get(name);
|
|
133
|
-
s.active=Math.max(0,s.active-1);s.completed++;
|
|
134
|
-
|
|
215
|
+
s.active=Math.max(0,s.active-1);noteRelease(name);s.completed++;
|
|
216
|
+
dispatchAvailable();
|
|
135
217
|
finishDrain();
|
|
136
218
|
return true;
|
|
137
219
|
}
|
|
138
220
|
});
|
|
139
221
|
}
|
|
140
|
-
async function acquire(tool,{signal}={}){
|
|
141
|
-
const name=toolClass(tool),cfg=limits[name],s=state.get(name),id=++sequence,queuedAt=Date.now();
|
|
222
|
+
async function acquire(tool,{signal,arguments:args}={}){
|
|
223
|
+
const name=toolClass(tool,args),cfg=limits[name],s=state.get(name),id=++sequence,queuedAt=Date.now();
|
|
142
224
|
if(closed){s.rejected++;throw new Error('bridge governor closed');}
|
|
143
225
|
if(signal?.aborted){s.cancelled++;throw signal.reason instanceof Error?signal.reason:new Error('bridge request aborted');}
|
|
144
|
-
if(
|
|
145
|
-
s.active++;s.admitted++;
|
|
226
|
+
if(canAdmit(name)){
|
|
227
|
+
s.active++;noteAdmit(name);s.admitted++;
|
|
146
228
|
return makeTicket(name,String(tool),id,queuedAt);
|
|
147
229
|
}
|
|
148
230
|
if(s.queue.length>=cfg.max_queue){s.rejected++;throw new Error(`bridge backpressure queue full for ${name}`);}
|
|
@@ -193,14 +275,22 @@ export function createBridgeGovernor({limits=DEFAULT_LIMITS}={}){
|
|
|
193
275
|
return removed;
|
|
194
276
|
}
|
|
195
277
|
function snapshot(){
|
|
196
|
-
|
|
278
|
+
const lanes=Object.fromEntries(classes.map(name=>{
|
|
197
279
|
const s=state.get(name),cfg=limits[name];
|
|
198
280
|
return [name,Object.freeze({active:s.active,queued:s.queue.filter(x=>!x.done).length,max_active:cfg.max_active,max_queue:cfg.max_queue,
|
|
199
281
|
admitted:s.admitted,completed:s.completed,rejected:s.rejected,timed_out:s.timed_out,cancelled:s.cancelled,saturated:s.active>=cfg.max_active})];
|
|
200
|
-
}))
|
|
282
|
+
}));
|
|
283
|
+
return Object.freeze({...lanes,shared:Object.freeze({
|
|
284
|
+
active:sharedActive,
|
|
285
|
+
non_control_active:sharedNonControlActive,
|
|
286
|
+
max_active:shared.max_active,
|
|
287
|
+
max_non_control_active:shared.max_non_control_active,
|
|
288
|
+
saturated:sharedActive>=shared.max_active
|
|
289
|
+
})});
|
|
201
290
|
}
|
|
202
291
|
return Object.freeze({acquire,reconcile,close,snapshot});
|
|
203
292
|
}
|
|
204
293
|
|
|
205
294
|
export const BRIDGE_R40_DEFAULT_LIMITS=DEFAULT_LIMITS;
|
|
295
|
+
export const BRIDGE_R61_SHARED_LIMITS=DEFAULT_SHARED_LIMITS;
|
|
206
296
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
const BASE_DELAYS_MS=Object.freeze([250,500,1000,2000,4000]);
|
|
4
|
+
|
|
5
|
+
export function reconnectDelayMs(attempt,random=Math.random){
|
|
6
|
+
if(!Number.isInteger(attempt)||attempt<0||attempt>=BASE_DELAYS_MS.length) throw new Error('reconnect attempt outside budget');
|
|
7
|
+
const sample=Number(random());
|
|
8
|
+
if(!Number.isFinite(sample)||sample<0||sample>1) throw new Error('reconnect jitter source invalid');
|
|
9
|
+
return Math.round(BASE_DELAYS_MS[attempt]*(0.75+sample*0.5));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const RECONNECT_ATTEMPT_BUDGET=BASE_DELAYS_MS.length;
|
|
13
|
+
|
|
14
|
+
export async function waitForReconnect(milliseconds,signal){
|
|
15
|
+
if(signal?.aborted)return false;
|
|
16
|
+
try{await delay(milliseconds,undefined,{signal});return !signal?.aborted;}
|
|
17
|
+
catch(error){if(error?.name==='AbortError')return false;throw error;}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function classifyReconnectFailure(error){
|
|
21
|
+
const code=String(error?.code??'').toUpperCase();
|
|
22
|
+
if(error?.kind==='terminal')return 'TERMINAL';
|
|
23
|
+
if(error?.kind==='transient')return 'TRANSIENT';
|
|
24
|
+
if(new Set(['DEADBYTE_AUTHORITY_REVOKED','DEADBYTE_POLICY_DENIED','DEADBYTE_SCHEMA_INVALID',
|
|
25
|
+
'DEADBYTE_RELEASE_MISMATCH','DEADBYTE_GENERATION_MISMATCH','DEADBYTE_IDEMPOTENCY_CONFLICT',
|
|
26
|
+
'DEADBYTE_CLAIM_CORRUPT','DEADBYTE_CREDENTIAL_EXPIRED']).has(code))return 'TERMINAL';
|
|
27
|
+
const message=String(error?.message??error??'').toLowerCase();
|
|
28
|
+
if(['EACCES','EPERM','ENOENT'].includes(code)||/identity drift|catalog drift|protocol is not modern|attestation invalid|generation mismatch|policy mismatch/.test(message))return 'TERMINAL';
|
|
29
|
+
if(['ECONNRESET','ECONNREFUSED','EPIPE','ETIMEDOUT','EHOSTUNREACH'].includes(code)||/connection reset|transport closed|timed out|temporarily unavailable/.test(message))return 'TRANSIENT';
|
|
30
|
+
return 'TERMINAL';
|
|
31
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { link, lstat, mkdir, open, readFile, readdir, rm } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { canonicalJson } from './canonical-json-r60.mjs';
|
|
5
|
+
|
|
6
|
+
const HEX64=/^[0-9a-f]{64}$/;
|
|
7
|
+
const ID=/^[A-Za-z0-9._:-]{1,160}$/;
|
|
8
|
+
const sha=value=>createHash('sha256').update(canonicalJson(value),'utf8').digest('hex');
|
|
9
|
+
|
|
10
|
+
async function publishOnce(target,value){
|
|
11
|
+
const temp=`${target}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
12
|
+
const handle=await open(temp,'wx');
|
|
13
|
+
const record={...value,record_sha256:sha(value)};
|
|
14
|
+
try{await handle.writeFile(`${canonicalJson(record)}\n`,'utf8');await handle.sync();}
|
|
15
|
+
finally{await handle.close();}
|
|
16
|
+
try{await link(temp,target);return true;}
|
|
17
|
+
catch(error){if(error?.code==='EEXIST')return false;throw error;}
|
|
18
|
+
finally{await rm(temp,{force:true});}
|
|
19
|
+
}
|
|
20
|
+
async function readExact(file){
|
|
21
|
+
try{
|
|
22
|
+
const info=await lstat(file);
|
|
23
|
+
if(!info.isFile()||info.isSymbolicLink()||info.size>2*1024*1024)throw new Error('remote claim record type or size corrupt');
|
|
24
|
+
const record=JSON.parse(await readFile(file,'utf8'));
|
|
25
|
+
if(!record||typeof record!=='object'||Array.isArray(record))throw new Error('remote claim record corrupt');
|
|
26
|
+
const {record_sha256,...body}=record;
|
|
27
|
+
if(!HEX64.test(String(record_sha256??''))||record_sha256!==sha(body))throw new Error('remote claim record hash corrupt');
|
|
28
|
+
return body;
|
|
29
|
+
}
|
|
30
|
+
catch(error){if(error?.code==='ENOENT')return null;throw new Error(`remote claim state unreadable: ${String(error?.message||error)}`);}
|
|
31
|
+
}
|
|
32
|
+
function validate(binding,now){
|
|
33
|
+
if(!binding||typeof binding!=='object'||Array.isArray(binding))throw new Error('remote claim binding invalid');
|
|
34
|
+
for(const key of ['device_id','session_id','request_id','tool','release_id'])if(!ID.test(String(binding[key]??'')))throw new Error(`remote claim ${key} invalid`);
|
|
35
|
+
if(binding.idempotency_key!==undefined&&!ID.test(String(binding.idempotency_key)))throw new Error('remote claim idempotency_key invalid');
|
|
36
|
+
if(!HEX64.test(String(binding.machine_id??'')))throw new Error('remote claim machine_id invalid');
|
|
37
|
+
if(!HEX64.test(String(binding.generation_manifest_sha256??'')))throw new Error('remote claim generation binding invalid');
|
|
38
|
+
if(!HEX64.test(String(binding.online_epoch??'')))throw new Error('remote claim online epoch invalid');
|
|
39
|
+
if(!['read_only','mutating','unknown'].includes(binding.classification))throw new Error('remote claim classification invalid');
|
|
40
|
+
if(!binding.arguments||typeof binding.arguments!=='object'||Array.isArray(binding.arguments))throw new Error('remote claim arguments invalid');
|
|
41
|
+
const expires=Date.parse(binding.expires_at_utc);
|
|
42
|
+
if(!Number.isFinite(expires))throw new Error('remote claim deadline invalid');
|
|
43
|
+
if(expires<=now)throw new Error('remote request expired');
|
|
44
|
+
return Object.freeze({schema:'deadbyte.remote-call-claim.v1',device_id:binding.device_id,session_id:binding.session_id,
|
|
45
|
+
idempotency_key:binding.idempotency_key??null,
|
|
46
|
+
request_id:String(binding.request_id),tool:binding.tool,payload_sha256:sha(binding.arguments),
|
|
47
|
+
received_at_utc:new Date(now).toISOString(),expires_at_utc:new Date(expires).toISOString(),
|
|
48
|
+
release_id:binding.release_id,generation_manifest_sha256:binding.generation_manifest_sha256,
|
|
49
|
+
machine_id:binding.machine_id,online_epoch:binding.online_epoch,classification:binding.classification});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function openRemoteCallClaims({root,clock=Date.now}={}){
|
|
53
|
+
if(typeof root!=='string'||!path.isAbsolute(root)||typeof clock!=='function')throw new Error('remote claim store options invalid');
|
|
54
|
+
const directory=path.resolve(root);
|
|
55
|
+
function files(id){
|
|
56
|
+
if(!HEX64.test(String(id)))throw new Error('remote claim identity invalid');
|
|
57
|
+
const base=path.join(directory,id.slice(0,2),id);
|
|
58
|
+
return {base,claim:`${base}.claim.json`,claimed:`${base}.claimed.json`,dispatch:`${base}.dispatch.json`,interrupted:`${base}.interrupted.json`,terminal:`${base}.terminal.json`};
|
|
59
|
+
}
|
|
60
|
+
async function state(claimId){
|
|
61
|
+
const f=files(claimId);
|
|
62
|
+
const claim=await readExact(f.claim);
|
|
63
|
+
if(!claim||claim.schema!=='deadbyte.remote-call-claim.v1')throw new Error('remote claim missing or invalid');
|
|
64
|
+
const terminal=await readExact(f.terminal);
|
|
65
|
+
if(terminal){
|
|
66
|
+
if(terminal.schema!=='deadbyte.remote-call-terminal.v1'||terminal.claim_id!==claimId||
|
|
67
|
+
!HEX64.test(String(terminal.result_sha256??''))||terminal.result_sha256!==sha(terminal.result))
|
|
68
|
+
throw new Error('remote claim terminal hash corrupt');
|
|
69
|
+
return Object.freeze({claim_id:claimId,owner:false,state:'TERMINAL',side_effect_state:claim.classification==='read_only'?'none':'unknown',result:terminal.result});
|
|
70
|
+
}
|
|
71
|
+
const interrupted=await readExact(f.interrupted);
|
|
72
|
+
if(interrupted){
|
|
73
|
+
if(interrupted.schema!=='deadbyte.remote-call-interrupted.v1'||interrupted.claim_id!==claimId)throw new Error('remote claim interruption corrupt');
|
|
74
|
+
return Object.freeze({claim_id:claimId,owner:false,state:'INTERRUPTED',side_effect_state:claim.classification==='read_only'?'none':'unknown'});
|
|
75
|
+
}
|
|
76
|
+
const dispatched=await readExact(f.dispatch);
|
|
77
|
+
if(dispatched)return Object.freeze({claim_id:claimId,owner:false,state:'EXECUTING',side_effect_state:claim.classification==='read_only'?'none':'may_have_started'});
|
|
78
|
+
const claimed=await readExact(f.claimed);
|
|
79
|
+
return Object.freeze({claim_id:claimId,owner:false,state:claimed?'CLAIMED':'RECEIVED',side_effect_state:'not_started'});
|
|
80
|
+
}
|
|
81
|
+
async function claim(binding){
|
|
82
|
+
const record=validate(binding,Number(clock()));
|
|
83
|
+
const claimId=record.idempotency_key===null
|
|
84
|
+
?sha({device_id:record.device_id,session_id:record.session_id,request_id:record.request_id})
|
|
85
|
+
:sha({device_id:record.device_id,idempotency_key:record.idempotency_key});
|
|
86
|
+
const f=files(claimId);
|
|
87
|
+
await mkdir(path.dirname(f.base),{recursive:true});
|
|
88
|
+
if(await publishOnce(f.claim,record)){
|
|
89
|
+
const owner=await publishOnce(f.claimed,{schema:'deadbyte.remote-call-claimed.v1',claim_id:claimId,execution_state:'CLAIMED',side_effect_state:'not_started'});
|
|
90
|
+
return owner?Object.freeze({claim_id:claimId,owner:true,state:'CLAIMED',side_effect_state:'not_started'}):state(claimId);
|
|
91
|
+
}
|
|
92
|
+
const prior=await readExact(f.claim);
|
|
93
|
+
if(!prior||prior.schema!==record.schema)throw new Error('remote claim identity corrupt');
|
|
94
|
+
if(Date.parse(prior.expires_at_utc)<=Number(clock()))throw new Error('remote request expired');
|
|
95
|
+
if(prior.tool!==record.tool||prior.payload_sha256!==record.payload_sha256)throw new Error(record.idempotency_key===null?'request_id_conflict':'idempotency_key_conflict');
|
|
96
|
+
if(prior.device_id!==record.device_id||prior.idempotency_key!==record.idempotency_key||
|
|
97
|
+
(record.idempotency_key===null&&(prior.session_id!==record.session_id||prior.request_id!==record.request_id)))throw new Error('remote claim identity drift');
|
|
98
|
+
if(prior.release_id!==record.release_id||prior.generation_manifest_sha256!==record.generation_manifest_sha256)throw new Error('remote claim release identity drift');
|
|
99
|
+
if(prior.machine_id!==record.machine_id||prior.online_epoch!==record.online_epoch||prior.classification!==record.classification)throw new Error('remote claim machine/epoch/classification drift');
|
|
100
|
+
const current=await state(claimId);
|
|
101
|
+
if(current.state==='RECEIVED'&&await publishOnce(f.claimed,{schema:'deadbyte.remote-call-claimed.v1',claim_id:claimId,execution_state:'CLAIMED',side_effect_state:'not_started'}))
|
|
102
|
+
return Object.freeze({claim_id:claimId,owner:true,state:'CLAIMED',side_effect_state:'not_started'});
|
|
103
|
+
return state(claimId);
|
|
104
|
+
}
|
|
105
|
+
async function dispatch(claimId){
|
|
106
|
+
const f=files(claimId);
|
|
107
|
+
if(!(await readExact(f.claimed)))throw new Error('remote claim not claimed before dispatch');
|
|
108
|
+
if(!(await publishOnce(f.dispatch,{schema:'deadbyte.remote-call-dispatch.v1',claim_id:claimId,at_utc:new Date(Number(clock())).toISOString(),execution_state:'EXECUTING',side_effect_state:'may_have_started'})))
|
|
109
|
+
throw new Error('remote claim already dispatched; outcome unknown');
|
|
110
|
+
return Object.freeze({claim_id:claimId,state:'EXECUTING',side_effect_state:'may_have_started'});
|
|
111
|
+
}
|
|
112
|
+
async function terminal(claimId,result){
|
|
113
|
+
const f=files(claimId);
|
|
114
|
+
if(!(await readExact(f.dispatch)))throw new Error('remote claim not dispatched');
|
|
115
|
+
const bytes=Buffer.byteLength(canonicalJson(result),'utf8');
|
|
116
|
+
if(bytes>1048576)throw new Error('remote claim terminal result exceeds bound');
|
|
117
|
+
if(!(await publishOnce(f.terminal,{schema:'deadbyte.remote-call-terminal.v1',claim_id:claimId,
|
|
118
|
+
at_utc:new Date(Number(clock())).toISOString(),result_sha256:sha(result),result})))throw new Error('remote claim already terminal');
|
|
119
|
+
return state(claimId);
|
|
120
|
+
}
|
|
121
|
+
async function recover({after=null,limit=100}={}){
|
|
122
|
+
if(after!==null&&!HEX64.test(String(after)))throw new Error('remote recovery cursor invalid');
|
|
123
|
+
if(!Number.isInteger(limit)||limit<1||limit>100)throw new Error('remote recovery batch limit invalid');
|
|
124
|
+
const records=[];let more=false;
|
|
125
|
+
for(let shard=0;shard<256;shard++){
|
|
126
|
+
const prefix=shard.toString(16).padStart(2,'0');
|
|
127
|
+
if(after&&prefix<after.slice(0,2))continue;
|
|
128
|
+
const folder=path.join(directory,prefix);
|
|
129
|
+
let names;
|
|
130
|
+
try{
|
|
131
|
+
const info=await lstat(folder);
|
|
132
|
+
if(!info.isDirectory()||info.isSymbolicLink())throw new Error('remote recovery shard corrupt');
|
|
133
|
+
names=await readdir(folder);
|
|
134
|
+
}catch(error){if(error?.code==='ENOENT')continue;throw error;}
|
|
135
|
+
if(names.length>4096)throw new Error('remote recovery shard exceeds bounded inventory');
|
|
136
|
+
for(const name of names.filter(item=>/^[0-9a-f]{64}\.claim\.json$/.test(item)).sort()){
|
|
137
|
+
const id=name.slice(0,64);
|
|
138
|
+
if(after&&id<=after)continue;
|
|
139
|
+
if(records.length>=limit){more=true;break;}
|
|
140
|
+
const claimRecord=await readExact(files(id).claim);
|
|
141
|
+
if(!claimRecord||claimRecord.schema!=='deadbyte.remote-call-claim.v1')throw new Error('remote recovery claim corrupt');
|
|
142
|
+
if(Date.parse(claimRecord.expires_at_utc)<=Number(clock()))continue;
|
|
143
|
+
let current=await state(id);
|
|
144
|
+
if(current.state==='TERMINAL')continue;
|
|
145
|
+
if(current.state==='EXECUTING'){
|
|
146
|
+
await publishOnce(files(id).interrupted,{schema:'deadbyte.remote-call-interrupted.v1',claim_id:id,
|
|
147
|
+
at_utc:new Date(Number(clock())).toISOString(),execution_state:'INTERRUPTED',side_effect_state:current.side_effect_state==='none'?'none':'unknown'});
|
|
148
|
+
current=await state(id);
|
|
149
|
+
}
|
|
150
|
+
records.push(current);
|
|
151
|
+
}
|
|
152
|
+
if(more)break;
|
|
153
|
+
}
|
|
154
|
+
return Object.freeze({records,next_cursor:more?records.at(-1)?.claim_id??after:null});
|
|
155
|
+
}
|
|
156
|
+
return Object.freeze({claim,dispatch,terminal,state,recover});
|
|
157
|
+
}
|