@evomap/evolver-proxy 2.0.0-beta.17 → 2.0.0-beta.19
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/dist/bin/evolver-proxy.d.ts +11 -0
- package/dist/bin/evolver-proxy.js +54 -10
- package/dist/daemon/collaborationFacade.js +23 -13
- package/dist/daemon/proxyDaemon.d.ts +50 -0
- package/dist/daemon/proxyDaemon.js +1026 -24
- package/dist/daemon/publishRecallVerifier.d.ts +114 -0
- package/dist/daemon/publishRecallVerifier.js +495 -0
- package/dist/daemon/systemdNotifier.d.ts +46 -0
- package/dist/daemon/systemdNotifier.js +153 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/lifecycle/claimNudge.d.ts +20 -0
- package/dist/lifecycle/claimNudge.js +124 -0
- package/dist/lifecycle/manager.d.ts +4 -0
- package/dist/lifecycle/manager.js +15 -2
- package/dist/llm/upstream.d.ts +5 -1
- package/dist/llm/upstream.js +24 -1
- package/dist/sync/engine.d.ts +12 -0
- package/dist/sync/engine.js +255 -64
- package/package.json +6 -3
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
1
2
|
import { dirname, join } from 'node:path';
|
|
2
|
-
import { mailbox, hub as hubNs, shadow as shadow_, assetstore, util } from '@evomap/evolver-core';
|
|
3
|
+
import { mailbox, hub as hubNs, shadow as shadow_, assetstore, wire, util } from '@evomap/evolver-core';
|
|
4
|
+
import { AuthError, HubClientError, HubUnreachableError } from '@evomap/evolver-adapter-public';
|
|
3
5
|
import { SyncEngine, SYNC_INTERVALS } from '../sync/engine.js';
|
|
4
6
|
import { LifecycleManager } from '../lifecycle/manager.js';
|
|
5
7
|
import { executeForceUpdate } from '../selfUpdate/executor.js';
|
|
@@ -7,11 +9,40 @@ import { reportPendingSelfUpdateLastUpdate, reportSelfUpdateLastUpdate } from '.
|
|
|
7
9
|
import { backfillProxyTraceUploads } from '../llm/traceBackfill.js';
|
|
8
10
|
import { hubAuthFailureHint } from './selectHub.js';
|
|
9
11
|
import { CollaborationFacade } from './collaborationFacade.js';
|
|
12
|
+
import { PublishRecallVerifier, resolvePublishRecallConfig, } from './publishRecallVerifier.js';
|
|
10
13
|
export const DEFAULT_IPC_PORT = 19820;
|
|
14
|
+
// V1 local-proxy compatibility contract; independent of the V2 mailbox envelope schema.
|
|
15
|
+
const PROXY_PROTOCOL_VERSION = '0.1.0';
|
|
16
|
+
const PROXY_STATUS_SCHEMA_VERSION = 1;
|
|
11
17
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
12
18
|
const MAX_PROXY_TICK_ERROR_LENGTH = 2_000;
|
|
13
19
|
const MAX_HEARTBEAT_TICK_ERROR_LENGTH = 1_000;
|
|
14
20
|
const MAX_EPHEMERAL_IPC_LISTEN_ATTEMPTS = 5;
|
|
21
|
+
const DEFAULT_ASSET_SEARCH_CACHE_TTL_MS = 30_000;
|
|
22
|
+
const DEFAULT_ASSET_SEARCH_CACHE_MAX = 256;
|
|
23
|
+
const DEFAULT_ASSET_SEARCH_STALE_GRACE_MS = 5 * 60_000;
|
|
24
|
+
const MAX_ASSET_SUBMIT_ITEMS = 50;
|
|
25
|
+
const ASYNC_ASSET_SUBMIT_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
26
|
+
const ASYNC_ASSET_SUBMIT_PREFIX = 'async_asset_submit:';
|
|
27
|
+
const OUTBOUND_HUB_MODE_FIELD = '__evolver_hub_mode';
|
|
28
|
+
const SYNC_ASSET_SUBMIT_PREFIX = 'sync_asset_submit:';
|
|
29
|
+
const SYNC_ASSET_SUBMIT_TYPE_RANK = {
|
|
30
|
+
Gene: 0,
|
|
31
|
+
Capsule: 1,
|
|
32
|
+
EvolutionEvent: 2,
|
|
33
|
+
AntiGene: 3,
|
|
34
|
+
};
|
|
35
|
+
const SYNC_ASSET_SUBMIT_SCOPE_STATE_KEY = 'sync_asset_submit:idempotency_scope:v1';
|
|
36
|
+
const SYNC_ASSET_SUBMIT_DIRECT_RETRY_GRACE_MS = 30_000;
|
|
37
|
+
const DEFAULT_SYNC_ASSET_SUBMIT_RESPONSE_TIMEOUT_MS = 15_000;
|
|
38
|
+
class OutboundHubModeMismatchError extends Error {
|
|
39
|
+
retryable = true;
|
|
40
|
+
retryAfterMs = 1_000;
|
|
41
|
+
constructor(expected, actual) {
|
|
42
|
+
super(`asset_submit hub mode mismatch: queued for ${expected}, running in ${actual}`);
|
|
43
|
+
this.name = 'OutboundHubModeMismatchError';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
15
46
|
/**
|
|
16
47
|
* ProxyDaemon(M6-4) 装配层: 把 core(MailboxStore/Dispatcher/MailboxDaemon/IpcServer) +
|
|
17
48
|
* HubBindings(M6-1) + SyncEngine(M6-2) + LifecycleManager(M6-3) 拼成系统级 proxy.
|
|
@@ -31,9 +62,21 @@ export class ProxyDaemon {
|
|
|
31
62
|
validator;
|
|
32
63
|
atp;
|
|
33
64
|
collaborationFacade;
|
|
65
|
+
publishRecallVerifier;
|
|
66
|
+
proxyHandler;
|
|
34
67
|
ipc;
|
|
35
68
|
now;
|
|
36
69
|
random;
|
|
70
|
+
assetSearchCacheTtlMs;
|
|
71
|
+
assetSearchCacheMax;
|
|
72
|
+
assetSearchStaleGraceMs;
|
|
73
|
+
assetSubmitResponseTimeoutMs;
|
|
74
|
+
synchronousAssetSubmitScope;
|
|
75
|
+
shadowMode;
|
|
76
|
+
assetSearchCache = new Map();
|
|
77
|
+
assetSearchInflight = new Map();
|
|
78
|
+
synchronousAssetSubmitInflight = new Map();
|
|
79
|
+
assetSearchCooldownUntil = 0;
|
|
37
80
|
nextHeartbeatAt;
|
|
38
81
|
heartbeatFailures = 0;
|
|
39
82
|
heartbeatGeneration = 0;
|
|
@@ -42,6 +85,10 @@ export class ProxyDaemon {
|
|
|
42
85
|
/** A poke that arrived between ticks (no sleep in flight) parks the wake here so it is not lost. */
|
|
43
86
|
wakeRunnerPending = false;
|
|
44
87
|
started = false;
|
|
88
|
+
lifecycleArmed = false;
|
|
89
|
+
lastTickAt;
|
|
90
|
+
nextTickDueAt;
|
|
91
|
+
consecutiveTickFailures = 0;
|
|
45
92
|
storeClosed = false;
|
|
46
93
|
forceUpdateTriggerInFlight = false;
|
|
47
94
|
forceUpdateLastTriggeredAt;
|
|
@@ -55,20 +102,34 @@ export class ProxyDaemon {
|
|
|
55
102
|
this.deps = deps;
|
|
56
103
|
this.now = deps.now ?? (() => Date.now());
|
|
57
104
|
this.random = deps.random ?? Math.random;
|
|
105
|
+
this.assetSearchCacheTtlMs = positiveIntegerOr(deps.assetSearchCacheTtlMs, DEFAULT_ASSET_SEARCH_CACHE_TTL_MS);
|
|
106
|
+
this.assetSearchCacheMax = positiveIntegerOr(deps.assetSearchCacheMax, DEFAULT_ASSET_SEARCH_CACHE_MAX);
|
|
107
|
+
this.assetSearchStaleGraceMs = positiveIntegerOr(deps.assetSearchStaleGraceMs, DEFAULT_ASSET_SEARCH_STALE_GRACE_MS);
|
|
108
|
+
this.assetSubmitResponseTimeoutMs = positiveIntegerOr(deps.assetSubmitResponseTimeoutMs, DEFAULT_SYNC_ASSET_SUBMIT_RESPONSE_TIMEOUT_MS);
|
|
58
109
|
if (!deps.store && !deps.storePath)
|
|
59
110
|
throw new Error('ProxyDaemon: 需 store 或 storePath 之一');
|
|
60
111
|
const shadow = deps.shadowMode === 'shadow';
|
|
112
|
+
this.shadowMode = shadow;
|
|
61
113
|
if (shadow && !deps.shadowSink)
|
|
62
114
|
throw new Error('ProxyDaemon: shadow 模式需 shadowSink');
|
|
63
115
|
// M8 shadow 装配: 在边界包 decorator, 下游 makeHubBindings/Dispatcher/SyncEngine/MailboxDaemon 零改.
|
|
64
116
|
this.store = deps.store
|
|
65
117
|
?? (shadow ? new shadow_.ShadowMailboxStore({ path: deps.storePath }, deps.shadowSink, 'shadow') : new mailbox.MailboxStore({ path: deps.storePath }));
|
|
118
|
+
const existingSynchronousAssetSubmitScope = this.store.getState(SYNC_ASSET_SUBMIT_SCOPE_STATE_KEY);
|
|
119
|
+
this.synchronousAssetSubmitScope = existingSynchronousAssetSubmitScope ?? randomUUID();
|
|
120
|
+
if (!existingSynchronousAssetSubmitScope) {
|
|
121
|
+
this.store.setState(SYNC_ASSET_SUBMIT_SCOPE_STATE_KEY, this.synchronousAssetSubmitScope);
|
|
122
|
+
}
|
|
66
123
|
const assetStoreDir = deps.assetStoreDir ?? (deps.storePath ? join(dirname(deps.storePath), 'assets') : undefined);
|
|
67
124
|
this.assetStore = deps.assetStore ?? (assetStoreDir ? new assetstore.LocalJsonlProvider(assetStoreDir) : undefined);
|
|
68
125
|
this.atp = deps.atp;
|
|
69
126
|
const hubToUse = shadow ? shadow_.shadowHubCapability(deps.hub, deps.shadowSink, 'shadow') : deps.hub;
|
|
70
|
-
const hubBindings = hubNs.makeHubBindings(hubToUse
|
|
71
|
-
|
|
127
|
+
const hubBindings = hubNs.makeHubBindings(hubToUse, deps.publishSanitizeEnv
|
|
128
|
+
? { sanitize: { env: deps.publishSanitizeEnv } }
|
|
129
|
+
: {});
|
|
130
|
+
this.proxyHandler = hubBindings.asProxyHandler();
|
|
131
|
+
const proxyHandler = this.proxyHandler;
|
|
132
|
+
const syncProxyHandler = (envelope) => this.handleHubModeBoundOutbound(envelope);
|
|
72
133
|
const assetByIdSource = isAssetByIdFetcher(deps.hub) ? deps.hub : (isAssetByIdFetcher(hubToUse) ? hubToUse : undefined);
|
|
73
134
|
this.remoteAssetById = assetByIdSource
|
|
74
135
|
? async (assetId) => {
|
|
@@ -76,6 +137,17 @@ export class ProxyDaemon {
|
|
|
76
137
|
return assetMatchesId(fetched, assetId) ? fetched : null;
|
|
77
138
|
}
|
|
78
139
|
: undefined;
|
|
140
|
+
const publishRecallConfig = resolvePublishRecallConfig();
|
|
141
|
+
this.publishRecallVerifier = deps.publishRecallVerifier ?? new PublishRecallVerifier({
|
|
142
|
+
store: this.store,
|
|
143
|
+
...(!shadow && assetByIdSource
|
|
144
|
+
? { fetchAssetById: (assetId) => assetByIdSource.fetchAssetById(assetId) }
|
|
145
|
+
: {}),
|
|
146
|
+
config: shadow ? { ...publishRecallConfig, enabled: false } : publishRecallConfig,
|
|
147
|
+
now: this.now,
|
|
148
|
+
random: this.random,
|
|
149
|
+
stateKey: `publish_recall_verifier:${deps.runtimeNamespace ?? 'default'}:v1`,
|
|
150
|
+
});
|
|
79
151
|
this.reuseResultReporter = isReuseResultReporter(hubToUse)
|
|
80
152
|
? hubToUse
|
|
81
153
|
: (!shadow && isReuseResultReporter(deps.hub) ? deps.hub : undefined);
|
|
@@ -105,15 +177,50 @@ export class ProxyDaemon {
|
|
|
105
177
|
...(deps.collaborationOperationTimeoutMs !== undefined ? { operationTimeoutMs: deps.collaborationOperationTimeoutMs } : {}),
|
|
106
178
|
});
|
|
107
179
|
this.sync = new SyncEngine({
|
|
108
|
-
store: this.store, hub: hubToUse, proxyHandler, now: this.now,
|
|
180
|
+
store: this.store, hub: hubToUse, proxyHandler: syncProxyHandler, now: this.now,
|
|
109
181
|
...(deps.runtimeNamespace ? { runtimeNamespace: deps.runtimeNamespace } : {}),
|
|
110
|
-
onOutboundSucceeded: (envelope, result) =>
|
|
111
|
-
|
|
182
|
+
onOutboundSucceeded: (envelope, result) => {
|
|
183
|
+
this.collaborationFacade.handleOutboundSucceeded(envelope, result);
|
|
184
|
+
let shouldObserve = true;
|
|
185
|
+
try {
|
|
186
|
+
const cached = this.cacheSynchronousAssetSubmitSuccess(envelope, result);
|
|
187
|
+
if (cached === false)
|
|
188
|
+
shouldObserve = false;
|
|
189
|
+
}
|
|
190
|
+
catch { /* best-effort */ }
|
|
191
|
+
// Observability must never turn a Hub-accepted publish into a failed/retried economic action.
|
|
192
|
+
if (shouldObserve) {
|
|
193
|
+
try {
|
|
194
|
+
this.publishRecallVerifier.observeAcceptedPublish(envelope, result);
|
|
195
|
+
}
|
|
196
|
+
catch { /* best-effort */ }
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
onOutboundTerminal: (envelope, error) => {
|
|
200
|
+
this.collaborationFacade.handleOutboundTerminal(envelope, error);
|
|
201
|
+
try {
|
|
202
|
+
this.cacheSynchronousAssetSubmitTerminal(envelope, error);
|
|
203
|
+
}
|
|
204
|
+
catch { /* best-effort */ }
|
|
205
|
+
},
|
|
206
|
+
acceptedOutcomeKey: (envelope) => !shadow && isSynchronousAssetSubmitEnvelope(envelope)
|
|
207
|
+
? synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey)
|
|
208
|
+
: undefined,
|
|
209
|
+
terminalOutcome: (envelope, error) => {
|
|
210
|
+
if (shadow || !isSynchronousAssetSubmitEnvelope(envelope))
|
|
211
|
+
return undefined;
|
|
212
|
+
const failure = mapSynchronousPublishFailure(error);
|
|
213
|
+
return {
|
|
214
|
+
key: synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey),
|
|
215
|
+
result: { kind: 'failed', ...failure },
|
|
216
|
+
};
|
|
217
|
+
},
|
|
112
218
|
normalizeInboundEnvelope: (envelope) => this.collaborationFacade.normalizeInboundEnvelope(envelope),
|
|
113
219
|
...(deps.traceBackfill ? { onOutboundFlushed: () => { this.drainProxyTraceBackfill(); } } : {}),
|
|
114
220
|
});
|
|
115
221
|
this.lifecycle = new LifecycleManager({
|
|
116
222
|
store: this.store, auth: hubToUse.auth, hello: deps.hello, heartbeat: deps.heartbeat, now: this.now,
|
|
223
|
+
...(deps.heartbeatIntervalMs !== undefined ? { heartbeatIntervalMs: deps.heartbeatIntervalMs } : {}),
|
|
117
224
|
...(deps.evolverVersion ? { evolverVersion: deps.evolverVersion } : {}),
|
|
118
225
|
...(deps.helloMode ? { helloMode: deps.helloMode } : {}),
|
|
119
226
|
onForceUpdateDirective: (directive, source) => { this.triggerForceUpdateFromHeartbeat(directive, source); },
|
|
@@ -157,6 +264,7 @@ export class ProxyDaemon {
|
|
|
157
264
|
this.daemon.start();
|
|
158
265
|
this.ipc = new mailbox.MailboxIpcServer({
|
|
159
266
|
store: this.store, token: this.deps.ipcToken,
|
|
267
|
+
runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
|
|
160
268
|
...(this.deps.ipcHost ? { host: this.deps.ipcHost } : {}), now: this.now,
|
|
161
269
|
onSend: (env, result) => {
|
|
162
270
|
if (result.stored && env.handler === 'proxy')
|
|
@@ -171,7 +279,12 @@ export class ProxyDaemon {
|
|
|
171
279
|
}
|
|
172
280
|
catch { /* local discovery publishing must not block daemon startup */ }
|
|
173
281
|
await this.lifecycle.doHello();
|
|
282
|
+
this.lifecycleArmed = true;
|
|
174
283
|
this.drainProxyTraceBackfill();
|
|
284
|
+
try {
|
|
285
|
+
this.publishRecallVerifier.start();
|
|
286
|
+
}
|
|
287
|
+
catch { /* verifier availability must not block proxy startup */ }
|
|
175
288
|
this.started = true;
|
|
176
289
|
return port;
|
|
177
290
|
}
|
|
@@ -186,6 +299,7 @@ export class ProxyDaemon {
|
|
|
186
299
|
}
|
|
187
300
|
catch { /* best-effort cleanup */ }
|
|
188
301
|
this.started = false;
|
|
302
|
+
this.lifecycleArmed = false;
|
|
189
303
|
throw err;
|
|
190
304
|
}
|
|
191
305
|
}
|
|
@@ -296,11 +410,14 @@ export class ProxyDaemon {
|
|
|
296
410
|
catch { /* ignore telemetry persistence failures */ }
|
|
297
411
|
}
|
|
298
412
|
const failedPhases = uniqueTickPhases(errors.map((err) => err.phase));
|
|
413
|
+
const fatalCandidate = errors.length > 0 && isFatalTickCandidate(outbound, inbound, failedPhases);
|
|
414
|
+
this.lastTickAt = this.now();
|
|
415
|
+
this.consecutiveTickFailures = fatalCandidate ? this.consecutiveTickFailures + 1 : 0;
|
|
299
416
|
return {
|
|
300
417
|
outbound,
|
|
301
418
|
inbound,
|
|
302
419
|
...(heartbeat ? { heartbeat } : {}),
|
|
303
|
-
...(errors.length > 0 ? { errors, failedPhases, fatalCandidate
|
|
420
|
+
...(errors.length > 0 ? { errors, failedPhases, fatalCandidate } : { failedPhases: [], fatalCandidate: false }),
|
|
304
421
|
};
|
|
305
422
|
}
|
|
306
423
|
/** 下一轮建议延时: inbound 背压/idle 与 outbound pending cadence 取更快者. */
|
|
@@ -317,6 +434,11 @@ export class ProxyDaemon {
|
|
|
317
434
|
setWakeHandler(wake) {
|
|
318
435
|
this.loopWakeHandler = wake;
|
|
319
436
|
}
|
|
437
|
+
setExpectedNextTick(delayMs) {
|
|
438
|
+
this.nextTickDueAt = delayMs === undefined
|
|
439
|
+
? undefined
|
|
440
|
+
: this.now() + Math.max(0, delayMs);
|
|
441
|
+
}
|
|
320
442
|
notifyNewOutbound() {
|
|
321
443
|
if (this.loopWakeHandler) {
|
|
322
444
|
this.loopWakeHandler();
|
|
@@ -374,11 +496,18 @@ export class ProxyDaemon {
|
|
|
374
496
|
return {
|
|
375
497
|
running: this.started,
|
|
376
498
|
ipcListening: !!this.ipc,
|
|
499
|
+
lifecycleArmed: this.lifecycleArmed,
|
|
377
500
|
...(this.lifecycle.nodeId ? { nodeId: this.lifecycle.nodeId } : {}),
|
|
378
501
|
lastWriteAt: this.daemon.lastWriteAt(),
|
|
502
|
+
...(this.lastTickAt !== undefined ? { lastTickAt: this.lastTickAt } : {}),
|
|
503
|
+
...(this.nextTickDueAt !== undefined ? { nextTickDueAt: this.nextTickDueAt } : {}),
|
|
504
|
+
consecutiveFailures: this.consecutiveTickFailures,
|
|
379
505
|
};
|
|
380
506
|
}
|
|
381
507
|
async stop() {
|
|
508
|
+
this.started = false;
|
|
509
|
+
this.lifecycleArmed = false;
|
|
510
|
+
this.nextTickDueAt = undefined;
|
|
382
511
|
if (this.forceUpdateTimer) {
|
|
383
512
|
clearTimeout(this.forceUpdateTimer);
|
|
384
513
|
this.forceUpdateTimer = undefined;
|
|
@@ -388,6 +517,10 @@ export class ProxyDaemon {
|
|
|
388
517
|
this.wakeRunnerPending = false;
|
|
389
518
|
if (this.wakeRunnerResolve)
|
|
390
519
|
this.wakeRunnerResolve();
|
|
520
|
+
try {
|
|
521
|
+
await this.publishRecallVerifier.stop();
|
|
522
|
+
}
|
|
523
|
+
catch { /* best-effort verifier shutdown */ }
|
|
391
524
|
let stopError;
|
|
392
525
|
try {
|
|
393
526
|
await this.closeIpc();
|
|
@@ -407,7 +540,6 @@ export class ProxyDaemon {
|
|
|
407
540
|
catch (err) {
|
|
408
541
|
stopError = stopError ?? err;
|
|
409
542
|
}
|
|
410
|
-
this.started = false;
|
|
411
543
|
if (stopError)
|
|
412
544
|
throw stopError;
|
|
413
545
|
}
|
|
@@ -590,15 +722,17 @@ export class ProxyDaemon {
|
|
|
590
722
|
ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
|
|
591
723
|
return true;
|
|
592
724
|
}
|
|
593
|
-
if (await this.collaborationFacade.handle(ctx))
|
|
594
|
-
return true;
|
|
595
725
|
const handledAtp = await this.handleAtpRoute(ctx);
|
|
596
726
|
if (handledAtp)
|
|
597
727
|
return true;
|
|
598
728
|
if (ctx.route === 'GET /proxy/status') {
|
|
599
729
|
ctx.json(200, {
|
|
600
730
|
running: true,
|
|
731
|
+
status: 'running',
|
|
732
|
+
proxy_protocol_version: PROXY_PROTOCOL_VERSION,
|
|
733
|
+
schema_version: PROXY_STATUS_SCHEMA_VERSION,
|
|
601
734
|
hub_mode: this.deps.hubMode ?? 'public',
|
|
735
|
+
runtime_namespace: this.deps.runtimeNamespace ?? 'default',
|
|
602
736
|
node_id: this.lifecycle.nodeId ?? null,
|
|
603
737
|
outbound_pending: this.store.countPending('proxy', this.deps.runtimeNamespace),
|
|
604
738
|
inbound_pending: this.store.countPending('agent', this.deps.runtimeNamespace) + this.store.countPending('core', this.deps.runtimeNamespace),
|
|
@@ -607,26 +741,45 @@ export class ProxyDaemon {
|
|
|
607
741
|
hub_auth_status: this.store.getState('hub:auth_status') || null,
|
|
608
742
|
reauth_backoff_until: this.stateNumber('lifecycle:reauth_until'),
|
|
609
743
|
hello_rate_limit_until: this.stateNumber('lifecycle:hello_rl_until'),
|
|
744
|
+
publish_recall_verify: this.publishRecallVerifier.status(),
|
|
610
745
|
});
|
|
611
746
|
return true;
|
|
612
747
|
}
|
|
613
748
|
if (ctx.route === 'POST /mailbox/poll') {
|
|
614
|
-
const body = (await ctx.readJson());
|
|
615
|
-
const limit =
|
|
616
|
-
|
|
617
|
-
.
|
|
618
|
-
|
|
619
|
-
|
|
749
|
+
const body = asRecord(await ctx.readJson());
|
|
750
|
+
const limit = boundedRequestLimit(body['limit'], 10, 50);
|
|
751
|
+
if (limit === undefined) {
|
|
752
|
+
ctx.json(400, { error: 'invalid_limit' });
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
const channel = typeof body['channel'] === 'string' ? body['channel'] : undefined;
|
|
756
|
+
const type = typeof body['type'] === 'string' && body['type'] ? body['type'] : undefined;
|
|
757
|
+
const runtimeNamespace = legacyMailboxRuntimeNamespace(channel, this.deps.runtimeNamespace ?? 'default');
|
|
758
|
+
const messages = runtimeNamespace === undefined
|
|
759
|
+
? []
|
|
760
|
+
: this.store.list({
|
|
761
|
+
status: 'pending',
|
|
762
|
+
direction: mailboxDirection(body['direction']) ?? 'inbound',
|
|
763
|
+
runtimeNamespace,
|
|
764
|
+
...(type ? { type } : {}),
|
|
765
|
+
limit,
|
|
766
|
+
}).map(mailbox.legacyMailboxMessage);
|
|
620
767
|
ctx.json(200, { messages, count: messages.length });
|
|
621
768
|
return true;
|
|
622
769
|
}
|
|
770
|
+
if (await this.collaborationFacade.handle(ctx))
|
|
771
|
+
return true;
|
|
623
772
|
if (ctx.route === 'POST /asset/search') {
|
|
624
773
|
const body = (await ctx.readJson());
|
|
625
774
|
if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
|
|
626
775
|
ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
|
|
627
776
|
return true;
|
|
628
777
|
}
|
|
629
|
-
const limit =
|
|
778
|
+
const limit = boundedRequestLimit(body.limit, 5, 25);
|
|
779
|
+
if (limit === undefined) {
|
|
780
|
+
ctx.json(400, { error: 'invalid_limit' });
|
|
781
|
+
return true;
|
|
782
|
+
}
|
|
630
783
|
const rawSignals = Array.isArray(body.signals) ? body.signals : body.signalsAny;
|
|
631
784
|
const signalsAny = Array.isArray(rawSignals) ? rawSignals.filter((s) => typeof s === 'string') : undefined;
|
|
632
785
|
const kind = assetKind(body.kind);
|
|
@@ -677,20 +830,71 @@ export class ProxyDaemon {
|
|
|
677
830
|
return true;
|
|
678
831
|
}
|
|
679
832
|
if (ctx.route === 'POST /asset/submit') {
|
|
680
|
-
const body = (await ctx.readJson());
|
|
681
|
-
if (hubModeMismatch(body
|
|
833
|
+
const body = asRecord(await ctx.readJson());
|
|
834
|
+
if (hubModeMismatch(body['expected_hub_mode'], this.deps.hubMode)) {
|
|
682
835
|
ctx.json(409, { stored: false, error: 'proxy_hub_mode_mismatch' });
|
|
683
836
|
return true;
|
|
684
837
|
}
|
|
685
|
-
|
|
838
|
+
const bundle = normalizeAssetSubmitBundle(body);
|
|
839
|
+
const legacyAssetId = typeof body['asset_id'] === 'string' && body['asset_id'].trim()
|
|
840
|
+
? body['asset_id'].trim()
|
|
841
|
+
: undefined;
|
|
842
|
+
if (!bundle && !legacyAssetId) {
|
|
686
843
|
ctx.json(400, { error: 'assets or asset_id is required' });
|
|
687
844
|
return true;
|
|
688
845
|
}
|
|
689
|
-
|
|
846
|
+
let outboundBundle = bundle;
|
|
847
|
+
if (!outboundBundle && legacyAssetId) {
|
|
848
|
+
if (!this.assetStore) {
|
|
849
|
+
ctx.json(503, { error: 'asset_store_unavailable' });
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
const resolved = await this.assetStore.get(legacyAssetId);
|
|
853
|
+
if (!resolved) {
|
|
854
|
+
ctx.json(404, { error: 'asset_not_found', asset_id: legacyAssetId });
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
outboundBundle = [resolved];
|
|
858
|
+
}
|
|
859
|
+
if (outboundBundle && outboundBundle.length > MAX_ASSET_SUBMIT_ITEMS) {
|
|
860
|
+
ctx.json(400, { error: `asset submit accepts at most ${MAX_ASSET_SUBMIT_ITEMS} items` });
|
|
861
|
+
return true;
|
|
862
|
+
}
|
|
863
|
+
const requestedMode = ctx.url.searchParams.get('mode');
|
|
864
|
+
const mode = requestedMode ?? (bundle ? 'sync' : 'async');
|
|
865
|
+
if (mode !== 'sync' && mode !== 'async') {
|
|
866
|
+
ctx.json(400, { error: 'mode must be sync or async' });
|
|
867
|
+
return true;
|
|
868
|
+
}
|
|
869
|
+
if (mode === 'sync') {
|
|
870
|
+
if (!bundle) {
|
|
871
|
+
ctx.json(400, { error: 'mode=sync requires a full asset bundle' });
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
await this.publishAssetSubmitSynchronously(ctx, outboundBundle);
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
const payload = {
|
|
878
|
+
assets: outboundBundle,
|
|
879
|
+
[OUTBOUND_HUB_MODE_FIELD]: this.currentHubMode(),
|
|
880
|
+
};
|
|
881
|
+
const requestId = typeof body['request_id'] === 'string' && ASYNC_ASSET_SUBMIT_REQUEST_ID.test(body['request_id'])
|
|
882
|
+
? body['request_id']
|
|
883
|
+
: undefined;
|
|
884
|
+
delete payload['request_id'];
|
|
885
|
+
const runtimeNamespace = this.deps.runtimeNamespace ?? 'default';
|
|
886
|
+
const stableId = requestId ? asyncAssetSubmitEnvelopeId(runtimeNamespace, requestId) : undefined;
|
|
887
|
+
const env = mailbox.createEnvelope({
|
|
888
|
+
...(stableId ? { id: stableId, idempotencyKey: stableId } : {}),
|
|
889
|
+
type: 'asset_submit',
|
|
890
|
+
payload,
|
|
891
|
+
runtimeNamespace,
|
|
892
|
+
now: ctx.now,
|
|
893
|
+
});
|
|
690
894
|
const r = this.store.send(env);
|
|
691
895
|
if (r.stored)
|
|
692
896
|
this.notifyNewOutbound();
|
|
693
|
-
ctx.json(
|
|
897
|
+
ctx.json(202, { id: env.id, message_id: env.id, receiptId: r.receiptId, status: 'pending', stored: r.stored });
|
|
694
898
|
return true;
|
|
695
899
|
}
|
|
696
900
|
if (ctx.route === 'POST /asset/validate') {
|
|
@@ -763,7 +967,13 @@ export class ProxyDaemon {
|
|
|
763
967
|
if (body['publish'] === true) {
|
|
764
968
|
const env = mailbox.createEnvelope({
|
|
765
969
|
type: 'asset_submit',
|
|
766
|
-
payload: {
|
|
970
|
+
payload: {
|
|
971
|
+
source: 'conversation_distillation',
|
|
972
|
+
distill_id: distill.distill_id,
|
|
973
|
+
assets: [distill.gene, distill.capsule],
|
|
974
|
+
[OUTBOUND_HUB_MODE_FIELD]: this.currentHubMode(),
|
|
975
|
+
},
|
|
976
|
+
runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
|
|
767
977
|
now: ctx.now,
|
|
768
978
|
});
|
|
769
979
|
const r = this.store.send(env);
|
|
@@ -837,7 +1047,7 @@ export class ProxyDaemon {
|
|
|
837
1047
|
const localSafe = local.filter((asset) => asset.type !== 'AntiGene');
|
|
838
1048
|
let remote;
|
|
839
1049
|
try {
|
|
840
|
-
remote =
|
|
1050
|
+
remote = await this.searchRemoteAssets(query, limit);
|
|
841
1051
|
}
|
|
842
1052
|
catch (error) {
|
|
843
1053
|
if (localSafe.length === 0)
|
|
@@ -861,6 +1071,364 @@ export class ProxyDaemon {
|
|
|
861
1071
|
}
|
|
862
1072
|
return out;
|
|
863
1073
|
}
|
|
1074
|
+
async searchRemoteAssets(query, limit) {
|
|
1075
|
+
const key = assetSearchCacheKey(this.deps.runtimeNamespace, query, limit);
|
|
1076
|
+
const now = this.now();
|
|
1077
|
+
const cached = this.assetSearchCache.get(key);
|
|
1078
|
+
if (cached && cached.expiresAt > now)
|
|
1079
|
+
return cached.value;
|
|
1080
|
+
if (now < this.assetSearchCooldownUntil) {
|
|
1081
|
+
if (cached && cached.staleUntil > now)
|
|
1082
|
+
return cached.value;
|
|
1083
|
+
if (cached)
|
|
1084
|
+
this.assetSearchCache.delete(key);
|
|
1085
|
+
throw new HubClientError(429, { error: 'rate_limited', source: 'asset_search_client_cooldown' }, this.assetSearchCooldownUntil - now);
|
|
1086
|
+
}
|
|
1087
|
+
const inflight = this.assetSearchInflight.get(key);
|
|
1088
|
+
if (inflight)
|
|
1089
|
+
return inflight;
|
|
1090
|
+
const request = (async () => {
|
|
1091
|
+
try {
|
|
1092
|
+
const value = (await this.deps.hub.search(query))
|
|
1093
|
+
.filter((asset) => asset.type !== 'AntiGene')
|
|
1094
|
+
.slice(0, limit);
|
|
1095
|
+
this.cacheRemoteAssetSearch(key, value, this.now());
|
|
1096
|
+
return value;
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
const retryAfterMs = assetSearchRetryAfterMs(error, this.assetSearchCacheTtlMs);
|
|
1100
|
+
if (retryAfterMs !== undefined) {
|
|
1101
|
+
const rateLimitedAt = this.now();
|
|
1102
|
+
this.assetSearchCooldownUntil = Math.max(this.assetSearchCooldownUntil, rateLimitedAt + retryAfterMs);
|
|
1103
|
+
const stale = this.assetSearchCache.get(key);
|
|
1104
|
+
if (stale && stale.staleUntil > rateLimitedAt)
|
|
1105
|
+
return stale.value;
|
|
1106
|
+
}
|
|
1107
|
+
throw error;
|
|
1108
|
+
}
|
|
1109
|
+
})();
|
|
1110
|
+
this.assetSearchInflight.set(key, request);
|
|
1111
|
+
const clearInflight = () => {
|
|
1112
|
+
if (this.assetSearchInflight.get(key) === request)
|
|
1113
|
+
this.assetSearchInflight.delete(key);
|
|
1114
|
+
};
|
|
1115
|
+
void request.then(clearInflight, clearInflight);
|
|
1116
|
+
return request;
|
|
1117
|
+
}
|
|
1118
|
+
cacheRemoteAssetSearch(key, value, now) {
|
|
1119
|
+
if (this.assetSearchCache.size >= this.assetSearchCacheMax && !this.assetSearchCache.has(key)) {
|
|
1120
|
+
const oldest = this.assetSearchCache.keys().next().value;
|
|
1121
|
+
if (oldest !== undefined)
|
|
1122
|
+
this.assetSearchCache.delete(oldest);
|
|
1123
|
+
}
|
|
1124
|
+
this.assetSearchCache.delete(key);
|
|
1125
|
+
this.assetSearchCache.set(key, {
|
|
1126
|
+
value,
|
|
1127
|
+
expiresAt: now + this.assetSearchCacheTtlMs,
|
|
1128
|
+
staleUntil: now + this.assetSearchCacheTtlMs + this.assetSearchStaleGraceMs,
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
async publishAssetSubmitSynchronously(ctx, items) {
|
|
1132
|
+
const classified = classifySynchronousAssetSubmit(items);
|
|
1133
|
+
if (!classified.ok) {
|
|
1134
|
+
ctx.json(422, { error: classified.error, code: 'invalid_asset_submit' });
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (classified.kind === 'wire') {
|
|
1138
|
+
const envelope = this.createSynchronousAssetSubmitEnvelope(classified.bundle, undefined, ctx.now);
|
|
1139
|
+
this.writeSynchronousAssetSubmitOutcome(ctx, await this.publishSynchronousBundle(envelope));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
const results = [];
|
|
1143
|
+
for (const item of classified.items) {
|
|
1144
|
+
const converted = await convertLegacyLooseAsset(item);
|
|
1145
|
+
if (!converted.ok) {
|
|
1146
|
+
results.push({ ok: false, error: converted.error, statusCode: 422 });
|
|
1147
|
+
continue;
|
|
1148
|
+
}
|
|
1149
|
+
const envelope = this.createSynchronousAssetSubmitEnvelope(converted.bundle, 'v1_loose_asset_compat', ctx.now);
|
|
1150
|
+
const outcome = await this.publishSynchronousBundle(envelope);
|
|
1151
|
+
if (outcome.kind === 'accepted') {
|
|
1152
|
+
const receipt = outcome.receipt;
|
|
1153
|
+
const publishedIds = submittedAssetIds(receipt, converted.bundle);
|
|
1154
|
+
results.push({
|
|
1155
|
+
ok: true,
|
|
1156
|
+
gene_asset_id: publishedIds[0],
|
|
1157
|
+
capsule_asset_id: publishedIds[1],
|
|
1158
|
+
response: receipt,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
else if (outcome.kind === 'failed') {
|
|
1162
|
+
results.push({
|
|
1163
|
+
ok: false,
|
|
1164
|
+
error: String(outcome.body['error']),
|
|
1165
|
+
statusCode: outcome.statusCode,
|
|
1166
|
+
...(typeof outcome.body['reason'] === 'string' ? { reason: outcome.body['reason'] } : {}),
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
else {
|
|
1170
|
+
results.push({
|
|
1171
|
+
ok: false,
|
|
1172
|
+
error: 'publish_pending',
|
|
1173
|
+
statusCode: 202,
|
|
1174
|
+
reason: `durable recovery pending (${outcome.messageId})`,
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
ctx.json(200, {
|
|
1179
|
+
published: results.filter((result) => result.ok).length,
|
|
1180
|
+
total: results.length,
|
|
1181
|
+
results,
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
createSynchronousAssetSubmitEnvelope(bundle, source, now) {
|
|
1185
|
+
const canonicalBundle = [...bundle].sort(compareSynchronousAssetSubmitAssets);
|
|
1186
|
+
const runtimeNamespace = this.deps.runtimeNamespace ?? 'default';
|
|
1187
|
+
const idempotencyKey = synchronousAssetSubmitKey(this.synchronousAssetSubmitScope, runtimeNamespace, this.currentHubMode(), canonicalBundle);
|
|
1188
|
+
return mailbox.createEnvelope({
|
|
1189
|
+
id: `compat:asset_submit:${idempotencyKey.slice(SYNC_ASSET_SUBMIT_PREFIX.length)}`,
|
|
1190
|
+
type: 'asset_submit',
|
|
1191
|
+
payload: {
|
|
1192
|
+
...(source ? { source } : {}),
|
|
1193
|
+
assets: canonicalBundle,
|
|
1194
|
+
[OUTBOUND_HUB_MODE_FIELD]: this.currentHubMode(),
|
|
1195
|
+
},
|
|
1196
|
+
idempotencyKey,
|
|
1197
|
+
runtimeNamespace,
|
|
1198
|
+
now,
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
currentHubMode() {
|
|
1202
|
+
return this.deps.hubMode ?? 'public';
|
|
1203
|
+
}
|
|
1204
|
+
handleHubModeBoundOutbound(envelope) {
|
|
1205
|
+
if (envelope.type !== 'asset_submit')
|
|
1206
|
+
return this.handleSynchronousProxyOutbound(envelope);
|
|
1207
|
+
const payload = asRecord(envelope.payload);
|
|
1208
|
+
const rawQueuedMode = payload[OUTBOUND_HUB_MODE_FIELD];
|
|
1209
|
+
const queuedMode = rawQueuedMode === undefined ? 'public' : String(rawQueuedMode);
|
|
1210
|
+
const currentMode = this.currentHubMode();
|
|
1211
|
+
if ((queuedMode !== 'public' && queuedMode !== 'private') || queuedMode !== currentMode) {
|
|
1212
|
+
throw new OutboundHubModeMismatchError(queuedMode, currentMode);
|
|
1213
|
+
}
|
|
1214
|
+
const outboundPayload = { ...payload };
|
|
1215
|
+
delete outboundPayload[OUTBOUND_HUB_MODE_FIELD];
|
|
1216
|
+
return this.handleSynchronousProxyOutbound({ ...envelope, payload: outboundPayload });
|
|
1217
|
+
}
|
|
1218
|
+
async publishSynchronousBundle(envelope) {
|
|
1219
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1220
|
+
if (cached)
|
|
1221
|
+
return cached;
|
|
1222
|
+
const inflight = this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey);
|
|
1223
|
+
if (inflight)
|
|
1224
|
+
return this.waitForSynchronousAssetSubmit(inflight, envelope.id);
|
|
1225
|
+
const { stored } = this.store.send(envelope);
|
|
1226
|
+
const cachedAfterInsert = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1227
|
+
if (cachedAfterInsert)
|
|
1228
|
+
return cachedAfterInsert;
|
|
1229
|
+
if (!stored) {
|
|
1230
|
+
const existingInflight = this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey);
|
|
1231
|
+
return existingInflight
|
|
1232
|
+
? this.waitForSynchronousAssetSubmit(existingInflight, envelope.id)
|
|
1233
|
+
: { kind: 'pending', messageId: envelope.id };
|
|
1234
|
+
}
|
|
1235
|
+
this.store.defer(envelope.id, 'synchronous asset submit attempt in progress', this.now(), SYNC_ASSET_SUBMIT_DIRECT_RETRY_GRACE_MS);
|
|
1236
|
+
this.notifyNewOutbound();
|
|
1237
|
+
const request = this.executeSynchronousAssetSubmit(envelope);
|
|
1238
|
+
this.synchronousAssetSubmitInflight.set(envelope.idempotencyKey, request);
|
|
1239
|
+
void request.finally(() => {
|
|
1240
|
+
if (this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey) === request) {
|
|
1241
|
+
this.synchronousAssetSubmitInflight.delete(envelope.idempotencyKey);
|
|
1242
|
+
}
|
|
1243
|
+
}).catch(() => { });
|
|
1244
|
+
return this.waitForSynchronousAssetSubmit(request, envelope.id);
|
|
1245
|
+
}
|
|
1246
|
+
waitForSynchronousAssetSubmit(request, messageId) {
|
|
1247
|
+
return new Promise((resolve, reject) => {
|
|
1248
|
+
const timeout = setTimeout(() => {
|
|
1249
|
+
resolve({ kind: 'pending', messageId });
|
|
1250
|
+
}, this.assetSubmitResponseTimeoutMs);
|
|
1251
|
+
timeout.unref?.();
|
|
1252
|
+
void request.then((outcome) => {
|
|
1253
|
+
clearTimeout(timeout);
|
|
1254
|
+
resolve(outcome);
|
|
1255
|
+
}, (error) => {
|
|
1256
|
+
clearTimeout(timeout);
|
|
1257
|
+
reject(error);
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
async executeSynchronousAssetSubmit(envelope) {
|
|
1262
|
+
try {
|
|
1263
|
+
const receipt = await this.proxyHandler(envelope);
|
|
1264
|
+
const firstObservation = this.cacheSynchronousAssetSubmitSuccess(envelope, receipt);
|
|
1265
|
+
if (firstObservation) {
|
|
1266
|
+
try {
|
|
1267
|
+
this.publishRecallVerifier.observeAcceptedPublish(envelope, receipt);
|
|
1268
|
+
}
|
|
1269
|
+
catch { /* best-effort */ }
|
|
1270
|
+
}
|
|
1271
|
+
if (this.store.getById(envelope.id)?.status !== 'in_flight')
|
|
1272
|
+
this.store.complete(envelope.id, this.now());
|
|
1273
|
+
return { kind: 'accepted', receipt };
|
|
1274
|
+
}
|
|
1275
|
+
catch (error) {
|
|
1276
|
+
const current = this.store.getById(envelope.id);
|
|
1277
|
+
const currentStatus = this.store.getStatus(envelope.id);
|
|
1278
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1279
|
+
// Once acceptance is durable, later publish or local-finalization errors cannot turn the external outcome
|
|
1280
|
+
// into a failure. A late acceptance may also need to recover an intent that a racing rejection put in DLQ.
|
|
1281
|
+
if (cached?.kind === 'accepted') {
|
|
1282
|
+
if (currentStatus?.dlq) {
|
|
1283
|
+
try {
|
|
1284
|
+
this.store.replayDlq(envelope.id, this.now());
|
|
1285
|
+
this.notifyNewOutbound();
|
|
1286
|
+
}
|
|
1287
|
+
catch (recoveryError) {
|
|
1288
|
+
this.recordTickError('outbound', recoveryError);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return cached;
|
|
1292
|
+
}
|
|
1293
|
+
const failure = mapSynchronousPublishFailure(error);
|
|
1294
|
+
const outcome = { kind: 'failed', ...failure, error };
|
|
1295
|
+
if (this.shadowMode)
|
|
1296
|
+
return outcome;
|
|
1297
|
+
if (current?.status !== 'in_flight') {
|
|
1298
|
+
const message = safeDaemonMessage(JSON.stringify(failure.body), MAX_PROXY_TICK_ERROR_LENGTH);
|
|
1299
|
+
const outcomeKey = synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey);
|
|
1300
|
+
const acceptedKey = synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey);
|
|
1301
|
+
const transitionNow = this.now();
|
|
1302
|
+
const terminal = isTerminalSynchronousPublishFailure(error);
|
|
1303
|
+
const transitioned = terminal
|
|
1304
|
+
? this.store.failAndMarkProcessedUnlessProcessed(envelope.id, [acceptedKey], outcomeKey, { kind: 'failed', ...failure }, message, transitionNow, 1)
|
|
1305
|
+
: isRetryableSynchronousPublishFailure(error)
|
|
1306
|
+
? this.store.deferUnlessProcessed(envelope.id, acceptedKey, message, transitionNow, synchronousPublishRetryAfterMs(error, failure))
|
|
1307
|
+
: this.store.failUnlessProcessed(envelope.id, acceptedKey, message, transitionNow);
|
|
1308
|
+
if (!transitioned) {
|
|
1309
|
+
const persisted = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1310
|
+
if (persisted)
|
|
1311
|
+
return persisted;
|
|
1312
|
+
}
|
|
1313
|
+
if (terminal) {
|
|
1314
|
+
const persisted = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1315
|
+
if (persisted?.kind === 'accepted')
|
|
1316
|
+
return persisted;
|
|
1317
|
+
}
|
|
1318
|
+
this.notifyNewOutbound();
|
|
1319
|
+
}
|
|
1320
|
+
return outcome;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
async handleSynchronousProxyOutbound(envelope) {
|
|
1324
|
+
if (!isSynchronousAssetSubmitEnvelope(envelope))
|
|
1325
|
+
return this.proxyHandler(envelope);
|
|
1326
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1327
|
+
if (cached?.kind === 'accepted')
|
|
1328
|
+
return cached.receipt;
|
|
1329
|
+
if (cached?.kind === 'failed')
|
|
1330
|
+
throw cachedSynchronousAssetSubmitFailure(cached);
|
|
1331
|
+
const inflight = this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey);
|
|
1332
|
+
if (inflight) {
|
|
1333
|
+
const outcome = await this.waitForSynchronousAssetSubmit(inflight, envelope.id);
|
|
1334
|
+
if (outcome.kind === 'accepted')
|
|
1335
|
+
return outcome.receipt;
|
|
1336
|
+
if (outcome.kind === 'failed')
|
|
1337
|
+
throw outcome.error ?? cachedSynchronousAssetSubmitFailure(outcome);
|
|
1338
|
+
if (this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey) === inflight) {
|
|
1339
|
+
this.synchronousAssetSubmitInflight.delete(envelope.idempotencyKey);
|
|
1340
|
+
}
|
|
1341
|
+
throw new HubUnreachableError('synchronous asset submit is still pending');
|
|
1342
|
+
}
|
|
1343
|
+
try {
|
|
1344
|
+
const receipt = await this.proxyHandler(envelope);
|
|
1345
|
+
const firstObservation = this.cacheSynchronousAssetSubmitSuccess(envelope, receipt);
|
|
1346
|
+
if (firstObservation) {
|
|
1347
|
+
try {
|
|
1348
|
+
this.publishRecallVerifier.observeAcceptedPublish(envelope, receipt);
|
|
1349
|
+
}
|
|
1350
|
+
catch { /* best-effort */ }
|
|
1351
|
+
}
|
|
1352
|
+
return receipt;
|
|
1353
|
+
}
|
|
1354
|
+
catch (error) {
|
|
1355
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1356
|
+
if (cached?.kind === 'accepted')
|
|
1357
|
+
return cached.receipt;
|
|
1358
|
+
if (isTerminalSynchronousPublishFailure(error))
|
|
1359
|
+
this.cacheSynchronousAssetSubmitTerminal(envelope, error);
|
|
1360
|
+
throw error;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
readSynchronousAssetSubmitOutcome(envelope) {
|
|
1364
|
+
if (this.shadowMode || !isSynchronousAssetSubmitEnvelope(envelope))
|
|
1365
|
+
return undefined;
|
|
1366
|
+
const outcomeKey = synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey);
|
|
1367
|
+
const value = asRecord(this.store.getProcessed(outcomeKey));
|
|
1368
|
+
if (value['kind'] === 'accepted' && Object.prototype.hasOwnProperty.call(value, 'receipt')) {
|
|
1369
|
+
const receipt = asRecord(value['receipt']);
|
|
1370
|
+
if (receipt['bundleId'] === 'shadow-bundle'
|
|
1371
|
+
&& typeof receipt['receiptId'] === 'string'
|
|
1372
|
+
&& receipt['receiptId'].startsWith('shadow-')) {
|
|
1373
|
+
this.store.deleteProcessed([
|
|
1374
|
+
outcomeKey,
|
|
1375
|
+
synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey),
|
|
1376
|
+
]);
|
|
1377
|
+
return undefined;
|
|
1378
|
+
}
|
|
1379
|
+
const backfilled = this.store.markProcessedIf(outcomeKey, synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey), { accepted: true }, this.now(), (current) => {
|
|
1380
|
+
const record = asRecord(current);
|
|
1381
|
+
return record['kind'] === 'accepted' && Object.prototype.hasOwnProperty.call(record, 'receipt');
|
|
1382
|
+
});
|
|
1383
|
+
if (!backfilled)
|
|
1384
|
+
return this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1385
|
+
return { kind: 'accepted', receipt: value['receipt'] };
|
|
1386
|
+
}
|
|
1387
|
+
if (value['kind'] === 'failed') {
|
|
1388
|
+
const statusCode = positiveFiniteNumber(value['statusCode']);
|
|
1389
|
+
if (statusCode !== undefined && isRecordValue(value['body'])) {
|
|
1390
|
+
return { kind: 'failed', statusCode, body: value['body'] };
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
return undefined;
|
|
1394
|
+
}
|
|
1395
|
+
cacheSynchronousAssetSubmitSuccess(envelope, receipt) {
|
|
1396
|
+
if (this.shadowMode || !isSynchronousAssetSubmitEnvelope(envelope))
|
|
1397
|
+
return undefined;
|
|
1398
|
+
const key = synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey);
|
|
1399
|
+
const acceptedKey = synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey);
|
|
1400
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1401
|
+
if (cached?.kind === 'accepted')
|
|
1402
|
+
return false;
|
|
1403
|
+
// Acceptance is monotonic: a concurrent attempt may reject after another request reached the Hub, but a
|
|
1404
|
+
// real acceptance must supersede an earlier rejection so replay reflects the economic side effect.
|
|
1405
|
+
this.store.replaceProcessedWithMarker(key, { kind: 'accepted', receipt }, acceptedKey, { accepted: true }, this.now());
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
cacheSynchronousAssetSubmitTerminal(envelope, error) {
|
|
1409
|
+
if (this.shadowMode || !isSynchronousAssetSubmitEnvelope(envelope))
|
|
1410
|
+
return;
|
|
1411
|
+
if (this.store.isProcessed(synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey)))
|
|
1412
|
+
return;
|
|
1413
|
+
if (this.readSynchronousAssetSubmitOutcome(envelope)?.kind === 'accepted')
|
|
1414
|
+
return;
|
|
1415
|
+
const failure = mapSynchronousPublishFailure(error);
|
|
1416
|
+
this.store.markProcessed(synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey), {
|
|
1417
|
+
kind: 'failed',
|
|
1418
|
+
...failure,
|
|
1419
|
+
}, this.now());
|
|
1420
|
+
}
|
|
1421
|
+
writeSynchronousAssetSubmitOutcome(ctx, outcome) {
|
|
1422
|
+
if (outcome.kind === 'accepted') {
|
|
1423
|
+
ctx.json(200, outcome.receipt);
|
|
1424
|
+
}
|
|
1425
|
+
else if (outcome.kind === 'failed') {
|
|
1426
|
+
ctx.json(outcome.statusCode, outcome.body);
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
ctx.json(202, { status: 'pending', message_id: outcome.messageId, durable: true });
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
864
1432
|
async handleAtpRoute(ctx) {
|
|
865
1433
|
if (!ctx.url.pathname.startsWith('/atp/'))
|
|
866
1434
|
return false;
|
|
@@ -983,12 +1551,446 @@ function uniqueStrings(values) {
|
|
|
983
1551
|
}
|
|
984
1552
|
return out;
|
|
985
1553
|
}
|
|
1554
|
+
function assetSearchCacheKey(runtimeNamespace, query, limit) {
|
|
1555
|
+
return JSON.stringify({
|
|
1556
|
+
runtimeNamespace: runtimeNamespace ?? 'default',
|
|
1557
|
+
kind: query.kind ?? null,
|
|
1558
|
+
signalsAny: uniqueStrings(query.signalsAny ?? []).sort(),
|
|
1559
|
+
category: query.category ?? null,
|
|
1560
|
+
gene: query.gene ?? null,
|
|
1561
|
+
text: query.text ?? null,
|
|
1562
|
+
limit,
|
|
1563
|
+
});
|
|
1564
|
+
}
|
|
1565
|
+
function assetSearchRetryAfterMs(error, fallbackMs) {
|
|
1566
|
+
const structured = asRecord(error);
|
|
1567
|
+
const details = asRecord(structured['details']);
|
|
1568
|
+
const structuredStatus = structured['statusCode']
|
|
1569
|
+
?? structured['status']
|
|
1570
|
+
?? details['statusCode']
|
|
1571
|
+
?? details['status'];
|
|
1572
|
+
const status = error instanceof HubClientError
|
|
1573
|
+
? error.status
|
|
1574
|
+
: (typeof structuredStatus === 'number'
|
|
1575
|
+
? structuredStatus
|
|
1576
|
+
: (typeof structuredStatus === 'string' ? Number(structuredStatus) : NaN));
|
|
1577
|
+
if (status !== 429)
|
|
1578
|
+
return undefined;
|
|
1579
|
+
const body = asRecord(error instanceof HubClientError ? error.body : structured['body']);
|
|
1580
|
+
const retryAfterMs = positiveFiniteNumber(error instanceof HubClientError
|
|
1581
|
+
? error.retryAfterMs
|
|
1582
|
+
: structured['retryAfterMs'] ?? details['retryAfterMs']) ?? positiveFiniteNumber(body['retry_after_ms'] ?? body['retryAfterMs']);
|
|
1583
|
+
const retryAfterSeconds = positiveFiniteNumber(body['retry_after'] ?? body['retryAfter']);
|
|
1584
|
+
return Math.floor(Math.min(retryAfterMs ?? (retryAfterSeconds !== undefined ? retryAfterSeconds * 1_000 : fallbackMs), MAX_TIMER_DELAY_MS));
|
|
1585
|
+
}
|
|
1586
|
+
function positiveFiniteNumber(value) {
|
|
1587
|
+
const parsed = typeof value === 'number'
|
|
1588
|
+
? value
|
|
1589
|
+
: (typeof value === 'string' && value.trim().length > 0 ? Number(value) : NaN);
|
|
1590
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
1591
|
+
}
|
|
1592
|
+
function positiveIntegerOr(value, fallback) {
|
|
1593
|
+
const parsed = positiveFiniteNumber(value);
|
|
1594
|
+
return parsed === undefined ? fallback : Math.max(1, Math.floor(parsed));
|
|
1595
|
+
}
|
|
1596
|
+
function boundedRequestLimit(value, fallback, maximum) {
|
|
1597
|
+
if (value === undefined)
|
|
1598
|
+
return fallback;
|
|
1599
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0)
|
|
1600
|
+
return undefined;
|
|
1601
|
+
return Math.min(value, maximum);
|
|
1602
|
+
}
|
|
1603
|
+
function legacyMailboxRuntimeNamespace(requestedChannel, runtimeNamespace) {
|
|
1604
|
+
if (requestedChannel === undefined || requestedChannel === 'evomap-hub' || requestedChannel === runtimeNamespace) {
|
|
1605
|
+
return runtimeNamespace;
|
|
1606
|
+
}
|
|
1607
|
+
return undefined;
|
|
1608
|
+
}
|
|
1609
|
+
function mailboxDirection(value) {
|
|
1610
|
+
return value === 'inbound' || value === 'outbound' || value === 'local' ? value : undefined;
|
|
1611
|
+
}
|
|
986
1612
|
function assetKind(value) {
|
|
987
1613
|
return value === 'Gene' || value === 'Capsule' || value === 'EvolutionEvent' || value === 'AntiGene' ? value : undefined;
|
|
988
1614
|
}
|
|
989
1615
|
function asRecord(value) {
|
|
990
1616
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
991
1617
|
}
|
|
1618
|
+
function normalizeAssetSubmitBundle(body) {
|
|
1619
|
+
if (Object.prototype.hasOwnProperty.call(body, 'assets')) {
|
|
1620
|
+
const assets = body['assets'];
|
|
1621
|
+
return Array.isArray(assets) && assets.length > 0 && assets.every(isNonEmptyAssetRecord)
|
|
1622
|
+
? assets
|
|
1623
|
+
: null;
|
|
1624
|
+
}
|
|
1625
|
+
return isNonEmptyAssetRecord(body['asset']) ? [body['asset']] : null;
|
|
1626
|
+
}
|
|
1627
|
+
function isNonEmptyAssetRecord(value) {
|
|
1628
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0);
|
|
1629
|
+
}
|
|
1630
|
+
function classifySynchronousAssetSubmit(items) {
|
|
1631
|
+
const wireLooking = items.map(isWireLookingAsset);
|
|
1632
|
+
const legacyLoose = items.map(isClearlyLegacyLooseAsset);
|
|
1633
|
+
if (wireLooking.every(Boolean)) {
|
|
1634
|
+
const bundle = [];
|
|
1635
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
1636
|
+
const item = items[index];
|
|
1637
|
+
if (!wire.validateWire(item).ok) {
|
|
1638
|
+
return { ok: false, error: `asset ${index}: malformed V2 wire asset` };
|
|
1639
|
+
}
|
|
1640
|
+
try {
|
|
1641
|
+
const normalized = assetstore.normalizeForPut(item);
|
|
1642
|
+
if (!normalized.verified) {
|
|
1643
|
+
return { ok: false, error: `asset ${index}: a verified content-addressed asset_id is required` };
|
|
1644
|
+
}
|
|
1645
|
+
bundle.push(normalized.record);
|
|
1646
|
+
}
|
|
1647
|
+
catch {
|
|
1648
|
+
return { ok: false, error: `asset ${index}: asset_id does not match its content` };
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
return { ok: true, kind: 'wire', bundle };
|
|
1652
|
+
}
|
|
1653
|
+
if (legacyLoose.every(Boolean))
|
|
1654
|
+
return { ok: true, kind: 'legacy', items };
|
|
1655
|
+
if (wireLooking.some(Boolean) && legacyLoose.some(Boolean)) {
|
|
1656
|
+
return { ok: false, error: 'wire assets and legacy loose assets cannot be mixed in one request' };
|
|
1657
|
+
}
|
|
1658
|
+
if (wireLooking.some(Boolean)) {
|
|
1659
|
+
return { ok: false, error: 'all wire-looking items must be valid content-addressed V2 assets' };
|
|
1660
|
+
}
|
|
1661
|
+
return { ok: false, error: 'unsupported asset input; provide V2 wire assets or legacy content/summary/strategy' };
|
|
1662
|
+
}
|
|
1663
|
+
function isWireLookingAsset(value) {
|
|
1664
|
+
return Object.prototype.hasOwnProperty.call(value, 'schema_version')
|
|
1665
|
+
|| Object.prototype.hasOwnProperty.call(value, 'asset_id');
|
|
1666
|
+
}
|
|
1667
|
+
function isClearlyLegacyLooseAsset(value) {
|
|
1668
|
+
if (Object.prototype.hasOwnProperty.call(value, 'schema_version')
|
|
1669
|
+
|| Object.prototype.hasOwnProperty.call(value, 'asset_id'))
|
|
1670
|
+
return false;
|
|
1671
|
+
return ['content', 'summary', 'strategy'].some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
1672
|
+
}
|
|
1673
|
+
async function convertLegacyLooseAsset(value) {
|
|
1674
|
+
const normalized = legacyLooseDistillInput(value);
|
|
1675
|
+
if (!normalized.ok)
|
|
1676
|
+
return normalized;
|
|
1677
|
+
try {
|
|
1678
|
+
const distilled = await hubNs.distillConversation(normalized.input, { persist: false });
|
|
1679
|
+
if (!distilled.ok) {
|
|
1680
|
+
return { ok: false, error: `legacy_distill_${safeIdentifier(distilled.reason)}` };
|
|
1681
|
+
}
|
|
1682
|
+
const gene = {
|
|
1683
|
+
...distilled.gene,
|
|
1684
|
+
...(normalized.constraints
|
|
1685
|
+
? { constraints: mergeLegacyConstraints(distilled.gene['constraints'], normalized.constraints) }
|
|
1686
|
+
: {}),
|
|
1687
|
+
...(normalized.category ? { category: normalized.category } : {}),
|
|
1688
|
+
};
|
|
1689
|
+
const bundle = [gene, distilled.capsule].map(deterministicDistilledAsset);
|
|
1690
|
+
if (!bundle.every((asset) => wire.validateWire(asset).ok)) {
|
|
1691
|
+
return { ok: false, error: 'legacy_distill_invalid_wire_output' };
|
|
1692
|
+
}
|
|
1693
|
+
return { ok: true, bundle };
|
|
1694
|
+
}
|
|
1695
|
+
catch {
|
|
1696
|
+
return { ok: false, error: 'legacy_distill_failed' };
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
function legacyLooseDistillInput(value) {
|
|
1700
|
+
const content = strictOptionalString(value, 'content');
|
|
1701
|
+
const summary = strictOptionalString(value, 'summary');
|
|
1702
|
+
if (!content.ok || !summary.ok)
|
|
1703
|
+
return { ok: false, error: 'legacy content and summary must be strings' };
|
|
1704
|
+
const strategy = strictOptionalStringList(value, 'strategy', 10, 220);
|
|
1705
|
+
if (!strategy.ok)
|
|
1706
|
+
return { ok: false, error: 'legacy strategy must be an array of strings' };
|
|
1707
|
+
if (strategy.value && (strategy.value.length < 2 || strategy.value.some((step) => step.length < 15))) {
|
|
1708
|
+
return { ok: false, error: 'legacy strategy requires at least two steps of 15 characters each' };
|
|
1709
|
+
}
|
|
1710
|
+
const text = [content.value, summary.value].filter(Boolean).join('\n').trim();
|
|
1711
|
+
const suppliedSubstance = [text, ...(strategy.value ?? [])].join(' ').trim();
|
|
1712
|
+
if (!strategy.value && text.length < 50) {
|
|
1713
|
+
return { ok: false, error: 'legacy content or summary must contain at least 50 characters' };
|
|
1714
|
+
}
|
|
1715
|
+
if (suppliedSubstance.length < 50) {
|
|
1716
|
+
return { ok: false, error: 'legacy input does not contain enough substantive content' };
|
|
1717
|
+
}
|
|
1718
|
+
const signals = strictOptionalStringList(value, 'signals', 12, 64);
|
|
1719
|
+
const signalsMatch = strictOptionalStringList(value, 'signals_match', 12, 64);
|
|
1720
|
+
const validation = strictOptionalStringList(value, 'validation', 8, 180);
|
|
1721
|
+
const verification = strictOptionalStringList(value, 'verification', 8, 180);
|
|
1722
|
+
const artifacts = strictOptionalStringList(value, 'artifacts', 12, 240);
|
|
1723
|
+
if (!signals.ok || !signalsMatch.ok || !validation.ok || !verification.ok || !artifacts.ok) {
|
|
1724
|
+
return { ok: false, error: 'legacy list fields must contain strings only' };
|
|
1725
|
+
}
|
|
1726
|
+
const constraints = parseLegacyConstraints(value['constraints']);
|
|
1727
|
+
if (!constraints.ok)
|
|
1728
|
+
return constraints;
|
|
1729
|
+
const category = parseLegacyCategory(value['category']);
|
|
1730
|
+
if (!category.ok)
|
|
1731
|
+
return category;
|
|
1732
|
+
const derivedSummary = summary.value
|
|
1733
|
+
|| content.value?.slice(0, 300)
|
|
1734
|
+
|| strategy.value?.join('; ').slice(0, 300)
|
|
1735
|
+
|| '';
|
|
1736
|
+
const input = {
|
|
1737
|
+
summary: derivedSummary,
|
|
1738
|
+
transcript: content.value ?? derivedSummary,
|
|
1739
|
+
...(strategy.value ? { strategy: strategy.value } : {}),
|
|
1740
|
+
...((signals.value ?? signalsMatch.value) ? { signals: signals.value ?? signalsMatch.value } : {}),
|
|
1741
|
+
...((validation.value ?? verification.value) ? { validation: validation.value ?? verification.value } : {}),
|
|
1742
|
+
...(artifacts.value ? { artifacts: artifacts.value } : {}),
|
|
1743
|
+
...strictForwardString(value, 'title'),
|
|
1744
|
+
...strictForwardString(value, 'name'),
|
|
1745
|
+
...strictForwardString(value, 'platform'),
|
|
1746
|
+
...strictForwardString(value, 'model'),
|
|
1747
|
+
...strictForwardString(value, 'thread_id'),
|
|
1748
|
+
...(isRecordValue(value['execution']) ? { execution: value['execution'] } : {}),
|
|
1749
|
+
...(isRecordValue(value['blast_radius']) ? { blast_radius: value['blast_radius'] } : {}),
|
|
1750
|
+
// Compatibility callers may not lower the V2 quality gate.
|
|
1751
|
+
min_score: 5,
|
|
1752
|
+
persist: false,
|
|
1753
|
+
};
|
|
1754
|
+
return {
|
|
1755
|
+
ok: true,
|
|
1756
|
+
input,
|
|
1757
|
+
...(constraints.value ? { constraints: constraints.value } : {}),
|
|
1758
|
+
...(category.value ? { category: category.value } : {}),
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
function parseLegacyConstraints(value) {
|
|
1762
|
+
if (value === undefined)
|
|
1763
|
+
return { ok: true };
|
|
1764
|
+
if (!isRecordValue(value))
|
|
1765
|
+
return { ok: false, error: 'legacy constraints must be an object' };
|
|
1766
|
+
if (Object.keys(value).some((key) => key !== 'max_files' && key !== 'forbidden_paths')) {
|
|
1767
|
+
return { ok: false, error: 'legacy constraints contains unsupported fields' };
|
|
1768
|
+
}
|
|
1769
|
+
const maxFiles = value['max_files'];
|
|
1770
|
+
if (maxFiles !== undefined && (!Number.isInteger(maxFiles) || Number(maxFiles) < 1 || Number(maxFiles) > 10_000)) {
|
|
1771
|
+
return { ok: false, error: 'legacy constraints.max_files must be an integer from 1 to 10000' };
|
|
1772
|
+
}
|
|
1773
|
+
const forbiddenPaths = value['forbidden_paths'];
|
|
1774
|
+
if (forbiddenPaths !== undefined && (!Array.isArray(forbiddenPaths)
|
|
1775
|
+
|| forbiddenPaths.length > 50
|
|
1776
|
+
|| forbiddenPaths.some((path) => typeof path !== 'string' || path.trim().length === 0 || path.trim().length > 200))) {
|
|
1777
|
+
return { ok: false, error: 'legacy constraints.forbidden_paths must be a bounded string array' };
|
|
1778
|
+
}
|
|
1779
|
+
const normalizedPaths = Array.isArray(forbiddenPaths)
|
|
1780
|
+
? uniqueStrings(forbiddenPaths.map((path) => String(path).trim()))
|
|
1781
|
+
: undefined;
|
|
1782
|
+
return {
|
|
1783
|
+
ok: true,
|
|
1784
|
+
value: {
|
|
1785
|
+
...(typeof maxFiles === 'number' ? { max_files: maxFiles } : {}),
|
|
1786
|
+
...(normalizedPaths ? { forbidden_paths: normalizedPaths } : {}),
|
|
1787
|
+
},
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
function mergeLegacyConstraints(base, legacy) {
|
|
1791
|
+
const current = isRecordValue(base) ? base : {};
|
|
1792
|
+
const currentMax = Number.isInteger(current['max_files']) && Number(current['max_files']) > 0
|
|
1793
|
+
? Number(current['max_files'])
|
|
1794
|
+
: 20;
|
|
1795
|
+
const currentPaths = Array.isArray(current['forbidden_paths'])
|
|
1796
|
+
? current['forbidden_paths'].filter((path) => typeof path === 'string')
|
|
1797
|
+
: [];
|
|
1798
|
+
return {
|
|
1799
|
+
max_files: Math.min(currentMax, legacy.max_files ?? currentMax),
|
|
1800
|
+
forbidden_paths: uniqueStrings([...currentPaths, ...(legacy.forbidden_paths ?? [])]),
|
|
1801
|
+
};
|
|
1802
|
+
}
|
|
1803
|
+
function parseLegacyCategory(value) {
|
|
1804
|
+
if (value === undefined)
|
|
1805
|
+
return { ok: true };
|
|
1806
|
+
if (value === 'repair' || value === 'optimize' || value === 'innovate' || value === 'explore') {
|
|
1807
|
+
return { ok: true, value };
|
|
1808
|
+
}
|
|
1809
|
+
return { ok: false, error: 'legacy category is invalid' };
|
|
1810
|
+
}
|
|
1811
|
+
function deterministicDistilledAsset(asset) {
|
|
1812
|
+
const draft = { ...asset, asset_id: '' };
|
|
1813
|
+
// `_source` is local distiller provenance and is not part of the current GEP Gene schema.
|
|
1814
|
+
// It also contains a wall-clock timestamp, so it must not influence compatibility asset ids.
|
|
1815
|
+
delete draft['_source'];
|
|
1816
|
+
return assetstore.normalizeForPut(draft).record;
|
|
1817
|
+
}
|
|
1818
|
+
function strictOptionalString(value, key) {
|
|
1819
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
1820
|
+
return { ok: true };
|
|
1821
|
+
const raw = value[key];
|
|
1822
|
+
if (typeof raw !== 'string')
|
|
1823
|
+
return { ok: false };
|
|
1824
|
+
const trimmed = raw.trim();
|
|
1825
|
+
return { ok: true, ...(trimmed ? { value: trimmed } : {}) };
|
|
1826
|
+
}
|
|
1827
|
+
function strictOptionalStringList(value, key, maxItems, maxLength) {
|
|
1828
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
1829
|
+
return { ok: true };
|
|
1830
|
+
const raw = value[key];
|
|
1831
|
+
if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string'))
|
|
1832
|
+
return { ok: false };
|
|
1833
|
+
const normalized = raw.map((item) => item.trim()).filter(Boolean).slice(0, maxItems);
|
|
1834
|
+
if (normalized.some((item) => item.length > maxLength))
|
|
1835
|
+
return { ok: false };
|
|
1836
|
+
return { ok: true, ...(normalized.length > 0 ? { value: normalized } : {}) };
|
|
1837
|
+
}
|
|
1838
|
+
function strictForwardString(value, key) {
|
|
1839
|
+
const parsed = strictOptionalString(value, key);
|
|
1840
|
+
return parsed.ok && parsed.value ? { [key]: parsed.value } : {};
|
|
1841
|
+
}
|
|
1842
|
+
function isRecordValue(value) {
|
|
1843
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
1844
|
+
}
|
|
1845
|
+
function safeIdentifier(value) {
|
|
1846
|
+
return value.replace(/[^a-z0-9_]+/gi, '_').slice(0, 80) || 'rejected';
|
|
1847
|
+
}
|
|
1848
|
+
function synchronousAssetSubmitKey(scope, runtimeNamespace, hubMode, bundle) {
|
|
1849
|
+
const assetIds = bundle.map((asset) => asset.asset_id).sort();
|
|
1850
|
+
const digestInput = hubMode === 'private'
|
|
1851
|
+
? [scope, runtimeNamespace, hubMode, assetIds]
|
|
1852
|
+
: [scope, runtimeNamespace, assetIds];
|
|
1853
|
+
const digest = createHash('sha256')
|
|
1854
|
+
.update(JSON.stringify(digestInput))
|
|
1855
|
+
.digest('hex');
|
|
1856
|
+
return `${SYNC_ASSET_SUBMIT_PREFIX}${digest}`;
|
|
1857
|
+
}
|
|
1858
|
+
function asyncAssetSubmitEnvelopeId(runtimeNamespace, requestId) {
|
|
1859
|
+
const digest = createHash('sha256')
|
|
1860
|
+
.update(JSON.stringify([runtimeNamespace, requestId]))
|
|
1861
|
+
.digest('hex');
|
|
1862
|
+
return `${ASYNC_ASSET_SUBMIT_PREFIX}${digest}`;
|
|
1863
|
+
}
|
|
1864
|
+
function compareSynchronousAssetSubmitAssets(left, right) {
|
|
1865
|
+
const typeOrder = SYNC_ASSET_SUBMIT_TYPE_RANK[left.type] - SYNC_ASSET_SUBMIT_TYPE_RANK[right.type];
|
|
1866
|
+
if (typeOrder !== 0)
|
|
1867
|
+
return typeOrder;
|
|
1868
|
+
if (left.asset_id < right.asset_id)
|
|
1869
|
+
return -1;
|
|
1870
|
+
if (left.asset_id > right.asset_id)
|
|
1871
|
+
return 1;
|
|
1872
|
+
return 0;
|
|
1873
|
+
}
|
|
1874
|
+
function synchronousAssetSubmitOutcomeKey(idempotencyKey) {
|
|
1875
|
+
return `${idempotencyKey}:outcome`;
|
|
1876
|
+
}
|
|
1877
|
+
function synchronousAssetSubmitAcceptanceKey(idempotencyKey) {
|
|
1878
|
+
return `${idempotencyKey}:accepted`;
|
|
1879
|
+
}
|
|
1880
|
+
function cachedSynchronousAssetSubmitFailure(outcome) {
|
|
1881
|
+
const retryAfterMs = positiveFiniteNumber(outcome.body['retry_after_ms']);
|
|
1882
|
+
return new hubNs.PublishRejectedError(typeof outcome.body['status'] === 'string'
|
|
1883
|
+
? outcome.body['status']
|
|
1884
|
+
: String(outcome.body['error'] ?? 'rejected'), true, typeof outcome.body['reason'] === 'string' ? outcome.body['reason'] : undefined, retryAfterMs, false);
|
|
1885
|
+
}
|
|
1886
|
+
function isSynchronousAssetSubmitEnvelope(envelope) {
|
|
1887
|
+
return envelope.type === 'asset_submit'
|
|
1888
|
+
&& envelope.idempotencyKey.startsWith(SYNC_ASSET_SUBMIT_PREFIX);
|
|
1889
|
+
}
|
|
1890
|
+
function isTerminalSynchronousPublishFailure(error) {
|
|
1891
|
+
return error instanceof hubNs.PublishRejectedError && error.terminal;
|
|
1892
|
+
}
|
|
1893
|
+
function isRetryableSynchronousPublishFailure(error) {
|
|
1894
|
+
if (error instanceof AuthError || errorName(error) === 'AuthError')
|
|
1895
|
+
return true;
|
|
1896
|
+
if (error instanceof HubUnreachableError || errorName(error) === 'HubUnreachableError')
|
|
1897
|
+
return true;
|
|
1898
|
+
if (error instanceof hubNs.PublishRejectedError) {
|
|
1899
|
+
return !error.terminal && (error.retryable === true || error.retryAfterMs !== undefined);
|
|
1900
|
+
}
|
|
1901
|
+
if (error instanceof HubClientError || errorName(error) === 'HubClientError') {
|
|
1902
|
+
const status = error instanceof HubClientError ? error.status : Number(asRecord(error)['status']);
|
|
1903
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
1904
|
+
}
|
|
1905
|
+
return false;
|
|
1906
|
+
}
|
|
1907
|
+
function synchronousPublishRetryAfterMs(error, failure) {
|
|
1908
|
+
const fromBody = positiveFiniteNumber(failure.body['retry_after_ms']);
|
|
1909
|
+
if (fromBody !== undefined)
|
|
1910
|
+
return Math.max(1_000, fromBody);
|
|
1911
|
+
if (error instanceof hubNs.PublishRejectedError && error.retryAfterMs !== undefined) {
|
|
1912
|
+
return Math.max(1_000, error.retryAfterMs);
|
|
1913
|
+
}
|
|
1914
|
+
if (error instanceof HubUnreachableError)
|
|
1915
|
+
return Math.max(1_000, error.retryAfterMs);
|
|
1916
|
+
if (error instanceof HubClientError && error.retryAfterMs !== undefined) {
|
|
1917
|
+
return Math.max(1_000, error.retryAfterMs);
|
|
1918
|
+
}
|
|
1919
|
+
return 60_000;
|
|
1920
|
+
}
|
|
1921
|
+
function submittedAssetIds(result, fallback) {
|
|
1922
|
+
const record = asRecord(result);
|
|
1923
|
+
for (const key of ['submittedAssetIds', 'assetIds']) {
|
|
1924
|
+
const value = record[key];
|
|
1925
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
|
1926
|
+
return value;
|
|
1927
|
+
}
|
|
1928
|
+
return fallback.map((asset) => asset.asset_id);
|
|
1929
|
+
}
|
|
1930
|
+
function mapSynchronousPublishFailure(error) {
|
|
1931
|
+
if (error instanceof hubNs.PublishRejectedError) {
|
|
1932
|
+
const status = publishRejectionStatus(error.status);
|
|
1933
|
+
if (status === 'cooldown') {
|
|
1934
|
+
return {
|
|
1935
|
+
statusCode: 429,
|
|
1936
|
+
body: {
|
|
1937
|
+
error: 'hub_rate_limited',
|
|
1938
|
+
...(error.retryAfterMs !== undefined ? { retry_after_ms: error.retryAfterMs } : {}),
|
|
1939
|
+
},
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
if (status === 'credit_shortage') {
|
|
1943
|
+
return { statusCode: 402, body: { error: 'hub_payment_required' } };
|
|
1944
|
+
}
|
|
1945
|
+
return {
|
|
1946
|
+
statusCode: error.terminal ? 422 : 503,
|
|
1947
|
+
body: {
|
|
1948
|
+
error: 'publish_rejected',
|
|
1949
|
+
status,
|
|
1950
|
+
terminal: error.terminal,
|
|
1951
|
+
reason: status === 'leak_blocked'
|
|
1952
|
+
? 'sensitive data detected before publish'
|
|
1953
|
+
: 'Hub did not accept the publish',
|
|
1954
|
+
...(error.retryAfterMs !== undefined ? { retry_after_ms: error.retryAfterMs } : {}),
|
|
1955
|
+
},
|
|
1956
|
+
};
|
|
1957
|
+
}
|
|
1958
|
+
if (error instanceof AuthError || errorName(error) === 'AuthError') {
|
|
1959
|
+
return { statusCode: 502, body: { error: 'hub_auth_failed' } };
|
|
1960
|
+
}
|
|
1961
|
+
if (error instanceof HubUnreachableError || errorName(error) === 'HubUnreachableError') {
|
|
1962
|
+
const retryAfterMs = error instanceof HubUnreachableError ? error.retryAfterMs : positiveFiniteNumber(asRecord(error)['retryAfterMs']);
|
|
1963
|
+
return {
|
|
1964
|
+
statusCode: 503,
|
|
1965
|
+
body: { error: 'hub_unreachable', ...(retryAfterMs !== undefined ? { retry_after_ms: retryAfterMs } : {}) },
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
if (error instanceof HubClientError || errorName(error) === 'HubClientError') {
|
|
1969
|
+
const status = error instanceof HubClientError ? error.status : Number(asRecord(error)['status']);
|
|
1970
|
+
if (status === 429) {
|
|
1971
|
+
const retryAfterMs = error instanceof HubClientError ? error.retryAfterMs : positiveFiniteNumber(asRecord(error)['retryAfterMs']);
|
|
1972
|
+
return {
|
|
1973
|
+
statusCode: 429,
|
|
1974
|
+
body: { error: 'hub_rate_limited', ...(retryAfterMs !== undefined ? { retry_after_ms: retryAfterMs } : {}) },
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
if (status === 402)
|
|
1978
|
+
return { statusCode: 402, body: { error: 'hub_payment_required' } };
|
|
1979
|
+
return { statusCode: status >= 500 ? 503 : 502, body: { error: 'hub_publish_failed' } };
|
|
1980
|
+
}
|
|
1981
|
+
return { statusCode: 502, body: { error: 'hub_publish_failed' } };
|
|
1982
|
+
}
|
|
1983
|
+
function errorName(value) {
|
|
1984
|
+
return typeof asRecord(value)['name'] === 'string' ? String(asRecord(value)['name']) : undefined;
|
|
1985
|
+
}
|
|
1986
|
+
function publishRejectionStatus(value) {
|
|
1987
|
+
return value === 'quarantine'
|
|
1988
|
+
|| value === 'leak_blocked'
|
|
1989
|
+
|| value === 'cooldown'
|
|
1990
|
+
|| value === 'credit_shortage'
|
|
1991
|
+
? value
|
|
1992
|
+
: 'rejected';
|
|
1993
|
+
}
|
|
992
1994
|
function respondAgentDirectory(ctx, result) {
|
|
993
1995
|
if (result.ok) {
|
|
994
1996
|
ctx.json(200, result);
|