deadbyte-mcp 0.15.35 → 0.15.37
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 +91 -46
- package/PROVENANCE.json +76 -30
- package/README.txt +62 -8
- package/RELEASE.json +78 -23
- package/SBOM.cdx.json +10 -25
- package/TEST-MATRIX.json +109 -44
- package/bridge/package.json +4 -2
- package/bridge/src/bridge-health-r59.mjs +53 -0
- package/bridge/src/bridge.mjs +158 -26
- package/bridge/src/canonical-json-r60.mjs +13 -0
- package/bridge/src/r40-governor.mjs +56 -10
- 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 +76 -13
- 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-TASK-CONTRACT.md +26 -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/package.json +7 -3
- package/proof/r59-full-regression-current.json +13 -0
- package/proof/r59-full-regression-current.stderr.log +0 -0
- package/proof/r59-full-regression-current.stdout.log +1268 -0
- package/scripts/full-regression-r60.mjs +32 -0
- package/scripts/gate-windows-r59-prepublish.ps1 +41 -0
- package/scripts/gate-windows-r60-prepublish.ps1 +74 -0
- package/scripts/r59-full-regression-capture.mjs +35 -0
- package/src/capability-resolver.mjs +25 -7
- package/src/capability-snapshot.mjs +56 -0
- package/src/compact-gateway-mcp-tools.mjs +10 -11
- package/src/deadbyte-cli.mjs +80 -34
- package/src/mcp-server.mjs +179 -118
- package/src/r40-compact-disarmed-probe.mjs +1 -1
- package/src/r44-compact-disarmed-probe.mjs +1 -1
- package/src/r45-compact-disarmed-probe.mjs +1 -1
- package/src/remote-authority.mjs +56 -0
- package/src/remote-enforcement-probe-r60.mjs +17 -0
- package/src/remote-live-log.mjs +19 -2
- package/src/remote-log-decode-r60.mjs +32 -0
- package/src/remote-mcp-machine-smoke-client.mjs +19 -1
- package/src/remote-mcp-r40-parity-client.mjs +1 -1
- package/src/remote-mcp-r44-parity-client.mjs +1 -1
- package/src/remote-mcp-r45-parity-client.mjs +1 -1
- package/src/remote-mcp-smoke-client.mjs +1 -1
- package/src/remote-session-guardian.mjs +20 -3
- package/src/runtime-plane-registry.mjs +74 -0
- package/src/tool-audit.mjs +5 -0
- package/src/version.mjs +1 -1
- package/test/bridge-runtime-r59.test.mjs +60 -0
- package/test/capability-resolver.test.mjs +16 -0
- package/test/capability-snapshot-r59.test.mjs +39 -0
- package/test/compact-gateway-r36.test.mjs +22 -2
- package/test/compact-max-catalog-r44.test.mjs +1 -1
- package/test/compact-max-catalog-r45.test.mjs +1 -1
- package/test/contained.test.mjs +2 -1
- package/test/controller-policy-preflight-r59.test.mjs +39 -0
- package/test/fixtures/r59-compact-surface-v0.15.35.json +222 -0
- package/test/observation-integration.test.mjs +2 -3
- package/test/optional-plane-degraded-r59.test.mjs +44 -0
- package/test/r48-release-contract.test.mjs +1 -1
- package/test/r49-windows-gate.test.mjs +1 -1
- package/test/r51-runtime-coherence.test.mjs +2 -2
- package/test/r58-bundled-bridge.test.mjs +3 -1
- 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 +36 -4
- package/test/r58-controller-state-coherence.test.mjs +1 -1
- package/test/r58-release-contract.test.mjs +3 -2
- package/test/r58-stale-owner.test.mjs +2 -2
- package/test/r58-windows-gate.test.mjs +2 -1
- package/test/r59-release-contract.test.mjs +61 -0
- package/test/r59-tool-surface-compat.test.mjs +90 -0
- 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 +81 -0
- package/test/r60-remote-call-recovery.test.mjs +170 -0
- package/test/r60-windows-log-decode.test.mjs +25 -0
- package/test/remote-live-log-r37.test.mjs +1 -1
- package/test/remote-session-cli.test.mjs +200 -43
- package/test/runtime-plane-registry-r59.test.mjs +40 -0
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';
|
|
@@ -9,8 +10,24 @@ import { McpServer, createMcpHandler, createRequestStateCodec, inputRequired, in
|
|
|
9
10
|
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';
|
|
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';
|
|
12
18
|
|
|
13
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);
|
|
14
31
|
const coreServer = path.join(coreRoot, 'src', 'mcp-server.mjs');
|
|
15
32
|
const workspaceRoot = path.resolve(process.env.DEADBYTE_WORKSPACE_ROOT ?? path.join(coreRoot, 'workspaces'));
|
|
16
33
|
const port = Number(process.env.DEADBYTE_BRIDGE_PORT ?? '8787');
|
|
@@ -68,6 +85,7 @@ const SENSITIVE_AUDIT_KEY = /(token|secret|password|authorization|cookie|recover
|
|
|
68
85
|
const PAYLOAD_AUDIT_KEY = /^(content|raw_text|text)$/i;
|
|
69
86
|
let auditSequence = 0;
|
|
70
87
|
const bridgeGovernor = createBridgeGovernor();
|
|
88
|
+
const bridgeHealth=createBridgeHealth({maxCapabilityAgeMs:15000});
|
|
71
89
|
|
|
72
90
|
function auditDigest(text) {
|
|
73
91
|
return createHash('sha256').update(text, 'utf8').digest('hex');
|
|
@@ -220,23 +238,18 @@ process.on('exit', code => {
|
|
|
220
238
|
console.error(`[bridge-exit] code=${code}`);
|
|
221
239
|
});
|
|
222
240
|
|
|
223
|
-
|
|
224
|
-
{
|
|
225
|
-
{
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
...process.env,
|
|
234
|
-
DEADBYTE_WORKSPACE_ROOT: workspaceRoot
|
|
235
|
-
},
|
|
236
|
-
stderr: 'pipe'
|
|
237
|
-
});
|
|
241
|
+
function newDownstreamClient(){return new Client(
|
|
242
|
+
{name:'deadbyte-chatgpt-bridge',version:'0.2.0'},
|
|
243
|
+
{versionNegotiation:{mode:{pin:'2026-07-28'}}}
|
|
244
|
+
);}
|
|
245
|
+
function newDownstreamTransport(){return new StdioClientTransport({command:process.execPath,args:[coreServer],cwd:coreRoot,
|
|
246
|
+
env:{...getDefaultEnvironment(),...process.env,DEADBYTE_WORKSPACE_ROOT:workspaceRoot},stderr:'pipe'});}
|
|
247
|
+
function pipeCoreStderr(transport){transport.stderr?.on('data',chunk=>process.stderr.write(`[deadbyte-core] ${chunk}`));}
|
|
248
|
+
|
|
249
|
+
let downstream=newDownstreamClient();
|
|
250
|
+
let downstreamTransport=newDownstreamTransport();
|
|
238
251
|
|
|
239
|
-
downstreamTransport
|
|
252
|
+
pipeCoreStderr(downstreamTransport);
|
|
240
253
|
await downstream.connect(downstreamTransport);
|
|
241
254
|
if (downstream.getProtocolEra() !== 'modern') throw new Error('DEADBYTE downstream is not modern MCP');
|
|
242
255
|
|
|
@@ -245,6 +258,7 @@ const actualNames = listed.tools.map(tool => tool.name).sort();
|
|
|
245
258
|
const exactR36Compact = JSON.stringify(actualNames) === JSON.stringify(r36CompactTools);
|
|
246
259
|
let compactCatalogAttestation = null;
|
|
247
260
|
let compactCatalogLegacy = false;
|
|
261
|
+
let currentCapabilityManifest = null;
|
|
248
262
|
if (actualNames.includes('capability_manifest')) {
|
|
249
263
|
const manifestResult = await downstream.callTool(
|
|
250
264
|
{ name:'capability_manifest', arguments:{} },
|
|
@@ -253,6 +267,7 @@ if (actualNames.includes('capability_manifest')) {
|
|
|
253
267
|
const manifestSchema = manifestResult?.structuredContent?.result?.schema ?? null;
|
|
254
268
|
if (manifestSchema === 'deadbyte.capability-manifest.v2' || manifestSchema === 'deadbyte.capability-manifest.v3') {
|
|
255
269
|
compactCatalogAttestation = verifyCompactCatalogAttestation(actualNames, manifestResult);
|
|
270
|
+
if(manifestSchema==='deadbyte.capability-manifest.v3') currentCapabilityManifest=manifestResult.structuredContent.result;
|
|
256
271
|
} else if (manifestSchema === 'deadbyte.capability-manifest.v1' && exactR36Compact) {
|
|
257
272
|
compactCatalogLegacy = true;
|
|
258
273
|
} else {
|
|
@@ -268,6 +283,82 @@ const agentConfigured = agentTools.every(name => actualNames.includes(name));
|
|
|
268
283
|
const codingConfigured = codingTools.every(name => actualNames.includes(name));
|
|
269
284
|
const autonomousConfigured = autonomousTools.every(name => actualNames.includes(name));
|
|
270
285
|
const bridgeCatalogDigest = bridgeCatalogSha256(actualNames);
|
|
286
|
+
const coreIdentity=createHash('sha256').update(readFileSync(coreServer)).digest('hex');
|
|
287
|
+
bridgeHealth.coreConnected({childIdentity:coreIdentity,protocolEra:downstream.getProtocolEra(),catalogSha256:bridgeCatalogDigest});
|
|
288
|
+
if(currentCapabilityManifest) bridgeHealth.capabilityObserved(currentCapabilityManifest);
|
|
289
|
+
else bridgeHealth.degraded('capability manifest v3 is required for fresh readiness');
|
|
290
|
+
|
|
291
|
+
async function refreshCapabilityAttestation(){
|
|
292
|
+
if(reconnecting||!bridgeHealth.snapshot().core)return;
|
|
293
|
+
try{
|
|
294
|
+
const result=await downstream.callTool({name:'capability_manifest',arguments:{}},bridgeDeadlineForTool('capability_manifest'));
|
|
295
|
+
verifyCompactCatalogAttestation(actualNames,result);
|
|
296
|
+
bridgeHealth.capabilityObserved(result.structuredContent.result);
|
|
297
|
+
}catch(error){bridgeHealth.degraded(`capability refresh failed: ${error?.message??String(error)}`);}
|
|
298
|
+
}
|
|
299
|
+
const capabilityRefreshTimer=setInterval(()=>{void refreshCapabilityAttestation();},4000);
|
|
300
|
+
capabilityRefreshTimer.unref?.();
|
|
301
|
+
let shuttingDown=false;
|
|
302
|
+
let reconnecting=false;
|
|
303
|
+
let downstreamGeneration=1;
|
|
304
|
+
const reconnectAbort=new AbortController();
|
|
305
|
+
|
|
306
|
+
async function attestReplacement(client){
|
|
307
|
+
if(client.getProtocolEra()!=='modern') throw Object.assign(new Error('replacement core protocol is not modern'),{kind:'terminal',code:'DEADBYTE_SCHEMA_INVALID'});
|
|
308
|
+
const replacement=(await client.listTools()).tools.map(tool=>tool.name).sort();
|
|
309
|
+
if(JSON.stringify(replacement)!==JSON.stringify(actualNames)) throw Object.assign(new Error('replacement core catalog drift'),{kind:'terminal',code:'DEADBYTE_SCHEMA_INVALID'});
|
|
310
|
+
const result=await client.callTool({name:'capability_manifest',arguments:{}},bridgeDeadlineForTool('capability_manifest'));
|
|
311
|
+
verifyCompactCatalogAttestation(actualNames,result);
|
|
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;
|
|
321
|
+
}
|
|
322
|
+
function bindDownstreamClose(transport){
|
|
323
|
+
const sdkClose=transport.onclose;
|
|
324
|
+
transport.onclose=()=>{
|
|
325
|
+
sdkClose?.();
|
|
326
|
+
if(shuttingDown)return;
|
|
327
|
+
bridgeHealth.reconciling(`downstream core generation ${downstreamGeneration} closed`);
|
|
328
|
+
bridgeGovernor.reconcile('downstream core unavailable before dispatch');
|
|
329
|
+
void reconcileDownstream();
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
async function reconcileDownstream(){
|
|
333
|
+
if(reconnecting||shuttingDown)return;
|
|
334
|
+
reconnecting=true;
|
|
335
|
+
try{
|
|
336
|
+
for(let attempt=0;attempt<RECONNECT_ATTEMPT_BUDGET&&!shuttingDown;attempt++){
|
|
337
|
+
if(!(await waitForReconnect(reconnectDelayMs(attempt),reconnectAbort.signal))||shuttingDown)return;
|
|
338
|
+
const nextClient=newDownstreamClient(),nextTransport=newDownstreamTransport();
|
|
339
|
+
pipeCoreStderr(nextTransport);
|
|
340
|
+
try{
|
|
341
|
+
if(createHash('sha256').update(readFileSync(coreServer)).digest('hex')!==coreIdentity) throw new Error('core script identity drift');
|
|
342
|
+
await nextClient.connect(nextTransport);
|
|
343
|
+
const manifest=await attestReplacement(nextClient);
|
|
344
|
+
downstream=nextClient;downstreamTransport=nextTransport;downstreamGeneration++;
|
|
345
|
+
bindDownstreamClose(nextTransport);
|
|
346
|
+
bridgeHealth.coreConnected({childIdentity:coreIdentity,protocolEra:nextClient.getProtocolEra(),catalogSha256:bridgeCatalogDigest});
|
|
347
|
+
bridgeHealth.capabilityObserved(manifest);
|
|
348
|
+
return;
|
|
349
|
+
}catch(error){
|
|
350
|
+
await nextClient.close().catch(()=>{});
|
|
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
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
bridgeHealth.degraded('core restart budget exhausted; bridge quarantined');
|
|
359
|
+
}finally{reconnecting=false;}
|
|
360
|
+
}
|
|
361
|
+
bindDownstreamClose(downstreamTransport);
|
|
271
362
|
|
|
272
363
|
function plannerTextFromInput(ctx) {
|
|
273
364
|
const response = inputResponse(ctx.mcpReq.inputResponses, 'planner');
|
|
@@ -280,6 +371,36 @@ function plannerTextFromInput(ctx) {
|
|
|
280
371
|
|
|
281
372
|
const publicToolDefinitions=preparePublicToolDefinitions(listed.tools);
|
|
282
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
|
+
|
|
283
404
|
function buildFrontServer() {
|
|
284
405
|
const front = new McpServer(
|
|
285
406
|
{ name: 'deadbyte-chatgpt-bridge', version: '0.2.0' },
|
|
@@ -294,17 +415,20 @@ function buildFrontServer() {
|
|
|
294
415
|
outputSchema,
|
|
295
416
|
annotations: tool.annotations
|
|
296
417
|
}, async (args, ctx) => {
|
|
418
|
+
const currentHealth=bridgeHealth.snapshot();
|
|
419
|
+
if(!currentHealth.core||['STARTING','RECONCILING','STOPPING'].includes(currentHealth.state)) throw new Error(`bridge correlation generation=${downstreamGeneration}; downstream ${currentHealth.state.toLowerCase()}; request not dispatched`);
|
|
297
420
|
if (tool.name !== 'autonomous_goal_run') {
|
|
298
421
|
const audit = auditToolStart(tool.name, args);
|
|
299
|
-
const ticket = await bridgeGovernor.acquire(tool.name);
|
|
422
|
+
const ticket = await bridgeGovernor.acquire(tool.name,{signal:ctx.mcpReq.signal});
|
|
300
423
|
try {
|
|
301
424
|
const budget = bridgeDeadlineForTool(tool.name);
|
|
302
|
-
const result = await
|
|
425
|
+
const result = await dispatchDownstream(tool,args,ctx,budget);
|
|
303
426
|
enforceDesktopBridgeResult(tool.name, result);
|
|
304
427
|
auditToolFinish(audit, result);
|
|
305
428
|
return result;
|
|
306
429
|
} catch (error) {
|
|
307
430
|
auditToolFail(audit, error);
|
|
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});
|
|
308
432
|
throw error;
|
|
309
433
|
} finally {
|
|
310
434
|
ticket.release();
|
|
@@ -318,14 +442,15 @@ function buildFrontServer() {
|
|
|
318
442
|
callArgs.planner_response = { request_id: state.request_id, raw_text: planner.raw_text, model: planner.model };
|
|
319
443
|
}
|
|
320
444
|
const audit = auditToolStart(tool.name, callArgs);
|
|
321
|
-
const ticket = await bridgeGovernor.acquire(tool.name);
|
|
445
|
+
const ticket = await bridgeGovernor.acquire(tool.name,{signal:ctx.mcpReq.signal});
|
|
322
446
|
let result;
|
|
323
447
|
try {
|
|
324
448
|
const budget = bridgeDeadlineForTool(tool.name);
|
|
325
|
-
result = await
|
|
449
|
+
result = await dispatchDownstream(tool,callArgs,ctx,budget);
|
|
326
450
|
auditToolFinish(audit, result);
|
|
327
451
|
} catch (error) {
|
|
328
452
|
auditToolFail(audit, error);
|
|
453
|
+
if(['RECONCILING','DEGRADED'].includes(bridgeHealth.snapshot().state)) throw new Error(`bridge correlation call=${audit.id} generation=${downstreamGeneration}; autonomous outcome unknown; reconcile before retry`,{cause:error});
|
|
329
454
|
throw error;
|
|
330
455
|
} finally {
|
|
331
456
|
ticket.release();
|
|
@@ -359,13 +484,15 @@ const httpServer = createServer(async (req, res) => {
|
|
|
359
484
|
try {
|
|
360
485
|
const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname;
|
|
361
486
|
if (pathname === '/healthz') {
|
|
362
|
-
|
|
363
|
-
|
|
487
|
+
const health=bridgeHealth.snapshot();
|
|
488
|
+
res.writeHead(health.http_status, { 'content-type': 'application/json; charset=utf-8','cache-control':'no-store' });
|
|
489
|
+
return res.end(JSON.stringify(health));
|
|
364
490
|
}
|
|
365
491
|
if (pathname === '/healthz/r40') {
|
|
366
|
-
|
|
492
|
+
const health=bridgeHealth.snapshot();
|
|
493
|
+
res.writeHead(health.http_status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
367
494
|
return res.end(JSON.stringify({
|
|
368
|
-
status:'ok',catalog_sha256:bridgeCatalogDigest,
|
|
495
|
+
status:health.ready?'ok':'degraded',runtime:health,catalog_sha256:bridgeCatalogDigest,
|
|
369
496
|
catalog_attestation:compactCatalogAttestation?.ok===true?'verified':compactCatalogLegacy?'legacy-r39':'not-applicable',
|
|
370
497
|
ledger_sha256:compactCatalogAttestation?.ledger_sha256??null,
|
|
371
498
|
governor:bridgeGovernor.snapshot()
|
|
@@ -380,7 +507,7 @@ const httpServer = createServer(async (req, res) => {
|
|
|
380
507
|
res.writeHead(413, { 'content-type': 'text/plain; charset=utf-8' });
|
|
381
508
|
return res.end('request too large');
|
|
382
509
|
}
|
|
383
|
-
await nodeMcpHandler(req, res);
|
|
510
|
+
await ingressDelivery.run({delivery_id:randomUUID()},()=>nodeMcpHandler(req, res));
|
|
384
511
|
} catch (error) {
|
|
385
512
|
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
386
513
|
if (!res.writableEnded) res.end('bridge error');
|
|
@@ -402,7 +529,12 @@ console.log(`BRIDGE_CODING_RUNTIME ${codingConfigured ? 'ENABLED' : 'DISABLED'}`
|
|
|
402
529
|
console.log(`BRIDGE_AUTONOMOUS_RUNTIME ${autonomousConfigured ? 'ENABLED' : 'DISABLED'}`);
|
|
403
530
|
|
|
404
531
|
async function shutdown() {
|
|
532
|
+
shuttingDown=true;
|
|
533
|
+
reconnectAbort.abort();
|
|
534
|
+
bridgeHealth.stopping();
|
|
535
|
+
clearInterval(capabilityRefreshTimer);
|
|
405
536
|
httpServer.close();
|
|
537
|
+
await Promise.race([bridgeGovernor.close('bridge stopping'),new Promise(resolve=>setTimeout(resolve,5000))]);
|
|
406
538
|
await mcpHandler.close().catch(() => {});
|
|
407
539
|
await downstream.close().catch(() => {});
|
|
408
540
|
process.exit(0);
|
|
@@ -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));}
|
|
@@ -103,15 +103,22 @@ export function createBridgeGovernor({limits=DEFAULT_LIMITS}={}){
|
|
|
103
103
|
insist(Number.isInteger(cfg.max_queue)&&cfg.max_queue>=0&&cfg.max_queue<=256,`bridge limit ${name}.max_queue invalid`);
|
|
104
104
|
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
105
|
}
|
|
106
|
-
const state=new Map(classes.map(name=>[name,{active:0,queue:[]}]));
|
|
106
|
+
const state=new Map(classes.map(name=>[name,{active:0,queue:[],admitted:0,completed:0,rejected:0,timed_out:0,cancelled:0}]));
|
|
107
107
|
let sequence=0;
|
|
108
|
+
let closed=false;
|
|
109
|
+
const drainWaiters=[];
|
|
110
|
+
|
|
111
|
+
function finishDrain(){
|
|
112
|
+
if(!closed||[...state.values()].some(item=>item.active>0||item.queue.some(waiter=>!waiter.done))) return;
|
|
113
|
+
while(drainWaiters.length) drainWaiters.shift()();
|
|
114
|
+
}
|
|
108
115
|
|
|
109
116
|
function dispatch(name){
|
|
110
117
|
const cfg=limits[name],s=state.get(name);
|
|
111
118
|
while(s.active<cfg.max_active&&s.queue.length){
|
|
112
119
|
const waiter=s.queue.shift();
|
|
113
120
|
if(waiter.done) continue;
|
|
114
|
-
waiter.done=true; clearTimeout(waiter.timer); s.active++;
|
|
121
|
+
waiter.done=true; clearTimeout(waiter.timer); waiter.cleanup(); s.active++; s.admitted++;
|
|
115
122
|
waiter.resolve(makeTicket(name,waiter.tool,waiter.id,waiter.queuedAt));
|
|
116
123
|
}
|
|
117
124
|
}
|
|
@@ -123,37 +130,76 @@ export function createBridgeGovernor({limits=DEFAULT_LIMITS}={}){
|
|
|
123
130
|
if(released) return false;
|
|
124
131
|
released=true;
|
|
125
132
|
const s=state.get(name);
|
|
126
|
-
s.active=Math.max(0,s.active-1);
|
|
133
|
+
s.active=Math.max(0,s.active-1);s.completed++;
|
|
127
134
|
dispatch(name);
|
|
135
|
+
finishDrain();
|
|
128
136
|
return true;
|
|
129
137
|
}
|
|
130
138
|
});
|
|
131
139
|
}
|
|
132
|
-
async function acquire(tool){
|
|
140
|
+
async function acquire(tool,{signal}={}){
|
|
133
141
|
const name=toolClass(tool),cfg=limits[name],s=state.get(name),id=++sequence,queuedAt=Date.now();
|
|
142
|
+
if(closed){s.rejected++;throw new Error('bridge governor closed');}
|
|
143
|
+
if(signal?.aborted){s.cancelled++;throw signal.reason instanceof Error?signal.reason:new Error('bridge request aborted');}
|
|
134
144
|
if(s.active<cfg.max_active){
|
|
135
|
-
s.active++;
|
|
145
|
+
s.active++;s.admitted++;
|
|
136
146
|
return makeTicket(name,String(tool),id,queuedAt);
|
|
137
147
|
}
|
|
138
|
-
if(s.queue.length>=cfg.max_queue)
|
|
148
|
+
if(s.queue.length>=cfg.max_queue){s.rejected++;throw new Error(`bridge backpressure queue full for ${name}`);}
|
|
139
149
|
return await new Promise((resolve,reject)=>{
|
|
140
|
-
const waiter={id,tool:String(tool),queuedAt,resolve,reject,done:false,timer:null};
|
|
150
|
+
const waiter={id,tool:String(tool),queuedAt,resolve,reject,done:false,timer:null,cleanup:()=>{}};
|
|
151
|
+
const abort=()=>{
|
|
152
|
+
if(waiter.done)return;
|
|
153
|
+
waiter.done=true;clearTimeout(waiter.timer);waiter.cleanup();
|
|
154
|
+
const index=s.queue.indexOf(waiter);if(index>=0)s.queue.splice(index,1);
|
|
155
|
+
s.cancelled++;reject(signal?.reason instanceof Error?signal.reason:new Error('bridge request aborted'));finishDrain();
|
|
156
|
+
};
|
|
157
|
+
waiter.cleanup=()=>signal?.removeEventListener?.('abort',abort);
|
|
158
|
+
signal?.addEventListener?.('abort',abort,{once:true});
|
|
141
159
|
waiter.timer=setTimeout(()=>{
|
|
142
160
|
if(waiter.done)return;
|
|
143
|
-
waiter.done=true;
|
|
161
|
+
waiter.done=true;waiter.cleanup();s.timed_out++;
|
|
144
162
|
const index=s.queue.indexOf(waiter); if(index>=0)s.queue.splice(index,1);
|
|
145
163
|
reject(new Error(`bridge backpressure queue timeout for ${name}`));
|
|
164
|
+
finishDrain();
|
|
146
165
|
},cfg.queue_timeout_ms);
|
|
147
166
|
s.queue.push(waiter);
|
|
148
167
|
});
|
|
149
168
|
}
|
|
169
|
+
function close(reason='bridge governor closed'){
|
|
170
|
+
if(!closed){
|
|
171
|
+
closed=true;
|
|
172
|
+
for(const s of state.values()){
|
|
173
|
+
for(const waiter of s.queue.splice(0)){
|
|
174
|
+
if(waiter.done)continue;
|
|
175
|
+
waiter.done=true;clearTimeout(waiter.timer);waiter.cleanup();s.cancelled++;
|
|
176
|
+
waiter.reject(new Error(String(reason)));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if([...state.values()].every(item=>item.active===0)) return Promise.resolve();
|
|
181
|
+
return new Promise(resolve=>drainWaiters.push(resolve));
|
|
182
|
+
}
|
|
183
|
+
function reconcile(reason='bridge downstream reconciling'){
|
|
184
|
+
if(closed) return 0;
|
|
185
|
+
let removed=0;
|
|
186
|
+
for(const s of state.values()){
|
|
187
|
+
for(const waiter of s.queue.splice(0)){
|
|
188
|
+
if(waiter.done)continue;
|
|
189
|
+
waiter.done=true;clearTimeout(waiter.timer);waiter.cleanup();s.cancelled++;removed++;
|
|
190
|
+
waiter.reject(new Error(String(reason)));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return removed;
|
|
194
|
+
}
|
|
150
195
|
function snapshot(){
|
|
151
196
|
return Object.freeze(Object.fromEntries(classes.map(name=>{
|
|
152
197
|
const s=state.get(name),cfg=limits[name];
|
|
153
|
-
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
|
|
198
|
+
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
|
+
admitted:s.admitted,completed:s.completed,rejected:s.rejected,timed_out:s.timed_out,cancelled:s.cancelled,saturated:s.active>=cfg.max_active})];
|
|
154
200
|
})));
|
|
155
201
|
}
|
|
156
|
-
return Object.freeze({acquire,snapshot});
|
|
202
|
+
return Object.freeze({acquire,reconcile,close,snapshot});
|
|
157
203
|
}
|
|
158
204
|
|
|
159
205
|
export const BRIDGE_R40_DEFAULT_LIMITS=DEFAULT_LIMITS;
|
|
@@ -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
|
+
}
|