@evomap/evolver-proxy 2.0.0-beta.2 → 2.0.0-beta.22
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-llm-proxy.js +0 -0
- package/dist/bin/evolver-proxy.d.ts +105 -7
- package/dist/bin/evolver-proxy.js +877 -121
- package/dist/daemon/atpConsent.js +5 -2
- package/dist/daemon/collaborationFacade.js +26 -16
- package/dist/daemon/proxyDaemon.d.ts +65 -0
- package/dist/daemon/proxyDaemon.js +1384 -29
- package/dist/daemon/publishRecallVerifier.d.ts +114 -0
- package/dist/daemon/publishRecallVerifier.js +495 -0
- package/dist/daemon/selectHub.js +5 -3
- package/dist/daemon/systemdNotifier.d.ts +48 -0
- package/dist/daemon/systemdNotifier.js +163 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/lifecycle/claimNudge.d.ts +20 -0
- package/dist/lifecycle/claimNudge.js +124 -0
- package/dist/lifecycle/legacyNodeId.d.ts +11 -13
- package/dist/lifecycle/legacyNodeId.js +35 -20
- package/dist/lifecycle/manager.d.ts +4 -0
- package/dist/lifecycle/manager.js +15 -2
- package/dist/llm/server.js +24 -4
- package/dist/llm/traceControl.js +1 -1
- package/dist/llm/upstream.d.ts +5 -1
- package/dist/llm/upstream.js +72 -2
- package/dist/private/accountAssetCompatibility.d.ts +29 -0
- package/dist/private/accountAssetCompatibility.js +196 -0
- package/dist/private/adapterLoader.d.ts +21 -1
- package/dist/private/adapterLoader.js +242 -7
- package/dist/private/nodeCredentialStore.d.ts +23 -0
- package/dist/private/nodeCredentialStore.js +210 -0
- package/dist/router/messagesRoute.js +9 -3
- package/dist/router/providerRoutes.js +7 -3
- package/dist/selfUpdate/bootstrap.d.ts +162 -0
- package/dist/selfUpdate/bootstrap.js +3524 -0
- package/dist/selfUpdate/bootstrapReadiness.d.ts +9 -0
- package/dist/selfUpdate/bootstrapReadiness.js +153 -0
- package/dist/selfUpdate/builtinKey.d.ts +4 -0
- package/dist/selfUpdate/builtinKey.js +16 -0
- package/dist/selfUpdate/controllerLifecycleAuthority.d.ts +45 -0
- package/dist/selfUpdate/controllerLifecycleAuthority.js +61 -0
- package/dist/selfUpdate/executor.d.ts +27 -11
- package/dist/selfUpdate/executor.js +233 -58
- package/dist/selfUpdate/failureCodes.d.ts +10 -0
- package/dist/selfUpdate/failureCodes.js +13 -0
- package/dist/selfUpdate/index.d.ts +5 -1
- package/dist/selfUpdate/index.js +5 -1
- package/dist/selfUpdate/lastUpdate.d.ts +3 -1
- package/dist/selfUpdate/lastUpdate.js +37 -6
- package/dist/selfUpdate/migration.d.ts +158 -0
- package/dist/selfUpdate/migration.js +2672 -0
- package/dist/selfUpdate/policy.d.ts +19 -2
- package/dist/selfUpdate/policy.js +76 -2
- package/dist/selfUpdate/recoveryChildStartGate.d.ts +29 -0
- package/dist/selfUpdate/recoveryChildStartGate.js +319 -0
- package/dist/selfUpdate/releaseBinary.d.ts +13 -0
- package/dist/selfUpdate/releaseBinary.js +93 -10
- package/dist/selfUpdate/transaction.d.ts +117 -0
- package/dist/selfUpdate/transaction.js +1322 -0
- package/dist/selfUpdate/unixController.d.ts +23 -0
- package/dist/selfUpdate/unixController.js +514 -0
- package/dist/selfUpdate/version.d.ts +6 -2
- package/dist/selfUpdate/version.js +5 -3
- package/dist/selfUpdate/windowsController.d.ts +35 -0
- package/dist/selfUpdate/windowsController.js +655 -0
- package/dist/selfUpdate/windowsUpdater.d.ts +104 -0
- package/dist/selfUpdate/windowsUpdater.js +882 -0
- package/dist/sync/engine.d.ts +12 -0
- package/dist/sync/engine.js +255 -64
- package/package.json +10 -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 } from '@evomap/evolver-core';
|
|
3
|
+
import { mailbox, hub as hubNs, shadow as shadow_, assetstore, wire, util, verify } 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,10 +9,62 @@ 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';
|
|
13
|
+
const DEFAULT_PUBLISH_EXECUTION_VERIFY_TIMEOUT_MS = 30_000;
|
|
10
14
|
export const DEFAULT_IPC_PORT = 19820;
|
|
15
|
+
// V1 local-proxy compatibility contract; independent of the V2 mailbox envelope schema.
|
|
16
|
+
const PROXY_PROTOCOL_VERSION = '0.1.0';
|
|
17
|
+
const PROXY_STATUS_SCHEMA_VERSION = 1;
|
|
11
18
|
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
12
19
|
const MAX_PROXY_TICK_ERROR_LENGTH = 2_000;
|
|
13
20
|
const MAX_HEARTBEAT_TICK_ERROR_LENGTH = 1_000;
|
|
21
|
+
const MAX_EPHEMERAL_IPC_LISTEN_ATTEMPTS = 5;
|
|
22
|
+
const DEFAULT_ASSET_SEARCH_CACHE_TTL_MS = 30_000;
|
|
23
|
+
const DEFAULT_ASSET_SEARCH_CACHE_MAX = 256;
|
|
24
|
+
const DEFAULT_ASSET_SEARCH_STALE_GRACE_MS = 5 * 60_000;
|
|
25
|
+
const MAX_ASSET_SUBMIT_ITEMS = 50;
|
|
26
|
+
const ASYNC_ASSET_SUBMIT_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
27
|
+
const ASYNC_ASSET_SUBMIT_PREFIX = 'async_asset_submit:';
|
|
28
|
+
const OUTBOUND_HUB_MODE_FIELD = '__evolver_hub_mode';
|
|
29
|
+
const SYNC_ASSET_SUBMIT_PREFIX = 'sync_asset_submit:';
|
|
30
|
+
const SYNC_ASSET_SUBMIT_TYPE_RANK = {
|
|
31
|
+
Gene: 0,
|
|
32
|
+
Capsule: 1,
|
|
33
|
+
EvolutionEvent: 2,
|
|
34
|
+
AntiGene: 3,
|
|
35
|
+
};
|
|
36
|
+
const SYNC_ASSET_SUBMIT_SCOPE_STATE_KEY = 'sync_asset_submit:idempotency_scope:v1';
|
|
37
|
+
const SYNC_ASSET_SUBMIT_DIRECT_RETRY_GRACE_MS = 30_000;
|
|
38
|
+
const DEFAULT_SYNC_ASSET_SUBMIT_RESPONSE_TIMEOUT_MS = 15_000;
|
|
39
|
+
function withSynchronousPublishStatus(value, publishStatus) {
|
|
40
|
+
const body = isRecordValue(value) ? { ...value } : { receipt: value };
|
|
41
|
+
return { ...body, publish_status: publishStatus, queued: false };
|
|
42
|
+
}
|
|
43
|
+
function legacySynchronousResultStatus(result) {
|
|
44
|
+
if (result.ok)
|
|
45
|
+
return 'accepted';
|
|
46
|
+
// 202 表示兼容层已持久化但尚未完成;校验或终态/传输失败即使在 mailbox
|
|
47
|
+
// 中保留可重试意图,聚合仍归类为 failed。
|
|
48
|
+
return result.statusCode === 202 || result.error === 'publish_pending' ? 'pending' : 'failed';
|
|
49
|
+
}
|
|
50
|
+
function summarizeLegacySynchronousResults(results) {
|
|
51
|
+
const statuses = new Set(results.map(legacySynchronousResultStatus));
|
|
52
|
+
// publish_status 表示聚合终态,而 queued 独立表示是否仍有 durable mailbox 工作。
|
|
53
|
+
// 因此 pending+failed 必须是 failed/true:不能掩盖失败,也不能谎报没有排队。
|
|
54
|
+
if (statuses.has('failed') || statuses.size === 0)
|
|
55
|
+
return { publishStatus: 'failed', queued: statuses.has('pending') };
|
|
56
|
+
if (statuses.has('pending'))
|
|
57
|
+
return { publishStatus: 'pending', queued: true };
|
|
58
|
+
return { publishStatus: 'accepted', queued: false };
|
|
59
|
+
}
|
|
60
|
+
class OutboundHubModeMismatchError extends Error {
|
|
61
|
+
retryable = true;
|
|
62
|
+
retryAfterMs = 1_000;
|
|
63
|
+
constructor(expected, actual) {
|
|
64
|
+
super(`asset_submit hub mode mismatch: queued for ${expected}, running in ${actual}`);
|
|
65
|
+
this.name = 'OutboundHubModeMismatchError';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
14
68
|
/**
|
|
15
69
|
* ProxyDaemon(M6-4) 装配层: 把 core(MailboxStore/Dispatcher/MailboxDaemon/IpcServer) +
|
|
16
70
|
* HubBindings(M6-1) + SyncEngine(M6-2) + LifecycleManager(M6-3) 拼成系统级 proxy.
|
|
@@ -30,9 +84,23 @@ export class ProxyDaemon {
|
|
|
30
84
|
validator;
|
|
31
85
|
atp;
|
|
32
86
|
collaborationFacade;
|
|
87
|
+
publishRecallVerifier;
|
|
88
|
+
proxyHandler;
|
|
89
|
+
hub;
|
|
90
|
+
recipeComposeStarted = new Set();
|
|
33
91
|
ipc;
|
|
34
92
|
now;
|
|
35
93
|
random;
|
|
94
|
+
assetSearchCacheTtlMs;
|
|
95
|
+
assetSearchCacheMax;
|
|
96
|
+
assetSearchStaleGraceMs;
|
|
97
|
+
assetSubmitResponseTimeoutMs;
|
|
98
|
+
synchronousAssetSubmitScope;
|
|
99
|
+
shadowMode;
|
|
100
|
+
assetSearchCache = new Map();
|
|
101
|
+
assetSearchInflight = new Map();
|
|
102
|
+
synchronousAssetSubmitInflight = new Map();
|
|
103
|
+
assetSearchCooldownUntil = 0;
|
|
36
104
|
nextHeartbeatAt;
|
|
37
105
|
heartbeatFailures = 0;
|
|
38
106
|
heartbeatGeneration = 0;
|
|
@@ -41,6 +109,10 @@ export class ProxyDaemon {
|
|
|
41
109
|
/** A poke that arrived between ticks (no sleep in flight) parks the wake here so it is not lost. */
|
|
42
110
|
wakeRunnerPending = false;
|
|
43
111
|
started = false;
|
|
112
|
+
lifecycleArmed = false;
|
|
113
|
+
lastTickAt;
|
|
114
|
+
nextTickDueAt;
|
|
115
|
+
consecutiveTickFailures = 0;
|
|
44
116
|
storeClosed = false;
|
|
45
117
|
forceUpdateTriggerInFlight = false;
|
|
46
118
|
forceUpdateLastTriggeredAt;
|
|
@@ -50,24 +122,41 @@ export class ProxyDaemon {
|
|
|
50
122
|
scheduledForceUpdateKey;
|
|
51
123
|
traceBackfillDraining = false;
|
|
52
124
|
loopWakeHandler;
|
|
125
|
+
/** 守护进程停止时取消宿主验证。 */
|
|
126
|
+
publishAbortController = new AbortController();
|
|
53
127
|
constructor(deps) {
|
|
54
128
|
this.deps = deps;
|
|
55
129
|
this.now = deps.now ?? (() => Date.now());
|
|
56
130
|
this.random = deps.random ?? Math.random;
|
|
131
|
+
this.assetSearchCacheTtlMs = positiveIntegerOr(deps.assetSearchCacheTtlMs, DEFAULT_ASSET_SEARCH_CACHE_TTL_MS);
|
|
132
|
+
this.assetSearchCacheMax = positiveIntegerOr(deps.assetSearchCacheMax, DEFAULT_ASSET_SEARCH_CACHE_MAX);
|
|
133
|
+
this.assetSearchStaleGraceMs = positiveIntegerOr(deps.assetSearchStaleGraceMs, DEFAULT_ASSET_SEARCH_STALE_GRACE_MS);
|
|
134
|
+
this.assetSubmitResponseTimeoutMs = positiveIntegerOr(deps.assetSubmitResponseTimeoutMs, DEFAULT_SYNC_ASSET_SUBMIT_RESPONSE_TIMEOUT_MS);
|
|
57
135
|
if (!deps.store && !deps.storePath)
|
|
58
136
|
throw new Error('ProxyDaemon: 需 store 或 storePath 之一');
|
|
59
137
|
const shadow = deps.shadowMode === 'shadow';
|
|
138
|
+
this.shadowMode = shadow;
|
|
60
139
|
if (shadow && !deps.shadowSink)
|
|
61
140
|
throw new Error('ProxyDaemon: shadow 模式需 shadowSink');
|
|
62
141
|
// M8 shadow 装配: 在边界包 decorator, 下游 makeHubBindings/Dispatcher/SyncEngine/MailboxDaemon 零改.
|
|
63
142
|
this.store = deps.store
|
|
64
143
|
?? (shadow ? new shadow_.ShadowMailboxStore({ path: deps.storePath }, deps.shadowSink, 'shadow') : new mailbox.MailboxStore({ path: deps.storePath }));
|
|
144
|
+
const existingSynchronousAssetSubmitScope = this.store.getState(SYNC_ASSET_SUBMIT_SCOPE_STATE_KEY);
|
|
145
|
+
this.synchronousAssetSubmitScope = existingSynchronousAssetSubmitScope ?? randomUUID();
|
|
146
|
+
if (!existingSynchronousAssetSubmitScope) {
|
|
147
|
+
this.store.setState(SYNC_ASSET_SUBMIT_SCOPE_STATE_KEY, this.synchronousAssetSubmitScope);
|
|
148
|
+
}
|
|
65
149
|
const assetStoreDir = deps.assetStoreDir ?? (deps.storePath ? join(dirname(deps.storePath), 'assets') : undefined);
|
|
66
150
|
this.assetStore = deps.assetStore ?? (assetStoreDir ? new assetstore.LocalJsonlProvider(assetStoreDir) : undefined);
|
|
67
151
|
this.atp = deps.atp;
|
|
68
152
|
const hubToUse = shadow ? shadow_.shadowHubCapability(deps.hub, deps.shadowSink, 'shadow') : deps.hub;
|
|
69
|
-
|
|
70
|
-
const
|
|
153
|
+
this.hub = hubToUse;
|
|
154
|
+
const hubBindings = hubNs.makeHubBindings(hubToUse, deps.publishSanitizeEnv
|
|
155
|
+
? { sanitize: { env: deps.publishSanitizeEnv } }
|
|
156
|
+
: {});
|
|
157
|
+
this.proxyHandler = hubBindings.asProxyHandler();
|
|
158
|
+
const proxyHandler = this.proxyHandler;
|
|
159
|
+
const syncProxyHandler = (envelope) => this.handleHubModeBoundOutbound(envelope);
|
|
71
160
|
const assetByIdSource = isAssetByIdFetcher(deps.hub) ? deps.hub : (isAssetByIdFetcher(hubToUse) ? hubToUse : undefined);
|
|
72
161
|
this.remoteAssetById = assetByIdSource
|
|
73
162
|
? async (assetId) => {
|
|
@@ -75,6 +164,17 @@ export class ProxyDaemon {
|
|
|
75
164
|
return assetMatchesId(fetched, assetId) ? fetched : null;
|
|
76
165
|
}
|
|
77
166
|
: undefined;
|
|
167
|
+
const publishRecallConfig = resolvePublishRecallConfig();
|
|
168
|
+
this.publishRecallVerifier = deps.publishRecallVerifier ?? new PublishRecallVerifier({
|
|
169
|
+
store: this.store,
|
|
170
|
+
...(!shadow && assetByIdSource
|
|
171
|
+
? { fetchAssetById: (assetId) => assetByIdSource.fetchAssetById(assetId) }
|
|
172
|
+
: {}),
|
|
173
|
+
config: shadow ? { ...publishRecallConfig, enabled: false } : publishRecallConfig,
|
|
174
|
+
now: this.now,
|
|
175
|
+
random: this.random,
|
|
176
|
+
stateKey: `publish_recall_verifier:${deps.runtimeNamespace ?? 'default'}:v1`,
|
|
177
|
+
});
|
|
78
178
|
this.reuseResultReporter = isReuseResultReporter(hubToUse)
|
|
79
179
|
? hubToUse
|
|
80
180
|
: (!shadow && isReuseResultReporter(deps.hub) ? deps.hub : undefined);
|
|
@@ -104,15 +204,52 @@ export class ProxyDaemon {
|
|
|
104
204
|
...(deps.collaborationOperationTimeoutMs !== undefined ? { operationTimeoutMs: deps.collaborationOperationTimeoutMs } : {}),
|
|
105
205
|
});
|
|
106
206
|
this.sync = new SyncEngine({
|
|
107
|
-
store: this.store, hub: hubToUse, proxyHandler, now: this.now,
|
|
207
|
+
store: this.store, hub: hubToUse, proxyHandler: syncProxyHandler, now: this.now,
|
|
108
208
|
...(deps.runtimeNamespace ? { runtimeNamespace: deps.runtimeNamespace } : {}),
|
|
109
|
-
onOutboundSucceeded: (envelope, result) =>
|
|
110
|
-
|
|
209
|
+
onOutboundSucceeded: (envelope, result) => {
|
|
210
|
+
this.collaborationFacade.handleOutboundSucceeded(envelope, result);
|
|
211
|
+
let shouldObserve = true;
|
|
212
|
+
try {
|
|
213
|
+
const cached = this.cacheSynchronousAssetSubmitSuccess(envelope, result);
|
|
214
|
+
if (cached === false)
|
|
215
|
+
shouldObserve = false;
|
|
216
|
+
}
|
|
217
|
+
catch { /* best-effort */ }
|
|
218
|
+
// Observability must never turn a Hub-accepted publish into a failed/retried economic action.
|
|
219
|
+
if (shouldObserve) {
|
|
220
|
+
try {
|
|
221
|
+
this.publishRecallVerifier.observeAcceptedPublish(envelope, result);
|
|
222
|
+
}
|
|
223
|
+
catch { /* best-effort */ }
|
|
224
|
+
}
|
|
225
|
+
// Recipe is the preferred public artifact. Failure here must not retry the already-accepted asset publish.
|
|
226
|
+
this.composeRecipeAfterAcceptedSubmit(envelope);
|
|
227
|
+
},
|
|
228
|
+
onOutboundTerminal: (envelope, error) => {
|
|
229
|
+
this.collaborationFacade.handleOutboundTerminal(envelope, error);
|
|
230
|
+
try {
|
|
231
|
+
this.cacheSynchronousAssetSubmitTerminal(envelope, error);
|
|
232
|
+
}
|
|
233
|
+
catch { /* best-effort */ }
|
|
234
|
+
},
|
|
235
|
+
acceptedOutcomeKey: (envelope) => !shadow && isSynchronousAssetSubmitEnvelope(envelope)
|
|
236
|
+
? synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey)
|
|
237
|
+
: undefined,
|
|
238
|
+
terminalOutcome: (envelope, error) => {
|
|
239
|
+
if (shadow || !isSynchronousAssetSubmitEnvelope(envelope))
|
|
240
|
+
return undefined;
|
|
241
|
+
const failure = mapSynchronousPublishFailure(error);
|
|
242
|
+
return {
|
|
243
|
+
key: synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey),
|
|
244
|
+
result: { kind: 'failed', ...failure },
|
|
245
|
+
};
|
|
246
|
+
},
|
|
111
247
|
normalizeInboundEnvelope: (envelope) => this.collaborationFacade.normalizeInboundEnvelope(envelope),
|
|
112
248
|
...(deps.traceBackfill ? { onOutboundFlushed: () => { this.drainProxyTraceBackfill(); } } : {}),
|
|
113
249
|
});
|
|
114
250
|
this.lifecycle = new LifecycleManager({
|
|
115
251
|
store: this.store, auth: hubToUse.auth, hello: deps.hello, heartbeat: deps.heartbeat, now: this.now,
|
|
252
|
+
...(deps.heartbeatIntervalMs !== undefined ? { heartbeatIntervalMs: deps.heartbeatIntervalMs } : {}),
|
|
116
253
|
...(deps.evolverVersion ? { evolverVersion: deps.evolverVersion } : {}),
|
|
117
254
|
...(deps.helloMode ? { helloMode: deps.helloMode } : {}),
|
|
118
255
|
onForceUpdateDirective: (directive, source) => { this.triggerForceUpdateFromHeartbeat(directive, source); },
|
|
@@ -152,10 +289,13 @@ export class ProxyDaemon {
|
|
|
152
289
|
async start() {
|
|
153
290
|
if (this.started)
|
|
154
291
|
throw new Error('ProxyDaemon 已启动');
|
|
292
|
+
if (this.publishAbortController.signal.aborted)
|
|
293
|
+
this.publishAbortController = new AbortController();
|
|
155
294
|
try {
|
|
156
295
|
this.daemon.start();
|
|
157
296
|
this.ipc = new mailbox.MailboxIpcServer({
|
|
158
297
|
store: this.store, token: this.deps.ipcToken,
|
|
298
|
+
runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
|
|
159
299
|
...(this.deps.ipcHost ? { host: this.deps.ipcHost } : {}), now: this.now,
|
|
160
300
|
onSend: (env, result) => {
|
|
161
301
|
if (result.stored && env.handler === 'proxy')
|
|
@@ -164,13 +304,18 @@ export class ProxyDaemon {
|
|
|
164
304
|
...(this.deps.onIpcAuthFailure ? { onAuthFailure: this.deps.onIpcAuthFailure } : {}),
|
|
165
305
|
extraRoutes: [(ctx) => this.handleProxyRoute(ctx)],
|
|
166
306
|
});
|
|
167
|
-
const port = await this.
|
|
307
|
+
const port = await this.listenIpc(this.ipc);
|
|
168
308
|
try {
|
|
169
309
|
this.deps.onIpcListen?.(port);
|
|
170
310
|
}
|
|
171
311
|
catch { /* local discovery publishing must not block daemon startup */ }
|
|
172
312
|
await this.lifecycle.doHello();
|
|
313
|
+
this.lifecycleArmed = true;
|
|
173
314
|
this.drainProxyTraceBackfill();
|
|
315
|
+
try {
|
|
316
|
+
this.publishRecallVerifier.start();
|
|
317
|
+
}
|
|
318
|
+
catch { /* verifier availability must not block proxy startup */ }
|
|
174
319
|
this.started = true;
|
|
175
320
|
return port;
|
|
176
321
|
}
|
|
@@ -185,9 +330,26 @@ export class ProxyDaemon {
|
|
|
185
330
|
}
|
|
186
331
|
catch { /* best-effort cleanup */ }
|
|
187
332
|
this.started = false;
|
|
333
|
+
this.lifecycleArmed = false;
|
|
188
334
|
throw err;
|
|
189
335
|
}
|
|
190
336
|
}
|
|
337
|
+
async listenIpc(ipc) {
|
|
338
|
+
const requestedPort = this.deps.ipcPort ?? DEFAULT_IPC_PORT;
|
|
339
|
+
if (requestedPort !== 0)
|
|
340
|
+
return ipc.listen(requestedPort);
|
|
341
|
+
for (let attempt = 0; attempt < MAX_EPHEMERAL_IPC_LISTEN_ATTEMPTS; attempt += 1) {
|
|
342
|
+
const assignedPort = await ipc.listen(0);
|
|
343
|
+
if (!util.isFetchForbiddenPort(assignedPort))
|
|
344
|
+
return assignedPort;
|
|
345
|
+
// The outer start() cleanup owns the final listener when retries are exhausted.
|
|
346
|
+
if (attempt === MAX_EPHEMERAL_IPC_LISTEN_ATTEMPTS - 1) {
|
|
347
|
+
throw new Error('proxy_ipc_safe_port_unavailable');
|
|
348
|
+
}
|
|
349
|
+
await ipc.close();
|
|
350
|
+
}
|
|
351
|
+
throw new Error('proxy_ipc_safe_port_unavailable');
|
|
352
|
+
}
|
|
191
353
|
/** 单轮: core pump/TTL/wake + proxy 出站 + hub 入站 + 到点心跳. */
|
|
192
354
|
async tick() {
|
|
193
355
|
const errors = [];
|
|
@@ -279,11 +441,14 @@ export class ProxyDaemon {
|
|
|
279
441
|
catch { /* ignore telemetry persistence failures */ }
|
|
280
442
|
}
|
|
281
443
|
const failedPhases = uniqueTickPhases(errors.map((err) => err.phase));
|
|
444
|
+
const fatalCandidate = errors.length > 0 && isFatalTickCandidate(outbound, inbound, failedPhases);
|
|
445
|
+
this.lastTickAt = this.now();
|
|
446
|
+
this.consecutiveTickFailures = fatalCandidate ? this.consecutiveTickFailures + 1 : 0;
|
|
282
447
|
return {
|
|
283
448
|
outbound,
|
|
284
449
|
inbound,
|
|
285
450
|
...(heartbeat ? { heartbeat } : {}),
|
|
286
|
-
...(errors.length > 0 ? { errors, failedPhases, fatalCandidate
|
|
451
|
+
...(errors.length > 0 ? { errors, failedPhases, fatalCandidate } : { failedPhases: [], fatalCandidate: false }),
|
|
287
452
|
};
|
|
288
453
|
}
|
|
289
454
|
/** 下一轮建议延时: inbound 背压/idle 与 outbound pending cadence 取更快者. */
|
|
@@ -300,6 +465,11 @@ export class ProxyDaemon {
|
|
|
300
465
|
setWakeHandler(wake) {
|
|
301
466
|
this.loopWakeHandler = wake;
|
|
302
467
|
}
|
|
468
|
+
setExpectedNextTick(delayMs) {
|
|
469
|
+
this.nextTickDueAt = delayMs === undefined
|
|
470
|
+
? undefined
|
|
471
|
+
: this.now() + Math.max(0, delayMs);
|
|
472
|
+
}
|
|
303
473
|
notifyNewOutbound() {
|
|
304
474
|
if (this.loopWakeHandler) {
|
|
305
475
|
this.loopWakeHandler();
|
|
@@ -357,11 +527,21 @@ export class ProxyDaemon {
|
|
|
357
527
|
return {
|
|
358
528
|
running: this.started,
|
|
359
529
|
ipcListening: !!this.ipc,
|
|
530
|
+
lifecycleArmed: this.lifecycleArmed,
|
|
360
531
|
...(this.lifecycle.nodeId ? { nodeId: this.lifecycle.nodeId } : {}),
|
|
361
532
|
lastWriteAt: this.daemon.lastWriteAt(),
|
|
533
|
+
...(this.lastTickAt !== undefined ? { lastTickAt: this.lastTickAt } : {}),
|
|
534
|
+
...(this.nextTickDueAt !== undefined ? { nextTickDueAt: this.nextTickDueAt } : {}),
|
|
535
|
+
consecutiveFailures: this.consecutiveTickFailures,
|
|
362
536
|
};
|
|
363
537
|
}
|
|
364
538
|
async stop() {
|
|
539
|
+
this.started = false;
|
|
540
|
+
this.lifecycleArmed = false;
|
|
541
|
+
if (!this.publishAbortController.signal.aborted) {
|
|
542
|
+
this.publishAbortController.abort(new Error('proxy_daemon_stopped'));
|
|
543
|
+
}
|
|
544
|
+
this.nextTickDueAt = undefined;
|
|
365
545
|
if (this.forceUpdateTimer) {
|
|
366
546
|
clearTimeout(this.forceUpdateTimer);
|
|
367
547
|
this.forceUpdateTimer = undefined;
|
|
@@ -371,6 +551,10 @@ export class ProxyDaemon {
|
|
|
371
551
|
this.wakeRunnerPending = false;
|
|
372
552
|
if (this.wakeRunnerResolve)
|
|
373
553
|
this.wakeRunnerResolve();
|
|
554
|
+
try {
|
|
555
|
+
await this.publishRecallVerifier.stop();
|
|
556
|
+
}
|
|
557
|
+
catch { /* best-effort verifier shutdown */ }
|
|
374
558
|
let stopError;
|
|
375
559
|
try {
|
|
376
560
|
await this.closeIpc();
|
|
@@ -390,7 +574,6 @@ export class ProxyDaemon {
|
|
|
390
574
|
catch (err) {
|
|
391
575
|
stopError = stopError ?? err;
|
|
392
576
|
}
|
|
393
|
-
this.started = false;
|
|
394
577
|
if (stopError)
|
|
395
578
|
throw stopError;
|
|
396
579
|
}
|
|
@@ -482,6 +665,7 @@ export class ProxyDaemon {
|
|
|
482
665
|
return { ok: false, reason: 'self_update_not_configured' };
|
|
483
666
|
const currentVersion = this.deps.selfUpdate.currentVersion ?? this.deps.evolverVersion ?? '0.0.0';
|
|
484
667
|
const originalTelemetry = this.deps.selfUpdate.onTelemetry;
|
|
668
|
+
const originalCleanupWarning = this.deps.selfUpdate.onCleanupWarning;
|
|
485
669
|
const selfUpdateDeps = {
|
|
486
670
|
...this.deps.selfUpdate,
|
|
487
671
|
currentVersion,
|
|
@@ -492,6 +676,15 @@ export class ProxyDaemon {
|
|
|
492
676
|
});
|
|
493
677
|
originalTelemetry?.(result);
|
|
494
678
|
},
|
|
679
|
+
onCleanupWarning: (warning, result) => {
|
|
680
|
+
try {
|
|
681
|
+
this.store.setState('self_update:last_cleanup_warning', safeDaemonMessage(`self_update_cleanup_warning:${warning}`, MAX_PROXY_TICK_ERROR_LENGTH));
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
// The returned result still carries cleanupWarning when operator state is unavailable.
|
|
685
|
+
}
|
|
686
|
+
originalCleanupWarning?.(warning, result);
|
|
687
|
+
},
|
|
495
688
|
};
|
|
496
689
|
return executeForceUpdate(directive, selfUpdateDeps);
|
|
497
690
|
}
|
|
@@ -568,14 +761,22 @@ export class ProxyDaemon {
|
|
|
568
761
|
return Number.isFinite(n) && n > 0 ? n : null;
|
|
569
762
|
}
|
|
570
763
|
async handleProxyRoute(ctx) {
|
|
571
|
-
|
|
764
|
+
const expectedHeader = singleHeader(ctx.req.headers['x-evomap-expected-hub-mode']);
|
|
765
|
+
if (hubModeMismatch(expectedHeader, this.deps.hubMode)) {
|
|
766
|
+
ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
|
|
572
767
|
return true;
|
|
768
|
+
}
|
|
573
769
|
const handledAtp = await this.handleAtpRoute(ctx);
|
|
574
770
|
if (handledAtp)
|
|
575
771
|
return true;
|
|
576
772
|
if (ctx.route === 'GET /proxy/status') {
|
|
577
773
|
ctx.json(200, {
|
|
578
774
|
running: true,
|
|
775
|
+
status: 'running',
|
|
776
|
+
proxy_protocol_version: PROXY_PROTOCOL_VERSION,
|
|
777
|
+
schema_version: PROXY_STATUS_SCHEMA_VERSION,
|
|
778
|
+
hub_mode: this.deps.hubMode ?? 'public',
|
|
779
|
+
runtime_namespace: this.deps.runtimeNamespace ?? 'default',
|
|
579
780
|
node_id: this.lifecycle.nodeId ?? null,
|
|
580
781
|
outbound_pending: this.store.countPending('proxy', this.deps.runtimeNamespace),
|
|
581
782
|
inbound_pending: this.store.countPending('agent', this.deps.runtimeNamespace) + this.store.countPending('core', this.deps.runtimeNamespace),
|
|
@@ -584,22 +785,45 @@ export class ProxyDaemon {
|
|
|
584
785
|
hub_auth_status: this.store.getState('hub:auth_status') || null,
|
|
585
786
|
reauth_backoff_until: this.stateNumber('lifecycle:reauth_until'),
|
|
586
787
|
hello_rate_limit_until: this.stateNumber('lifecycle:hello_rl_until'),
|
|
788
|
+
publish_recall_verify: this.publishRecallVerifier.status(),
|
|
587
789
|
});
|
|
588
790
|
return true;
|
|
589
791
|
}
|
|
590
792
|
if (ctx.route === 'POST /mailbox/poll') {
|
|
591
|
-
const body = (await ctx.readJson());
|
|
592
|
-
const limit =
|
|
593
|
-
|
|
594
|
-
.
|
|
595
|
-
|
|
596
|
-
|
|
793
|
+
const body = asRecord(await ctx.readJson());
|
|
794
|
+
const limit = boundedRequestLimit(body['limit'], 10, 50);
|
|
795
|
+
if (limit === undefined) {
|
|
796
|
+
ctx.json(400, { error: 'invalid_limit' });
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
const channel = typeof body['channel'] === 'string' ? body['channel'] : undefined;
|
|
800
|
+
const type = typeof body['type'] === 'string' && body['type'] ? body['type'] : undefined;
|
|
801
|
+
const runtimeNamespace = legacyMailboxRuntimeNamespace(channel, this.deps.runtimeNamespace ?? 'default');
|
|
802
|
+
const messages = runtimeNamespace === undefined
|
|
803
|
+
? []
|
|
804
|
+
: this.store.list({
|
|
805
|
+
status: 'pending',
|
|
806
|
+
direction: mailboxDirection(body['direction']) ?? 'inbound',
|
|
807
|
+
runtimeNamespace,
|
|
808
|
+
...(type ? { type } : {}),
|
|
809
|
+
limit,
|
|
810
|
+
}).map(mailbox.legacyMailboxMessage);
|
|
597
811
|
ctx.json(200, { messages, count: messages.length });
|
|
598
812
|
return true;
|
|
599
813
|
}
|
|
814
|
+
if (await this.collaborationFacade.handle(ctx))
|
|
815
|
+
return true;
|
|
600
816
|
if (ctx.route === 'POST /asset/search') {
|
|
601
817
|
const body = (await ctx.readJson());
|
|
602
|
-
|
|
818
|
+
if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
|
|
819
|
+
ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
|
|
820
|
+
return true;
|
|
821
|
+
}
|
|
822
|
+
const limit = boundedRequestLimit(body.limit, 5, 25);
|
|
823
|
+
if (limit === undefined) {
|
|
824
|
+
ctx.json(400, { error: 'invalid_limit' });
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
603
827
|
const rawSignals = Array.isArray(body.signals) ? body.signals : body.signalsAny;
|
|
604
828
|
const signalsAny = Array.isArray(rawSignals) ? rawSignals.filter((s) => typeof s === 'string') : undefined;
|
|
605
829
|
const kind = assetKind(body.kind);
|
|
@@ -619,8 +843,71 @@ export class ProxyDaemon {
|
|
|
619
843
|
ctx.json(200, { results, assets: results, query: body });
|
|
620
844
|
return true;
|
|
621
845
|
}
|
|
846
|
+
if (ctx.route === 'POST /recipe/search') {
|
|
847
|
+
const body = (await ctx.readJson());
|
|
848
|
+
if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
|
|
849
|
+
ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
|
|
850
|
+
return true;
|
|
851
|
+
}
|
|
852
|
+
const recipes = this.hub.recipes;
|
|
853
|
+
if (!recipes) {
|
|
854
|
+
ctx.json(501, { error: 'recipe_unsupported' });
|
|
855
|
+
return true;
|
|
856
|
+
}
|
|
857
|
+
const limit = boundedRequestLimit(body.limit, 10, 50);
|
|
858
|
+
if (limit === undefined) {
|
|
859
|
+
ctx.json(400, { error: 'invalid_limit' });
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
const q = [body.q, body.query, body.text].find((value) => typeof value === 'string' && value.trim().length > 0);
|
|
863
|
+
const cursor = typeof body.cursor === 'string' && body.cursor.trim() ? body.cursor.trim() : undefined;
|
|
864
|
+
const sort = typeof body.sort === 'string' && body.sort.trim() ? body.sort.trim() : undefined;
|
|
865
|
+
const request = {
|
|
866
|
+
...(q ? { q } : {}),
|
|
867
|
+
limit,
|
|
868
|
+
...(cursor ? { cursor } : {}),
|
|
869
|
+
...(sort ? { sort } : {}),
|
|
870
|
+
};
|
|
871
|
+
const receipt = q ? await recipes.search(request) : await recipes.list(request);
|
|
872
|
+
ctx.json(200, {
|
|
873
|
+
recipes: receipt.recipes,
|
|
874
|
+
...(receipt.nextCursor ? { nextCursor: receipt.nextCursor } : {}),
|
|
875
|
+
...(receipt.hasMore !== undefined ? { hasMore: receipt.hasMore } : {}),
|
|
876
|
+
query: body,
|
|
877
|
+
});
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
if (ctx.route === 'POST /recipe/express') {
|
|
881
|
+
const body = asRecord(await ctx.readJson());
|
|
882
|
+
if (hubModeMismatch(body['expected_hub_mode'], this.deps.hubMode)) {
|
|
883
|
+
ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
|
|
884
|
+
return true;
|
|
885
|
+
}
|
|
886
|
+
const recipes = this.hub.recipes;
|
|
887
|
+
if (!recipes) {
|
|
888
|
+
ctx.json(501, { error: 'recipe_unsupported' });
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
const recipeId = typeof body['recipe_id'] === 'string'
|
|
892
|
+
? body['recipe_id']
|
|
893
|
+
: typeof body['recipeId'] === 'string'
|
|
894
|
+
? body['recipeId']
|
|
895
|
+
: '';
|
|
896
|
+
if (!recipeId.trim()) {
|
|
897
|
+
ctx.json(400, { error: 'recipe_id_required' });
|
|
898
|
+
return true;
|
|
899
|
+
}
|
|
900
|
+
const inputPayload = asRecord(body['input_payload']) ?? asRecord(body['inputPayload']) ?? {};
|
|
901
|
+
const receipt = await recipes.express(recipeId.trim(), { inputPayload });
|
|
902
|
+
ctx.json(200, receipt);
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
622
905
|
if (ctx.route === 'POST /asset/fetch') {
|
|
623
906
|
const body = (await ctx.readJson());
|
|
907
|
+
if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
|
|
908
|
+
ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
624
911
|
const ids = uniqueStrings([
|
|
625
912
|
...(Array.isArray(body.asset_ids) ? body.asset_ids : []),
|
|
626
913
|
...(typeof body.asset_id === 'string' ? [body.asset_id] : []),
|
|
@@ -646,22 +933,82 @@ export class ProxyDaemon {
|
|
|
646
933
|
return true;
|
|
647
934
|
}
|
|
648
935
|
if (ctx.route === 'POST /asset/submit') {
|
|
649
|
-
const body = (await ctx.readJson());
|
|
650
|
-
if (
|
|
936
|
+
const body = asRecord(await ctx.readJson());
|
|
937
|
+
if (hubModeMismatch(body['expected_hub_mode'], this.deps.hubMode)) {
|
|
938
|
+
ctx.json(409, { stored: false, error: 'proxy_hub_mode_mismatch' });
|
|
939
|
+
return true;
|
|
940
|
+
}
|
|
941
|
+
const bundle = normalizeAssetSubmitBundle(body);
|
|
942
|
+
const legacyAssetId = typeof body['asset_id'] === 'string' && body['asset_id'].trim()
|
|
943
|
+
? body['asset_id'].trim()
|
|
944
|
+
: undefined;
|
|
945
|
+
if (!bundle && !legacyAssetId) {
|
|
651
946
|
ctx.json(400, { error: 'assets or asset_id is required' });
|
|
652
947
|
return true;
|
|
653
948
|
}
|
|
654
|
-
|
|
949
|
+
let outboundBundle = bundle;
|
|
950
|
+
if (!outboundBundle && legacyAssetId) {
|
|
951
|
+
if (!this.assetStore) {
|
|
952
|
+
ctx.json(503, { error: 'asset_store_unavailable' });
|
|
953
|
+
return true;
|
|
954
|
+
}
|
|
955
|
+
const resolved = await this.assetStore.get(legacyAssetId);
|
|
956
|
+
if (!resolved) {
|
|
957
|
+
ctx.json(404, { error: 'asset_not_found', asset_id: legacyAssetId });
|
|
958
|
+
return true;
|
|
959
|
+
}
|
|
960
|
+
outboundBundle = [resolved];
|
|
961
|
+
}
|
|
962
|
+
if (outboundBundle && outboundBundle.length > MAX_ASSET_SUBMIT_ITEMS) {
|
|
963
|
+
ctx.json(400, { error: `asset submit accepts at most ${MAX_ASSET_SUBMIT_ITEMS} items` });
|
|
964
|
+
return true;
|
|
965
|
+
}
|
|
966
|
+
const requestedMode = ctx.url.searchParams.get('mode');
|
|
967
|
+
const mode = requestedMode ?? (bundle ? 'sync' : 'async');
|
|
968
|
+
if (mode !== 'sync' && mode !== 'async') {
|
|
969
|
+
ctx.json(400, { error: 'mode must be sync or async' });
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
if (mode === 'sync') {
|
|
973
|
+
if (!bundle) {
|
|
974
|
+
ctx.json(400, { error: 'mode=sync requires a full asset bundle' });
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
await this.publishAssetSubmitSynchronously(ctx, outboundBundle, hubNs.recipeComposeRequested(body));
|
|
978
|
+
return true;
|
|
979
|
+
}
|
|
980
|
+
const payload = {
|
|
981
|
+
assets: outboundBundle,
|
|
982
|
+
compose_recipe: hubNs.recipeComposeRequested(body),
|
|
983
|
+
[OUTBOUND_HUB_MODE_FIELD]: this.currentHubMode(),
|
|
984
|
+
};
|
|
985
|
+
const requestId = typeof body['request_id'] === 'string' && ASYNC_ASSET_SUBMIT_REQUEST_ID.test(body['request_id'])
|
|
986
|
+
? body['request_id']
|
|
987
|
+
: undefined;
|
|
988
|
+
delete payload['request_id'];
|
|
989
|
+
const runtimeNamespace = this.deps.runtimeNamespace ?? 'default';
|
|
990
|
+
const stableId = requestId ? asyncAssetSubmitEnvelopeId(runtimeNamespace, requestId) : undefined;
|
|
991
|
+
const env = mailbox.createEnvelope({
|
|
992
|
+
...(stableId ? { id: stableId, idempotencyKey: stableId } : {}),
|
|
993
|
+
type: 'asset_submit',
|
|
994
|
+
payload,
|
|
995
|
+
runtimeNamespace,
|
|
996
|
+
now: ctx.now,
|
|
997
|
+
});
|
|
655
998
|
const r = this.store.send(env);
|
|
656
999
|
if (r.stored)
|
|
657
1000
|
this.notifyNewOutbound();
|
|
658
|
-
ctx.json(
|
|
1001
|
+
ctx.json(202, { id: env.id, message_id: env.id, receiptId: r.receiptId, status: 'pending', stored: r.stored });
|
|
659
1002
|
return true;
|
|
660
1003
|
}
|
|
661
1004
|
if (ctx.route === 'POST /asset/validate') {
|
|
662
1005
|
// Pre-publish dry-run against the hub's quality + content-safety gate (nothing stored, no credits).
|
|
663
1006
|
// Same {assets:[…]} bundle shape as /asset/submit; the adapter wraps it in a GEP-A2A envelope.
|
|
664
1007
|
const body = (await ctx.readJson());
|
|
1008
|
+
if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
|
|
1009
|
+
ctx.json(409, { valid: false, error: 'proxy_hub_mode_mismatch' });
|
|
1010
|
+
return true;
|
|
1011
|
+
}
|
|
665
1012
|
const bundle = Array.isArray(body.assets)
|
|
666
1013
|
? body.assets.filter((a) => Boolean(a && typeof a === 'object'))
|
|
667
1014
|
: (body.asset && typeof body.asset === 'object' && !Array.isArray(body.asset) ? [body.asset] : []);
|
|
@@ -687,7 +1034,12 @@ export class ProxyDaemon {
|
|
|
687
1034
|
return true;
|
|
688
1035
|
}
|
|
689
1036
|
if (ctx.route === 'POST /asset/reuse-result') {
|
|
690
|
-
const
|
|
1037
|
+
const body = await ctx.readJson();
|
|
1038
|
+
if (hubModeMismatch(asRecord(body)['expected_hub_mode'], this.deps.hubMode)) {
|
|
1039
|
+
ctx.json(409, { recorded: false, error: 'proxy_hub_mode_mismatch' });
|
|
1040
|
+
return true;
|
|
1041
|
+
}
|
|
1042
|
+
const parsed = parseReuseResultReport(body);
|
|
691
1043
|
if ('error' in parsed) {
|
|
692
1044
|
ctx.json(400, { recorded: false, error: parsed.error });
|
|
693
1045
|
return true;
|
|
@@ -706,16 +1058,78 @@ export class ProxyDaemon {
|
|
|
706
1058
|
}
|
|
707
1059
|
if (ctx.route === 'POST /conversation/distill') {
|
|
708
1060
|
const body = (await ctx.readJson());
|
|
709
|
-
const
|
|
1061
|
+
const publishRequested = body['publish'] === true;
|
|
1062
|
+
const abortPublish = () => {
|
|
1063
|
+
if (ctx.signal?.aborted)
|
|
1064
|
+
return true;
|
|
1065
|
+
if (!this.publishAbortController.signal.aborted)
|
|
1066
|
+
return false;
|
|
1067
|
+
// 守护进程停止时仍需结束开放的响应,否则 IPC server.close() 会等待该连接而无法完成停机。
|
|
1068
|
+
if (!ctx.res.destroyed && !ctx.res.writableEnded) {
|
|
1069
|
+
// 'blocked' (not 'failed'): the request was never queued, it was intercepted by
|
|
1070
|
+
// shutdown; the 503 + error field already explains why.
|
|
1071
|
+
ctx.json(503, { error: 'proxy_shutting_down', publish_status: 'blocked', queued: false });
|
|
1072
|
+
}
|
|
1073
|
+
return true;
|
|
1074
|
+
};
|
|
1075
|
+
if (hubModeMismatch(body.expected_hub_mode, this.deps.hubMode)) {
|
|
1076
|
+
ctx.json(409, { error: 'proxy_hub_mode_mismatch' });
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
// 请求已取消时禁止继续生成或持久化发布草稿,避免把取消前的陈旧状态留下。
|
|
1080
|
+
if (publishRequested && abortPublish())
|
|
1081
|
+
return true;
|
|
1082
|
+
const verifiedExecution = publishRequested
|
|
1083
|
+
? await resolveVerifiedExecutionAfterPreflight(this.deps.publishExecutionVerifier, body, this.deps.publishExecutionVerifierTimeoutMs, [this.publishAbortController.signal, ...(ctx.signal ? [ctx.signal] : [])])
|
|
1084
|
+
: undefined;
|
|
1085
|
+
if (publishRequested && abortPublish())
|
|
1086
|
+
return true;
|
|
1087
|
+
const distill = await hubNs.distillConversation(body, {
|
|
1088
|
+
// 调用方明确要求持久化时,即使发布被证据或质量闸门拦截,也保留可审查草稿;永不因此进入队列。
|
|
1089
|
+
persist: body.persist === true,
|
|
1090
|
+
store: this.assetStore,
|
|
1091
|
+
...(verifiedExecution ? { verifiedExecution } : {}),
|
|
1092
|
+
});
|
|
1093
|
+
if (publishRequested && abortPublish())
|
|
1094
|
+
return true;
|
|
710
1095
|
if (!distill.ok) {
|
|
711
|
-
ctx.json(200, {
|
|
1096
|
+
ctx.json(200, {
|
|
1097
|
+
...distill,
|
|
1098
|
+
queued: false,
|
|
1099
|
+
submission: null,
|
|
1100
|
+
publish_status: publishRequested ? 'blocked' : 'not_requested',
|
|
1101
|
+
});
|
|
712
1102
|
return true;
|
|
713
1103
|
}
|
|
714
1104
|
let submission = null;
|
|
715
|
-
if (
|
|
1105
|
+
if (publishRequested) {
|
|
1106
|
+
if (abortPublish())
|
|
1107
|
+
return true;
|
|
1108
|
+
// 未达到可复用质量阈值的蒸馏结果仍可作为有用草稿,
|
|
1109
|
+
// 但不得进入出站发布队列。安全与内容完整性闸门保持严格,质量负责晋级。
|
|
1110
|
+
if (distill.publishable !== true) {
|
|
1111
|
+
const reason = distill.quality.ok && verifiedExecution === undefined ? 'execution_evidence' : 'quality_gate';
|
|
1112
|
+
ctx.json(200, {
|
|
1113
|
+
...distill,
|
|
1114
|
+
queued: false,
|
|
1115
|
+
submission: null,
|
|
1116
|
+
publish_blocked: reason,
|
|
1117
|
+
publish_status: 'blocked',
|
|
1118
|
+
});
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
716
1121
|
const env = mailbox.createEnvelope({
|
|
717
1122
|
type: 'asset_submit',
|
|
718
|
-
payload: {
|
|
1123
|
+
payload: {
|
|
1124
|
+
source: 'conversation_distillation',
|
|
1125
|
+
distill_id: distill.distill_id,
|
|
1126
|
+
assets: [distill.gene, distill.capsule],
|
|
1127
|
+
compose_recipe: body['publish_recipe'] !== false,
|
|
1128
|
+
title: typeof body.title === 'string' ? body.title : undefined,
|
|
1129
|
+
description: typeof body.summary === 'string' ? body.summary : undefined,
|
|
1130
|
+
[OUTBOUND_HUB_MODE_FIELD]: this.currentHubMode(),
|
|
1131
|
+
},
|
|
1132
|
+
runtimeNamespace: this.deps.runtimeNamespace ?? 'default',
|
|
719
1133
|
now: ctx.now,
|
|
720
1134
|
});
|
|
721
1135
|
const r = this.store.send(env);
|
|
@@ -723,7 +1137,12 @@ export class ProxyDaemon {
|
|
|
723
1137
|
this.notifyNewOutbound();
|
|
724
1138
|
submission = { id: env.id, message_id: env.id, receiptId: r.receiptId, status: 'pending', stored: r.stored };
|
|
725
1139
|
}
|
|
726
|
-
ctx.json(200, {
|
|
1140
|
+
ctx.json(200, {
|
|
1141
|
+
...distill,
|
|
1142
|
+
queued: submission !== null,
|
|
1143
|
+
submission,
|
|
1144
|
+
publish_status: publishRequested ? 'queued' : 'not_requested',
|
|
1145
|
+
});
|
|
727
1146
|
return true;
|
|
728
1147
|
}
|
|
729
1148
|
if (ctx.route === 'POST /agent/search') {
|
|
@@ -789,7 +1208,7 @@ export class ProxyDaemon {
|
|
|
789
1208
|
const localSafe = local.filter((asset) => asset.type !== 'AntiGene');
|
|
790
1209
|
let remote;
|
|
791
1210
|
try {
|
|
792
|
-
remote =
|
|
1211
|
+
remote = await this.searchRemoteAssets(query, limit);
|
|
793
1212
|
}
|
|
794
1213
|
catch (error) {
|
|
795
1214
|
if (localSafe.length === 0)
|
|
@@ -813,6 +1232,399 @@ export class ProxyDaemon {
|
|
|
813
1232
|
}
|
|
814
1233
|
return out;
|
|
815
1234
|
}
|
|
1235
|
+
async searchRemoteAssets(query, limit) {
|
|
1236
|
+
const key = assetSearchCacheKey(this.deps.runtimeNamespace, query, limit);
|
|
1237
|
+
const now = this.now();
|
|
1238
|
+
const cached = this.assetSearchCache.get(key);
|
|
1239
|
+
if (cached && cached.expiresAt > now)
|
|
1240
|
+
return cached.value;
|
|
1241
|
+
if (now < this.assetSearchCooldownUntil) {
|
|
1242
|
+
if (cached && cached.staleUntil > now)
|
|
1243
|
+
return cached.value;
|
|
1244
|
+
if (cached)
|
|
1245
|
+
this.assetSearchCache.delete(key);
|
|
1246
|
+
throw new HubClientError(429, { error: 'rate_limited', source: 'asset_search_client_cooldown' }, this.assetSearchCooldownUntil - now);
|
|
1247
|
+
}
|
|
1248
|
+
const inflight = this.assetSearchInflight.get(key);
|
|
1249
|
+
if (inflight)
|
|
1250
|
+
return inflight;
|
|
1251
|
+
const request = (async () => {
|
|
1252
|
+
try {
|
|
1253
|
+
const value = (await this.deps.hub.search(query))
|
|
1254
|
+
.filter((asset) => asset.type !== 'AntiGene')
|
|
1255
|
+
.slice(0, limit);
|
|
1256
|
+
this.cacheRemoteAssetSearch(key, value, this.now());
|
|
1257
|
+
return value;
|
|
1258
|
+
}
|
|
1259
|
+
catch (error) {
|
|
1260
|
+
const retryAfterMs = assetSearchRetryAfterMs(error, this.assetSearchCacheTtlMs);
|
|
1261
|
+
if (retryAfterMs !== undefined) {
|
|
1262
|
+
const rateLimitedAt = this.now();
|
|
1263
|
+
this.assetSearchCooldownUntil = Math.max(this.assetSearchCooldownUntil, rateLimitedAt + retryAfterMs);
|
|
1264
|
+
const stale = this.assetSearchCache.get(key);
|
|
1265
|
+
if (stale && stale.staleUntil > rateLimitedAt)
|
|
1266
|
+
return stale.value;
|
|
1267
|
+
}
|
|
1268
|
+
throw error;
|
|
1269
|
+
}
|
|
1270
|
+
})();
|
|
1271
|
+
this.assetSearchInflight.set(key, request);
|
|
1272
|
+
const clearInflight = () => {
|
|
1273
|
+
if (this.assetSearchInflight.get(key) === request)
|
|
1274
|
+
this.assetSearchInflight.delete(key);
|
|
1275
|
+
};
|
|
1276
|
+
void request.then(clearInflight, clearInflight);
|
|
1277
|
+
return request;
|
|
1278
|
+
}
|
|
1279
|
+
cacheRemoteAssetSearch(key, value, now) {
|
|
1280
|
+
if (this.assetSearchCache.size >= this.assetSearchCacheMax && !this.assetSearchCache.has(key)) {
|
|
1281
|
+
const oldest = this.assetSearchCache.keys().next().value;
|
|
1282
|
+
if (oldest !== undefined)
|
|
1283
|
+
this.assetSearchCache.delete(oldest);
|
|
1284
|
+
}
|
|
1285
|
+
this.assetSearchCache.delete(key);
|
|
1286
|
+
this.assetSearchCache.set(key, {
|
|
1287
|
+
value,
|
|
1288
|
+
expiresAt: now + this.assetSearchCacheTtlMs,
|
|
1289
|
+
staleUntil: now + this.assetSearchCacheTtlMs + this.assetSearchStaleGraceMs,
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
async publishAssetSubmitSynchronously(ctx, items, composeRecipe = true) {
|
|
1293
|
+
const abortSynchronousPublish = () => {
|
|
1294
|
+
if (ctx.signal?.aborted)
|
|
1295
|
+
return true;
|
|
1296
|
+
if (!this.publishAbortController.signal.aborted)
|
|
1297
|
+
return false;
|
|
1298
|
+
if (!ctx.res.destroyed && !ctx.res.writableEnded) {
|
|
1299
|
+
ctx.json(503, { error: 'proxy_shutting_down', publish_status: 'failed', queued: false });
|
|
1300
|
+
}
|
|
1301
|
+
return true;
|
|
1302
|
+
};
|
|
1303
|
+
const classified = classifySynchronousAssetSubmit(items);
|
|
1304
|
+
if (!classified.ok) {
|
|
1305
|
+
ctx.json(422, { error: classified.error, code: 'invalid_asset_submit' });
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
if (classified.kind === 'wire') {
|
|
1309
|
+
if (abortSynchronousPublish())
|
|
1310
|
+
return;
|
|
1311
|
+
const envelope = this.createSynchronousAssetSubmitEnvelope(classified.bundle, undefined, ctx.now, composeRecipe);
|
|
1312
|
+
this.writeSynchronousAssetSubmitOutcome(ctx, await this.publishSynchronousBundle(envelope));
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
const results = [];
|
|
1316
|
+
for (const item of classified.items) {
|
|
1317
|
+
const converted = await convertLegacyLooseAsset(item, this.deps.publishExecutionVerifier, this.deps.publishExecutionVerifierTimeoutMs, ctx.signal, this.publishAbortController.signal);
|
|
1318
|
+
if (!converted.ok) {
|
|
1319
|
+
results.push({ ok: false, error: converted.error, statusCode: 422 });
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
if (abortSynchronousPublish())
|
|
1323
|
+
return;
|
|
1324
|
+
const envelope = this.createSynchronousAssetSubmitEnvelope(converted.bundle, 'v1_loose_asset_compat', ctx.now, composeRecipe);
|
|
1325
|
+
const outcome = await this.publishSynchronousBundle(envelope);
|
|
1326
|
+
if (outcome.kind === 'accepted') {
|
|
1327
|
+
const receipt = outcome.receipt;
|
|
1328
|
+
const publishedIds = submittedAssetIds(receipt, converted.bundle);
|
|
1329
|
+
results.push({
|
|
1330
|
+
ok: true,
|
|
1331
|
+
gene_asset_id: publishedIds[0],
|
|
1332
|
+
capsule_asset_id: publishedIds[1],
|
|
1333
|
+
response: receipt,
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
else if (outcome.kind === 'failed') {
|
|
1337
|
+
results.push({
|
|
1338
|
+
ok: false,
|
|
1339
|
+
error: String(outcome.body['error']),
|
|
1340
|
+
statusCode: outcome.statusCode,
|
|
1341
|
+
...(typeof outcome.body['reason'] === 'string' ? { reason: outcome.body['reason'] } : {}),
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
results.push({
|
|
1346
|
+
ok: false,
|
|
1347
|
+
error: 'publish_pending',
|
|
1348
|
+
statusCode: 202,
|
|
1349
|
+
reason: `durable recovery pending (${outcome.messageId})`,
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
const summary = summarizeLegacySynchronousResults(results);
|
|
1354
|
+
ctx.json(200, {
|
|
1355
|
+
published: results.filter((result) => result.ok).length,
|
|
1356
|
+
total: results.length,
|
|
1357
|
+
results,
|
|
1358
|
+
publish_status: summary.publishStatus,
|
|
1359
|
+
queued: summary.queued,
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
createSynchronousAssetSubmitEnvelope(bundle, source, now, composeRecipe = true) {
|
|
1363
|
+
const canonicalBundle = [...bundle].sort(compareSynchronousAssetSubmitAssets);
|
|
1364
|
+
const runtimeNamespace = this.deps.runtimeNamespace ?? 'default';
|
|
1365
|
+
const idempotencyKey = synchronousAssetSubmitKey(this.synchronousAssetSubmitScope, runtimeNamespace, this.currentHubMode(), canonicalBundle);
|
|
1366
|
+
return mailbox.createEnvelope({
|
|
1367
|
+
id: `compat:asset_submit:${idempotencyKey.slice(SYNC_ASSET_SUBMIT_PREFIX.length)}`,
|
|
1368
|
+
type: 'asset_submit',
|
|
1369
|
+
payload: {
|
|
1370
|
+
...(source ? { source } : {}),
|
|
1371
|
+
assets: canonicalBundle,
|
|
1372
|
+
compose_recipe: composeRecipe,
|
|
1373
|
+
[OUTBOUND_HUB_MODE_FIELD]: this.currentHubMode(),
|
|
1374
|
+
},
|
|
1375
|
+
idempotencyKey,
|
|
1376
|
+
runtimeNamespace,
|
|
1377
|
+
now,
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
composeRecipeAfterAcceptedSubmit(envelope) {
|
|
1381
|
+
if (envelope.type !== 'asset_submit')
|
|
1382
|
+
return;
|
|
1383
|
+
const key = envelope.idempotencyKey || envelope.id;
|
|
1384
|
+
if (this.recipeComposeStarted.has(key))
|
|
1385
|
+
return;
|
|
1386
|
+
this.recipeComposeStarted.add(key);
|
|
1387
|
+
void hubNs.composeRecipeAfterAssetPublish(this.hub, asRecord(envelope.payload)).catch(() => { });
|
|
1388
|
+
}
|
|
1389
|
+
currentHubMode() {
|
|
1390
|
+
return this.deps.hubMode ?? 'public';
|
|
1391
|
+
}
|
|
1392
|
+
handleHubModeBoundOutbound(envelope) {
|
|
1393
|
+
if (envelope.type !== 'asset_submit')
|
|
1394
|
+
return this.handleSynchronousProxyOutbound(envelope);
|
|
1395
|
+
const payload = asRecord(envelope.payload);
|
|
1396
|
+
const rawQueuedMode = payload[OUTBOUND_HUB_MODE_FIELD];
|
|
1397
|
+
const queuedMode = rawQueuedMode === undefined ? 'public' : String(rawQueuedMode);
|
|
1398
|
+
const currentMode = this.currentHubMode();
|
|
1399
|
+
if ((queuedMode !== 'public' && queuedMode !== 'private') || queuedMode !== currentMode) {
|
|
1400
|
+
throw new OutboundHubModeMismatchError(queuedMode, currentMode);
|
|
1401
|
+
}
|
|
1402
|
+
const outboundPayload = { ...payload };
|
|
1403
|
+
delete outboundPayload[OUTBOUND_HUB_MODE_FIELD];
|
|
1404
|
+
return this.handleSynchronousProxyOutbound({ ...envelope, payload: outboundPayload });
|
|
1405
|
+
}
|
|
1406
|
+
async publishSynchronousBundle(envelope) {
|
|
1407
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1408
|
+
if (cached)
|
|
1409
|
+
return cached;
|
|
1410
|
+
const inflight = this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey);
|
|
1411
|
+
if (inflight)
|
|
1412
|
+
return this.waitForSynchronousAssetSubmit(inflight, envelope.id);
|
|
1413
|
+
const { stored } = this.store.send(envelope);
|
|
1414
|
+
const cachedAfterInsert = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1415
|
+
if (cachedAfterInsert)
|
|
1416
|
+
return cachedAfterInsert;
|
|
1417
|
+
if (!stored) {
|
|
1418
|
+
const existingInflight = this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey);
|
|
1419
|
+
return existingInflight
|
|
1420
|
+
? this.waitForSynchronousAssetSubmit(existingInflight, envelope.id)
|
|
1421
|
+
: { kind: 'pending', messageId: envelope.id };
|
|
1422
|
+
}
|
|
1423
|
+
this.store.defer(envelope.id, 'synchronous asset submit attempt in progress', this.now(), SYNC_ASSET_SUBMIT_DIRECT_RETRY_GRACE_MS);
|
|
1424
|
+
this.notifyNewOutbound();
|
|
1425
|
+
const request = this.executeSynchronousAssetSubmit(envelope);
|
|
1426
|
+
this.synchronousAssetSubmitInflight.set(envelope.idempotencyKey, request);
|
|
1427
|
+
void request.finally(() => {
|
|
1428
|
+
if (this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey) === request) {
|
|
1429
|
+
this.synchronousAssetSubmitInflight.delete(envelope.idempotencyKey);
|
|
1430
|
+
}
|
|
1431
|
+
}).catch(() => { });
|
|
1432
|
+
return this.waitForSynchronousAssetSubmit(request, envelope.id);
|
|
1433
|
+
}
|
|
1434
|
+
waitForSynchronousAssetSubmit(request, messageId) {
|
|
1435
|
+
return new Promise((resolve, reject) => {
|
|
1436
|
+
const timeout = setTimeout(() => {
|
|
1437
|
+
resolve({ kind: 'pending', messageId });
|
|
1438
|
+
}, this.assetSubmitResponseTimeoutMs);
|
|
1439
|
+
timeout.unref?.();
|
|
1440
|
+
void request.then((outcome) => {
|
|
1441
|
+
clearTimeout(timeout);
|
|
1442
|
+
resolve(outcome);
|
|
1443
|
+
}, (error) => {
|
|
1444
|
+
clearTimeout(timeout);
|
|
1445
|
+
reject(error);
|
|
1446
|
+
});
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
async executeSynchronousAssetSubmit(envelope) {
|
|
1450
|
+
try {
|
|
1451
|
+
const receipt = await this.proxyHandler(envelope);
|
|
1452
|
+
const firstObservation = this.cacheSynchronousAssetSubmitSuccess(envelope, receipt);
|
|
1453
|
+
if (firstObservation) {
|
|
1454
|
+
try {
|
|
1455
|
+
this.publishRecallVerifier.observeAcceptedPublish(envelope, receipt);
|
|
1456
|
+
}
|
|
1457
|
+
catch { /* best-effort */ }
|
|
1458
|
+
this.composeRecipeAfterAcceptedSubmit(envelope);
|
|
1459
|
+
}
|
|
1460
|
+
if (this.store.getById(envelope.id)?.status !== 'in_flight')
|
|
1461
|
+
this.store.complete(envelope.id, this.now());
|
|
1462
|
+
return { kind: 'accepted', receipt };
|
|
1463
|
+
}
|
|
1464
|
+
catch (error) {
|
|
1465
|
+
const current = this.store.getById(envelope.id);
|
|
1466
|
+
const currentStatus = this.store.getStatus(envelope.id);
|
|
1467
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1468
|
+
// Once acceptance is durable, later publish or local-finalization errors cannot turn the external outcome
|
|
1469
|
+
// into a failure. A late acceptance may also need to recover an intent that a racing rejection put in DLQ.
|
|
1470
|
+
if (cached?.kind === 'accepted') {
|
|
1471
|
+
if (currentStatus?.dlq) {
|
|
1472
|
+
try {
|
|
1473
|
+
this.store.replayDlq(envelope.id, this.now());
|
|
1474
|
+
this.notifyNewOutbound();
|
|
1475
|
+
}
|
|
1476
|
+
catch (recoveryError) {
|
|
1477
|
+
this.recordTickError('outbound', recoveryError);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
return cached;
|
|
1481
|
+
}
|
|
1482
|
+
const failure = mapSynchronousPublishFailure(error);
|
|
1483
|
+
const outcome = { kind: 'failed', ...failure, error };
|
|
1484
|
+
if (this.shadowMode)
|
|
1485
|
+
return outcome;
|
|
1486
|
+
if (current?.status !== 'in_flight') {
|
|
1487
|
+
const message = safeDaemonMessage(JSON.stringify(failure.body), MAX_PROXY_TICK_ERROR_LENGTH);
|
|
1488
|
+
const outcomeKey = synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey);
|
|
1489
|
+
const acceptedKey = synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey);
|
|
1490
|
+
const transitionNow = this.now();
|
|
1491
|
+
const terminal = isTerminalSynchronousPublishFailure(error);
|
|
1492
|
+
const transitioned = terminal
|
|
1493
|
+
? this.store.failAndMarkProcessedUnlessProcessed(envelope.id, [acceptedKey], outcomeKey, { kind: 'failed', ...failure }, message, transitionNow, 1)
|
|
1494
|
+
: isRetryableSynchronousPublishFailure(error)
|
|
1495
|
+
? this.store.deferUnlessProcessed(envelope.id, acceptedKey, message, transitionNow, synchronousPublishRetryAfterMs(error, failure))
|
|
1496
|
+
: this.store.failUnlessProcessed(envelope.id, acceptedKey, message, transitionNow);
|
|
1497
|
+
if (!transitioned) {
|
|
1498
|
+
const persisted = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1499
|
+
if (persisted)
|
|
1500
|
+
return persisted;
|
|
1501
|
+
}
|
|
1502
|
+
if (terminal) {
|
|
1503
|
+
const persisted = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1504
|
+
if (persisted?.kind === 'accepted')
|
|
1505
|
+
return persisted;
|
|
1506
|
+
}
|
|
1507
|
+
this.notifyNewOutbound();
|
|
1508
|
+
}
|
|
1509
|
+
return outcome;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
async handleSynchronousProxyOutbound(envelope) {
|
|
1513
|
+
if (!isSynchronousAssetSubmitEnvelope(envelope))
|
|
1514
|
+
return this.proxyHandler(envelope);
|
|
1515
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1516
|
+
if (cached?.kind === 'accepted')
|
|
1517
|
+
return cached.receipt;
|
|
1518
|
+
if (cached?.kind === 'failed')
|
|
1519
|
+
throw cachedSynchronousAssetSubmitFailure(cached);
|
|
1520
|
+
const inflight = this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey);
|
|
1521
|
+
if (inflight) {
|
|
1522
|
+
const outcome = await this.waitForSynchronousAssetSubmit(inflight, envelope.id);
|
|
1523
|
+
if (outcome.kind === 'accepted')
|
|
1524
|
+
return outcome.receipt;
|
|
1525
|
+
if (outcome.kind === 'failed')
|
|
1526
|
+
throw outcome.error ?? cachedSynchronousAssetSubmitFailure(outcome);
|
|
1527
|
+
if (this.synchronousAssetSubmitInflight.get(envelope.idempotencyKey) === inflight) {
|
|
1528
|
+
this.synchronousAssetSubmitInflight.delete(envelope.idempotencyKey);
|
|
1529
|
+
}
|
|
1530
|
+
throw new HubUnreachableError('synchronous asset submit is still pending');
|
|
1531
|
+
}
|
|
1532
|
+
try {
|
|
1533
|
+
const receipt = await this.proxyHandler(envelope);
|
|
1534
|
+
const firstObservation = this.cacheSynchronousAssetSubmitSuccess(envelope, receipt);
|
|
1535
|
+
if (firstObservation) {
|
|
1536
|
+
try {
|
|
1537
|
+
this.publishRecallVerifier.observeAcceptedPublish(envelope, receipt);
|
|
1538
|
+
}
|
|
1539
|
+
catch { /* best-effort */ }
|
|
1540
|
+
this.composeRecipeAfterAcceptedSubmit(envelope);
|
|
1541
|
+
}
|
|
1542
|
+
return receipt;
|
|
1543
|
+
}
|
|
1544
|
+
catch (error) {
|
|
1545
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1546
|
+
if (cached?.kind === 'accepted')
|
|
1547
|
+
return cached.receipt;
|
|
1548
|
+
if (isTerminalSynchronousPublishFailure(error))
|
|
1549
|
+
this.cacheSynchronousAssetSubmitTerminal(envelope, error);
|
|
1550
|
+
throw error;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
readSynchronousAssetSubmitOutcome(envelope) {
|
|
1554
|
+
if (this.shadowMode || !isSynchronousAssetSubmitEnvelope(envelope))
|
|
1555
|
+
return undefined;
|
|
1556
|
+
const outcomeKey = synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey);
|
|
1557
|
+
const value = asRecord(this.store.getProcessed(outcomeKey));
|
|
1558
|
+
if (value['kind'] === 'accepted' && Object.prototype.hasOwnProperty.call(value, 'receipt')) {
|
|
1559
|
+
const receipt = asRecord(value['receipt']);
|
|
1560
|
+
if (receipt['bundleId'] === 'shadow-bundle'
|
|
1561
|
+
&& typeof receipt['receiptId'] === 'string'
|
|
1562
|
+
&& receipt['receiptId'].startsWith('shadow-')) {
|
|
1563
|
+
this.store.deleteProcessed([
|
|
1564
|
+
outcomeKey,
|
|
1565
|
+
synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey),
|
|
1566
|
+
]);
|
|
1567
|
+
return undefined;
|
|
1568
|
+
}
|
|
1569
|
+
const backfilled = this.store.markProcessedIf(outcomeKey, synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey), { accepted: true }, this.now(), (current) => {
|
|
1570
|
+
const record = asRecord(current);
|
|
1571
|
+
return record['kind'] === 'accepted' && Object.prototype.hasOwnProperty.call(record, 'receipt');
|
|
1572
|
+
});
|
|
1573
|
+
if (!backfilled)
|
|
1574
|
+
return this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1575
|
+
return { kind: 'accepted', receipt: value['receipt'] };
|
|
1576
|
+
}
|
|
1577
|
+
if (value['kind'] === 'failed') {
|
|
1578
|
+
const statusCode = positiveFiniteNumber(value['statusCode']);
|
|
1579
|
+
if (statusCode !== undefined && isRecordValue(value['body'])) {
|
|
1580
|
+
return { kind: 'failed', statusCode, body: value['body'] };
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
return undefined;
|
|
1584
|
+
}
|
|
1585
|
+
cacheSynchronousAssetSubmitSuccess(envelope, receipt) {
|
|
1586
|
+
if (this.shadowMode || !isSynchronousAssetSubmitEnvelope(envelope))
|
|
1587
|
+
return undefined;
|
|
1588
|
+
const key = synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey);
|
|
1589
|
+
const acceptedKey = synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey);
|
|
1590
|
+
const cached = this.readSynchronousAssetSubmitOutcome(envelope);
|
|
1591
|
+
if (cached?.kind === 'accepted')
|
|
1592
|
+
return false;
|
|
1593
|
+
// Acceptance is monotonic: a concurrent attempt may reject after another request reached the Hub, but a
|
|
1594
|
+
// real acceptance must supersede an earlier rejection so replay reflects the economic side effect.
|
|
1595
|
+
this.store.replaceProcessedWithMarker(key, { kind: 'accepted', receipt }, acceptedKey, { accepted: true }, this.now());
|
|
1596
|
+
return true;
|
|
1597
|
+
}
|
|
1598
|
+
cacheSynchronousAssetSubmitTerminal(envelope, error) {
|
|
1599
|
+
if (this.shadowMode || !isSynchronousAssetSubmitEnvelope(envelope))
|
|
1600
|
+
return;
|
|
1601
|
+
if (this.store.isProcessed(synchronousAssetSubmitAcceptanceKey(envelope.idempotencyKey)))
|
|
1602
|
+
return;
|
|
1603
|
+
if (this.readSynchronousAssetSubmitOutcome(envelope)?.kind === 'accepted')
|
|
1604
|
+
return;
|
|
1605
|
+
const failure = mapSynchronousPublishFailure(error);
|
|
1606
|
+
this.store.markProcessed(synchronousAssetSubmitOutcomeKey(envelope.idempotencyKey), {
|
|
1607
|
+
kind: 'failed',
|
|
1608
|
+
...failure,
|
|
1609
|
+
}, this.now());
|
|
1610
|
+
}
|
|
1611
|
+
writeSynchronousAssetSubmitOutcome(ctx, outcome) {
|
|
1612
|
+
if (outcome.kind === 'accepted') {
|
|
1613
|
+
ctx.json(200, withSynchronousPublishStatus(outcome.receipt, 'accepted'));
|
|
1614
|
+
}
|
|
1615
|
+
else if (outcome.kind === 'failed') {
|
|
1616
|
+
ctx.json(outcome.statusCode, withSynchronousPublishStatus(outcome.body, 'failed'));
|
|
1617
|
+
}
|
|
1618
|
+
else {
|
|
1619
|
+
ctx.json(202, {
|
|
1620
|
+
status: 'pending',
|
|
1621
|
+
message_id: outcome.messageId,
|
|
1622
|
+
durable: true,
|
|
1623
|
+
publish_status: 'pending',
|
|
1624
|
+
queued: true,
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
816
1628
|
async handleAtpRoute(ctx) {
|
|
817
1629
|
if (!ctx.url.pathname.startsWith('/atp/'))
|
|
818
1630
|
return false;
|
|
@@ -912,7 +1724,17 @@ function isValidator(value) {
|
|
|
912
1724
|
return Boolean(value && typeof value === 'object' && typeof value.validate === 'function');
|
|
913
1725
|
}
|
|
914
1726
|
function assetMatchesId(asset, assetId) {
|
|
915
|
-
|
|
1727
|
+
if (!asset)
|
|
1728
|
+
return false;
|
|
1729
|
+
return assetId.startsWith('sha256:')
|
|
1730
|
+
? asset.asset_id === assetId
|
|
1731
|
+
: asset.asset_id === assetId || asset['id'] === assetId;
|
|
1732
|
+
}
|
|
1733
|
+
function hubModeMismatch(expected, actual) {
|
|
1734
|
+
return expected !== undefined && expected !== (actual ?? 'public');
|
|
1735
|
+
}
|
|
1736
|
+
function singleHeader(value) {
|
|
1737
|
+
return Array.isArray(value) ? value[0] : value;
|
|
916
1738
|
}
|
|
917
1739
|
function uniqueStrings(values) {
|
|
918
1740
|
const seen = new Set();
|
|
@@ -925,12 +1747,545 @@ function uniqueStrings(values) {
|
|
|
925
1747
|
}
|
|
926
1748
|
return out;
|
|
927
1749
|
}
|
|
1750
|
+
function assetSearchCacheKey(runtimeNamespace, query, limit) {
|
|
1751
|
+
return JSON.stringify({
|
|
1752
|
+
runtimeNamespace: runtimeNamespace ?? 'default',
|
|
1753
|
+
kind: query.kind ?? null,
|
|
1754
|
+
signalsAny: uniqueStrings(query.signalsAny ?? []).sort(),
|
|
1755
|
+
category: query.category ?? null,
|
|
1756
|
+
gene: query.gene ?? null,
|
|
1757
|
+
text: query.text ?? null,
|
|
1758
|
+
limit,
|
|
1759
|
+
});
|
|
1760
|
+
}
|
|
1761
|
+
function assetSearchRetryAfterMs(error, fallbackMs) {
|
|
1762
|
+
const structured = asRecord(error);
|
|
1763
|
+
const details = asRecord(structured['details']);
|
|
1764
|
+
const structuredStatus = structured['statusCode']
|
|
1765
|
+
?? structured['status']
|
|
1766
|
+
?? details['statusCode']
|
|
1767
|
+
?? details['status'];
|
|
1768
|
+
const status = error instanceof HubClientError
|
|
1769
|
+
? error.status
|
|
1770
|
+
: (typeof structuredStatus === 'number'
|
|
1771
|
+
? structuredStatus
|
|
1772
|
+
: (typeof structuredStatus === 'string' ? Number(structuredStatus) : NaN));
|
|
1773
|
+
if (status !== 429)
|
|
1774
|
+
return undefined;
|
|
1775
|
+
const body = asRecord(error instanceof HubClientError ? error.body : structured['body']);
|
|
1776
|
+
const retryAfterMs = positiveFiniteNumber(error instanceof HubClientError
|
|
1777
|
+
? error.retryAfterMs
|
|
1778
|
+
: structured['retryAfterMs'] ?? details['retryAfterMs']) ?? positiveFiniteNumber(body['retry_after_ms'] ?? body['retryAfterMs']);
|
|
1779
|
+
const retryAfterSeconds = positiveFiniteNumber(body['retry_after'] ?? body['retryAfter']);
|
|
1780
|
+
return Math.floor(Math.min(retryAfterMs ?? (retryAfterSeconds !== undefined ? retryAfterSeconds * 1_000 : fallbackMs), MAX_TIMER_DELAY_MS));
|
|
1781
|
+
}
|
|
1782
|
+
function positiveFiniteNumber(value) {
|
|
1783
|
+
const parsed = typeof value === 'number'
|
|
1784
|
+
? value
|
|
1785
|
+
: (typeof value === 'string' && value.trim().length > 0 ? Number(value) : NaN);
|
|
1786
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
1787
|
+
}
|
|
1788
|
+
function positiveIntegerOr(value, fallback) {
|
|
1789
|
+
const parsed = positiveFiniteNumber(value);
|
|
1790
|
+
return parsed === undefined ? fallback : Math.max(1, Math.floor(parsed));
|
|
1791
|
+
}
|
|
1792
|
+
function boundedRequestLimit(value, fallback, maximum) {
|
|
1793
|
+
if (value === undefined)
|
|
1794
|
+
return fallback;
|
|
1795
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0)
|
|
1796
|
+
return undefined;
|
|
1797
|
+
return Math.min(value, maximum);
|
|
1798
|
+
}
|
|
1799
|
+
function legacyMailboxRuntimeNamespace(requestedChannel, runtimeNamespace) {
|
|
1800
|
+
if (requestedChannel === undefined || requestedChannel === 'evomap-hub' || requestedChannel === runtimeNamespace) {
|
|
1801
|
+
return runtimeNamespace;
|
|
1802
|
+
}
|
|
1803
|
+
return undefined;
|
|
1804
|
+
}
|
|
1805
|
+
function mailboxDirection(value) {
|
|
1806
|
+
return value === 'inbound' || value === 'outbound' || value === 'local' ? value : undefined;
|
|
1807
|
+
}
|
|
928
1808
|
function assetKind(value) {
|
|
929
1809
|
return value === 'Gene' || value === 'Capsule' || value === 'EvolutionEvent' || value === 'AntiGene' ? value : undefined;
|
|
930
1810
|
}
|
|
931
1811
|
function asRecord(value) {
|
|
932
1812
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
933
1813
|
}
|
|
1814
|
+
function normalizeAssetSubmitBundle(body) {
|
|
1815
|
+
if (Object.prototype.hasOwnProperty.call(body, 'assets')) {
|
|
1816
|
+
const assets = body['assets'];
|
|
1817
|
+
return Array.isArray(assets) && assets.length > 0 && assets.every(isNonEmptyAssetRecord)
|
|
1818
|
+
? assets
|
|
1819
|
+
: null;
|
|
1820
|
+
}
|
|
1821
|
+
return isNonEmptyAssetRecord(body['asset']) ? [body['asset']] : null;
|
|
1822
|
+
}
|
|
1823
|
+
function isNonEmptyAssetRecord(value) {
|
|
1824
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0);
|
|
1825
|
+
}
|
|
1826
|
+
function classifySynchronousAssetSubmit(items) {
|
|
1827
|
+
const wireLooking = items.map(isWireLookingAsset);
|
|
1828
|
+
const legacyLoose = items.map(isClearlyLegacyLooseAsset);
|
|
1829
|
+
if (wireLooking.every(Boolean)) {
|
|
1830
|
+
const bundle = [];
|
|
1831
|
+
for (let index = 0; index < items.length; index += 1) {
|
|
1832
|
+
const item = items[index];
|
|
1833
|
+
if (!wire.validateWire(item).ok) {
|
|
1834
|
+
return { ok: false, error: `asset ${index}: malformed V2 wire asset` };
|
|
1835
|
+
}
|
|
1836
|
+
try {
|
|
1837
|
+
const normalized = assetstore.normalizeForPut(item);
|
|
1838
|
+
if (!normalized.verified) {
|
|
1839
|
+
return { ok: false, error: `asset ${index}: a verified content-addressed asset_id is required` };
|
|
1840
|
+
}
|
|
1841
|
+
bundle.push(normalized.record);
|
|
1842
|
+
}
|
|
1843
|
+
catch {
|
|
1844
|
+
return { ok: false, error: `asset ${index}: asset_id does not match its content` };
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return { ok: true, kind: 'wire', bundle };
|
|
1848
|
+
}
|
|
1849
|
+
if (legacyLoose.every(Boolean))
|
|
1850
|
+
return { ok: true, kind: 'legacy', items };
|
|
1851
|
+
if (wireLooking.some(Boolean) && legacyLoose.some(Boolean)) {
|
|
1852
|
+
return { ok: false, error: 'wire assets and legacy loose assets cannot be mixed in one request' };
|
|
1853
|
+
}
|
|
1854
|
+
if (wireLooking.some(Boolean)) {
|
|
1855
|
+
return { ok: false, error: 'all wire-looking items must be valid content-addressed V2 assets' };
|
|
1856
|
+
}
|
|
1857
|
+
return { ok: false, error: 'unsupported asset input; provide V2 wire assets or legacy content/summary/strategy' };
|
|
1858
|
+
}
|
|
1859
|
+
function isWireLookingAsset(value) {
|
|
1860
|
+
return Object.prototype.hasOwnProperty.call(value, 'schema_version')
|
|
1861
|
+
|| Object.prototype.hasOwnProperty.call(value, 'asset_id');
|
|
1862
|
+
}
|
|
1863
|
+
function isClearlyLegacyLooseAsset(value) {
|
|
1864
|
+
if (Object.prototype.hasOwnProperty.call(value, 'schema_version')
|
|
1865
|
+
|| Object.prototype.hasOwnProperty.call(value, 'asset_id'))
|
|
1866
|
+
return false;
|
|
1867
|
+
return ['content', 'summary', 'strategy'].some((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
1868
|
+
}
|
|
1869
|
+
async function resolveVerifiedExecution(verifier, input, expectedValidation, timeoutMs = DEFAULT_PUBLISH_EXECUTION_VERIFY_TIMEOUT_MS, parentSignals = []) {
|
|
1870
|
+
if (!verifier)
|
|
1871
|
+
return undefined;
|
|
1872
|
+
if (parentSignals.some((signal) => signal.aborted))
|
|
1873
|
+
return undefined;
|
|
1874
|
+
const boundedTimeout = Number.isSafeInteger(timeoutMs) && timeoutMs > 0
|
|
1875
|
+
? timeoutMs
|
|
1876
|
+
: DEFAULT_PUBLISH_EXECUTION_VERIFY_TIMEOUT_MS;
|
|
1877
|
+
const controller = new AbortController();
|
|
1878
|
+
let timer;
|
|
1879
|
+
let resolveAborted;
|
|
1880
|
+
const aborted = new Promise((resolve) => {
|
|
1881
|
+
resolveAborted = () => resolve(null);
|
|
1882
|
+
});
|
|
1883
|
+
const onParentAbort = () => {
|
|
1884
|
+
if (!controller.signal.aborted)
|
|
1885
|
+
controller.abort(new Error('publish_verification_aborted'));
|
|
1886
|
+
resolveAborted();
|
|
1887
|
+
};
|
|
1888
|
+
for (const signal of parentSignals)
|
|
1889
|
+
signal.addEventListener('abort', onParentAbort, { once: true });
|
|
1890
|
+
try {
|
|
1891
|
+
const timeout = new Promise((resolve) => {
|
|
1892
|
+
timer = setTimeout(() => {
|
|
1893
|
+
if (!controller.signal.aborted)
|
|
1894
|
+
controller.abort(new Error('publish_verification_timeout'));
|
|
1895
|
+
resolve(null);
|
|
1896
|
+
}, boundedTimeout);
|
|
1897
|
+
timer.unref?.();
|
|
1898
|
+
});
|
|
1899
|
+
const candidate = await Promise.race([verifier(input, controller.signal), timeout, aborted]);
|
|
1900
|
+
if (!candidate || !Array.isArray(candidate.trace) || candidate.trace.length === 0)
|
|
1901
|
+
return undefined;
|
|
1902
|
+
if (controller.signal.aborted)
|
|
1903
|
+
return undefined;
|
|
1904
|
+
if (candidate.trace.some((row) => (!row
|
|
1905
|
+
|| typeof row.command !== 'string'
|
|
1906
|
+
|| row.command.trim().length === 0
|
|
1907
|
+
|| !Number.isInteger(row.exit)
|
|
1908
|
+
|| row.exit !== 0)))
|
|
1909
|
+
return undefined;
|
|
1910
|
+
if (candidate.trace.length !== expectedValidation.length)
|
|
1911
|
+
return undefined;
|
|
1912
|
+
if (candidate.trace.some((row, index) => row.command.trim() !== expectedValidation[index]))
|
|
1913
|
+
return undefined;
|
|
1914
|
+
return { ...candidate, validation: expectedValidation };
|
|
1915
|
+
}
|
|
1916
|
+
catch {
|
|
1917
|
+
return undefined;
|
|
1918
|
+
}
|
|
1919
|
+
finally {
|
|
1920
|
+
if (timer !== undefined)
|
|
1921
|
+
clearTimeout(timer);
|
|
1922
|
+
for (const signal of parentSignals)
|
|
1923
|
+
signal.removeEventListener('abort', onParentAbort);
|
|
1924
|
+
if (!controller.signal.aborted)
|
|
1925
|
+
controller.abort(new Error('publish_verification_complete'));
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
function declaredValidationCommands(input) {
|
|
1929
|
+
const raw = input.validation ?? input.verification ?? (input.execution && typeof input.execution === 'object' && !Array.isArray(input.execution)
|
|
1930
|
+
? input.execution['validation']
|
|
1931
|
+
: undefined);
|
|
1932
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 8)
|
|
1933
|
+
return undefined;
|
|
1934
|
+
const commands = raw.map((command) => typeof command === 'string' ? command.trim() : '');
|
|
1935
|
+
if (commands.some((command) => command.length === 0 || command.length > 180 || !verify.isValidationCommandAllowed(command))) {
|
|
1936
|
+
return undefined;
|
|
1937
|
+
}
|
|
1938
|
+
return commands;
|
|
1939
|
+
}
|
|
1940
|
+
async function resolveVerifiedExecutionAfterPreflight(verifier, input, timeoutMs, parentSignals = []) {
|
|
1941
|
+
if (!verifier)
|
|
1942
|
+
return undefined;
|
|
1943
|
+
if (parentSignals.some((signal) => signal.aborted))
|
|
1944
|
+
return undefined;
|
|
1945
|
+
const preflight = await hubNs.distillConversation(input, { persist: false });
|
|
1946
|
+
if (parentSignals.some((signal) => signal.aborted) || !preflight.ok || !preflight.quality.ok)
|
|
1947
|
+
return undefined;
|
|
1948
|
+
const validation = declaredValidationCommands(input);
|
|
1949
|
+
if (!validation)
|
|
1950
|
+
return undefined;
|
|
1951
|
+
// 只把通过质量与命令策略预检的最小验证输入交给宿主,绝不转发调用方的 execution/status/trace。
|
|
1952
|
+
return resolveVerifiedExecution(verifier, { validation }, validation, timeoutMs, parentSignals);
|
|
1953
|
+
}
|
|
1954
|
+
async function convertLegacyLooseAsset(value, verifyExecution, verifyExecutionTimeoutMs, ...parentSignals) {
|
|
1955
|
+
const normalized = legacyLooseDistillInput(value);
|
|
1956
|
+
if (!normalized.ok)
|
|
1957
|
+
return normalized;
|
|
1958
|
+
try {
|
|
1959
|
+
const verifiedExecution = await resolveVerifiedExecutionAfterPreflight(verifyExecution, normalized.input, verifyExecutionTimeoutMs, parentSignals.filter((signal) => signal !== undefined));
|
|
1960
|
+
const distilled = await hubNs.distillConversation(normalized.input, {
|
|
1961
|
+
persist: false,
|
|
1962
|
+
...(verifiedExecution ? { verifiedExecution } : {}),
|
|
1963
|
+
});
|
|
1964
|
+
if (!distilled.ok)
|
|
1965
|
+
return { ok: false, error: `legacy_distill_${safeIdentifier(distilled.reason)}` };
|
|
1966
|
+
// Mirror the /conversation/distill route: a draft that passed the quality gate but was
|
|
1967
|
+
// blocked only by the missing host execution evidence must not be reported as a
|
|
1968
|
+
// quality-gate failure.
|
|
1969
|
+
if (distilled.publishable !== true) {
|
|
1970
|
+
return {
|
|
1971
|
+
ok: false,
|
|
1972
|
+
error: distilled.quality.ok && verifiedExecution === undefined
|
|
1973
|
+
? 'legacy_distill_execution_evidence'
|
|
1974
|
+
: 'legacy_distill_quality_gate',
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
const gene = {
|
|
1978
|
+
...distilled.gene,
|
|
1979
|
+
...(normalized.constraints
|
|
1980
|
+
? { constraints: mergeLegacyConstraints(distilled.gene['constraints'], normalized.constraints) }
|
|
1981
|
+
: {}),
|
|
1982
|
+
...(normalized.category ? { category: normalized.category } : {}),
|
|
1983
|
+
};
|
|
1984
|
+
const bundle = [gene, distilled.capsule].map(deterministicDistilledAsset);
|
|
1985
|
+
if (!bundle.every((asset) => wire.validateWire(asset).ok)) {
|
|
1986
|
+
return { ok: false, error: 'legacy_distill_invalid_wire_output' };
|
|
1987
|
+
}
|
|
1988
|
+
return { ok: true, bundle };
|
|
1989
|
+
}
|
|
1990
|
+
catch {
|
|
1991
|
+
return { ok: false, error: 'legacy_distill_failed' };
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
function legacyLooseDistillInput(value) {
|
|
1995
|
+
const content = strictOptionalString(value, 'content');
|
|
1996
|
+
const summary = strictOptionalString(value, 'summary');
|
|
1997
|
+
if (!content.ok || !summary.ok)
|
|
1998
|
+
return { ok: false, error: 'legacy content and summary must be strings' };
|
|
1999
|
+
const strategy = strictOptionalStringList(value, 'strategy', 10, 220);
|
|
2000
|
+
if (!strategy.ok)
|
|
2001
|
+
return { ok: false, error: 'legacy strategy must be an array of strings' };
|
|
2002
|
+
if (strategy.value && (strategy.value.length < 2 || strategy.value.some((step) => step.length < 15))) {
|
|
2003
|
+
return { ok: false, error: 'legacy strategy requires at least two steps of 15 characters each' };
|
|
2004
|
+
}
|
|
2005
|
+
const text = [content.value, summary.value].filter(Boolean).join('\n').trim();
|
|
2006
|
+
const suppliedSubstance = [text, ...(strategy.value ?? [])].join(' ').trim();
|
|
2007
|
+
if (!strategy.value && text.length < 50) {
|
|
2008
|
+
return { ok: false, error: 'legacy content or summary must contain at least 50 characters' };
|
|
2009
|
+
}
|
|
2010
|
+
if (suppliedSubstance.length < 50) {
|
|
2011
|
+
return { ok: false, error: 'legacy input does not contain enough substantive content' };
|
|
2012
|
+
}
|
|
2013
|
+
const signals = strictOptionalStringList(value, 'signals', 12, 64);
|
|
2014
|
+
const signalsMatch = strictOptionalStringList(value, 'signals_match', 12, 64);
|
|
2015
|
+
const validation = strictOptionalStringList(value, 'validation', 8, 180);
|
|
2016
|
+
const verification = strictOptionalStringList(value, 'verification', 8, 180);
|
|
2017
|
+
const artifacts = strictOptionalStringList(value, 'artifacts', 12, 240);
|
|
2018
|
+
if (!signals.ok || !signalsMatch.ok || !validation.ok || !verification.ok || !artifacts.ok) {
|
|
2019
|
+
return { ok: false, error: 'legacy list fields must contain strings only' };
|
|
2020
|
+
}
|
|
2021
|
+
const constraints = parseLegacyConstraints(value['constraints']);
|
|
2022
|
+
if (!constraints.ok)
|
|
2023
|
+
return constraints;
|
|
2024
|
+
const category = parseLegacyCategory(value['category']);
|
|
2025
|
+
if (!category.ok)
|
|
2026
|
+
return category;
|
|
2027
|
+
const derivedSummary = summary.value
|
|
2028
|
+
|| content.value?.slice(0, 300)
|
|
2029
|
+
|| strategy.value?.join('; ').slice(0, 300)
|
|
2030
|
+
|| '';
|
|
2031
|
+
const input = {
|
|
2032
|
+
summary: derivedSummary,
|
|
2033
|
+
transcript: content.value ?? derivedSummary,
|
|
2034
|
+
...(strategy.value ? { strategy: strategy.value } : {}),
|
|
2035
|
+
...((signals.value ?? signalsMatch.value) ? { signals: signals.value ?? signalsMatch.value } : {}),
|
|
2036
|
+
...((validation.value ?? verification.value) ? { validation: validation.value ?? verification.value } : {}),
|
|
2037
|
+
...(artifacts.value ? { artifacts: artifacts.value } : {}),
|
|
2038
|
+
...strictForwardString(value, 'title'),
|
|
2039
|
+
...strictForwardString(value, 'name'),
|
|
2040
|
+
...strictForwardString(value, 'platform'),
|
|
2041
|
+
...strictForwardString(value, 'model'),
|
|
2042
|
+
...strictForwardString(value, 'thread_id'),
|
|
2043
|
+
...(isRecordValue(value['execution']) ? { execution: value['execution'] } : {}),
|
|
2044
|
+
...(isRecordValue(value['blast_radius']) ? { blast_radius: value['blast_radius'] } : {}),
|
|
2045
|
+
// Compatibility callers may not lower the V2 quality gate.
|
|
2046
|
+
min_score: 5,
|
|
2047
|
+
persist: false,
|
|
2048
|
+
};
|
|
2049
|
+
return {
|
|
2050
|
+
ok: true,
|
|
2051
|
+
input,
|
|
2052
|
+
...(constraints.value ? { constraints: constraints.value } : {}),
|
|
2053
|
+
...(category.value ? { category: category.value } : {}),
|
|
2054
|
+
};
|
|
2055
|
+
}
|
|
2056
|
+
function parseLegacyConstraints(value) {
|
|
2057
|
+
if (value === undefined)
|
|
2058
|
+
return { ok: true };
|
|
2059
|
+
if (!isRecordValue(value))
|
|
2060
|
+
return { ok: false, error: 'legacy constraints must be an object' };
|
|
2061
|
+
if (Object.keys(value).some((key) => key !== 'max_files' && key !== 'forbidden_paths')) {
|
|
2062
|
+
return { ok: false, error: 'legacy constraints contains unsupported fields' };
|
|
2063
|
+
}
|
|
2064
|
+
const maxFiles = value['max_files'];
|
|
2065
|
+
if (maxFiles !== undefined && (!Number.isInteger(maxFiles) || Number(maxFiles) < 1 || Number(maxFiles) > 10_000)) {
|
|
2066
|
+
return { ok: false, error: 'legacy constraints.max_files must be an integer from 1 to 10000' };
|
|
2067
|
+
}
|
|
2068
|
+
const forbiddenPaths = value['forbidden_paths'];
|
|
2069
|
+
if (forbiddenPaths !== undefined && (!Array.isArray(forbiddenPaths)
|
|
2070
|
+
|| forbiddenPaths.length > 50
|
|
2071
|
+
|| forbiddenPaths.some((path) => typeof path !== 'string' || path.trim().length === 0 || path.trim().length > 200))) {
|
|
2072
|
+
return { ok: false, error: 'legacy constraints.forbidden_paths must be a bounded string array' };
|
|
2073
|
+
}
|
|
2074
|
+
const normalizedPaths = Array.isArray(forbiddenPaths)
|
|
2075
|
+
? uniqueStrings(forbiddenPaths.map((path) => String(path).trim()))
|
|
2076
|
+
: undefined;
|
|
2077
|
+
return {
|
|
2078
|
+
ok: true,
|
|
2079
|
+
value: {
|
|
2080
|
+
...(typeof maxFiles === 'number' ? { max_files: maxFiles } : {}),
|
|
2081
|
+
...(normalizedPaths ? { forbidden_paths: normalizedPaths } : {}),
|
|
2082
|
+
},
|
|
2083
|
+
};
|
|
2084
|
+
}
|
|
2085
|
+
function mergeLegacyConstraints(base, legacy) {
|
|
2086
|
+
const current = isRecordValue(base) ? base : {};
|
|
2087
|
+
const currentMax = Number.isInteger(current['max_files']) && Number(current['max_files']) > 0
|
|
2088
|
+
? Number(current['max_files'])
|
|
2089
|
+
: 20;
|
|
2090
|
+
const currentPaths = Array.isArray(current['forbidden_paths'])
|
|
2091
|
+
? current['forbidden_paths'].filter((path) => typeof path === 'string')
|
|
2092
|
+
: [];
|
|
2093
|
+
return {
|
|
2094
|
+
max_files: Math.min(currentMax, legacy.max_files ?? currentMax),
|
|
2095
|
+
forbidden_paths: uniqueStrings([...currentPaths, ...(legacy.forbidden_paths ?? [])]),
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
2098
|
+
function parseLegacyCategory(value) {
|
|
2099
|
+
if (value === undefined)
|
|
2100
|
+
return { ok: true };
|
|
2101
|
+
if (value === 'repair' || value === 'optimize' || value === 'innovate' || value === 'explore') {
|
|
2102
|
+
return { ok: true, value };
|
|
2103
|
+
}
|
|
2104
|
+
return { ok: false, error: 'legacy category is invalid' };
|
|
2105
|
+
}
|
|
2106
|
+
function deterministicDistilledAsset(asset) {
|
|
2107
|
+
const draft = wire.stripGeneHints({ ...asset, asset_id: '' });
|
|
2108
|
+
// `_source` is local distiller provenance and is not part of the current GEP Gene schema.
|
|
2109
|
+
// It also contains a wall-clock timestamp, so it must not influence compatibility asset ids.
|
|
2110
|
+
delete draft['_source'];
|
|
2111
|
+
return assetstore.normalizeForPut(draft).record;
|
|
2112
|
+
}
|
|
2113
|
+
function strictOptionalString(value, key) {
|
|
2114
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
2115
|
+
return { ok: true };
|
|
2116
|
+
const raw = value[key];
|
|
2117
|
+
if (typeof raw !== 'string')
|
|
2118
|
+
return { ok: false };
|
|
2119
|
+
const trimmed = raw.trim();
|
|
2120
|
+
return { ok: true, ...(trimmed ? { value: trimmed } : {}) };
|
|
2121
|
+
}
|
|
2122
|
+
function strictOptionalStringList(value, key, maxItems, maxLength) {
|
|
2123
|
+
if (!Object.prototype.hasOwnProperty.call(value, key))
|
|
2124
|
+
return { ok: true };
|
|
2125
|
+
const raw = value[key];
|
|
2126
|
+
if (!Array.isArray(raw) || raw.some((item) => typeof item !== 'string'))
|
|
2127
|
+
return { ok: false };
|
|
2128
|
+
const normalized = raw.map((item) => item.trim()).filter(Boolean).slice(0, maxItems);
|
|
2129
|
+
if (normalized.some((item) => item.length > maxLength))
|
|
2130
|
+
return { ok: false };
|
|
2131
|
+
return { ok: true, ...(normalized.length > 0 ? { value: normalized } : {}) };
|
|
2132
|
+
}
|
|
2133
|
+
function strictForwardString(value, key) {
|
|
2134
|
+
const parsed = strictOptionalString(value, key);
|
|
2135
|
+
return parsed.ok && parsed.value ? { [key]: parsed.value } : {};
|
|
2136
|
+
}
|
|
2137
|
+
function isRecordValue(value) {
|
|
2138
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
|
2139
|
+
}
|
|
2140
|
+
function safeIdentifier(value) {
|
|
2141
|
+
return value.replace(/[^a-z0-9_]+/gi, '_').slice(0, 80) || 'rejected';
|
|
2142
|
+
}
|
|
2143
|
+
function synchronousAssetSubmitKey(scope, runtimeNamespace, hubMode, bundle) {
|
|
2144
|
+
const assetIds = bundle.map((asset) => asset.asset_id).sort();
|
|
2145
|
+
const digestInput = hubMode === 'private'
|
|
2146
|
+
? [scope, runtimeNamespace, hubMode, assetIds]
|
|
2147
|
+
: [scope, runtimeNamespace, assetIds];
|
|
2148
|
+
const digest = createHash('sha256')
|
|
2149
|
+
.update(JSON.stringify(digestInput))
|
|
2150
|
+
.digest('hex');
|
|
2151
|
+
return `${SYNC_ASSET_SUBMIT_PREFIX}${digest}`;
|
|
2152
|
+
}
|
|
2153
|
+
function asyncAssetSubmitEnvelopeId(runtimeNamespace, requestId) {
|
|
2154
|
+
const digest = createHash('sha256')
|
|
2155
|
+
.update(JSON.stringify([runtimeNamespace, requestId]))
|
|
2156
|
+
.digest('hex');
|
|
2157
|
+
return `${ASYNC_ASSET_SUBMIT_PREFIX}${digest}`;
|
|
2158
|
+
}
|
|
2159
|
+
function compareSynchronousAssetSubmitAssets(left, right) {
|
|
2160
|
+
const typeOrder = SYNC_ASSET_SUBMIT_TYPE_RANK[left.type] - SYNC_ASSET_SUBMIT_TYPE_RANK[right.type];
|
|
2161
|
+
if (typeOrder !== 0)
|
|
2162
|
+
return typeOrder;
|
|
2163
|
+
if (left.asset_id < right.asset_id)
|
|
2164
|
+
return -1;
|
|
2165
|
+
if (left.asset_id > right.asset_id)
|
|
2166
|
+
return 1;
|
|
2167
|
+
return 0;
|
|
2168
|
+
}
|
|
2169
|
+
function synchronousAssetSubmitOutcomeKey(idempotencyKey) {
|
|
2170
|
+
return `${idempotencyKey}:outcome`;
|
|
2171
|
+
}
|
|
2172
|
+
function synchronousAssetSubmitAcceptanceKey(idempotencyKey) {
|
|
2173
|
+
return `${idempotencyKey}:accepted`;
|
|
2174
|
+
}
|
|
2175
|
+
function cachedSynchronousAssetSubmitFailure(outcome) {
|
|
2176
|
+
const retryAfterMs = positiveFiniteNumber(outcome.body['retry_after_ms']);
|
|
2177
|
+
return new hubNs.PublishRejectedError(typeof outcome.body['status'] === 'string'
|
|
2178
|
+
? outcome.body['status']
|
|
2179
|
+
: String(outcome.body['error'] ?? 'rejected'), true, typeof outcome.body['reason'] === 'string' ? outcome.body['reason'] : undefined, retryAfterMs, false);
|
|
2180
|
+
}
|
|
2181
|
+
function isSynchronousAssetSubmitEnvelope(envelope) {
|
|
2182
|
+
return envelope.type === 'asset_submit'
|
|
2183
|
+
&& envelope.idempotencyKey.startsWith(SYNC_ASSET_SUBMIT_PREFIX);
|
|
2184
|
+
}
|
|
2185
|
+
function isTerminalSynchronousPublishFailure(error) {
|
|
2186
|
+
return error instanceof hubNs.PublishRejectedError && error.terminal;
|
|
2187
|
+
}
|
|
2188
|
+
function isRetryableSynchronousPublishFailure(error) {
|
|
2189
|
+
if (error instanceof AuthError || errorName(error) === 'AuthError')
|
|
2190
|
+
return true;
|
|
2191
|
+
if (error instanceof HubUnreachableError || errorName(error) === 'HubUnreachableError')
|
|
2192
|
+
return true;
|
|
2193
|
+
if (error instanceof hubNs.PublishRejectedError) {
|
|
2194
|
+
return !error.terminal && (error.retryable === true || error.retryAfterMs !== undefined);
|
|
2195
|
+
}
|
|
2196
|
+
if (error instanceof HubClientError || errorName(error) === 'HubClientError') {
|
|
2197
|
+
const status = error instanceof HubClientError ? error.status : Number(asRecord(error)['status']);
|
|
2198
|
+
return status === 429 || (status >= 500 && status <= 599);
|
|
2199
|
+
}
|
|
2200
|
+
return false;
|
|
2201
|
+
}
|
|
2202
|
+
function synchronousPublishRetryAfterMs(error, failure) {
|
|
2203
|
+
const fromBody = positiveFiniteNumber(failure.body['retry_after_ms']);
|
|
2204
|
+
if (fromBody !== undefined)
|
|
2205
|
+
return Math.max(1_000, fromBody);
|
|
2206
|
+
if (error instanceof hubNs.PublishRejectedError && error.retryAfterMs !== undefined) {
|
|
2207
|
+
return Math.max(1_000, error.retryAfterMs);
|
|
2208
|
+
}
|
|
2209
|
+
if (error instanceof HubUnreachableError)
|
|
2210
|
+
return Math.max(1_000, error.retryAfterMs);
|
|
2211
|
+
if (error instanceof HubClientError && error.retryAfterMs !== undefined) {
|
|
2212
|
+
return Math.max(1_000, error.retryAfterMs);
|
|
2213
|
+
}
|
|
2214
|
+
return 60_000;
|
|
2215
|
+
}
|
|
2216
|
+
function submittedAssetIds(result, fallback) {
|
|
2217
|
+
const record = asRecord(result);
|
|
2218
|
+
for (const key of ['submittedAssetIds', 'assetIds']) {
|
|
2219
|
+
const value = record[key];
|
|
2220
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
|
2221
|
+
return value;
|
|
2222
|
+
}
|
|
2223
|
+
return fallback.map((asset) => asset.asset_id);
|
|
2224
|
+
}
|
|
2225
|
+
function mapSynchronousPublishFailure(error) {
|
|
2226
|
+
if (error instanceof hubNs.PublishRejectedError) {
|
|
2227
|
+
const status = publishRejectionStatus(error.status);
|
|
2228
|
+
if (status === 'cooldown') {
|
|
2229
|
+
return {
|
|
2230
|
+
statusCode: 429,
|
|
2231
|
+
body: {
|
|
2232
|
+
error: 'hub_rate_limited',
|
|
2233
|
+
...(error.retryAfterMs !== undefined ? { retry_after_ms: error.retryAfterMs } : {}),
|
|
2234
|
+
},
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
if (status === 'credit_shortage') {
|
|
2238
|
+
return { statusCode: 402, body: { error: 'hub_payment_required' } };
|
|
2239
|
+
}
|
|
2240
|
+
return {
|
|
2241
|
+
statusCode: error.terminal ? 422 : 503,
|
|
2242
|
+
body: {
|
|
2243
|
+
error: 'publish_rejected',
|
|
2244
|
+
status,
|
|
2245
|
+
terminal: error.terminal,
|
|
2246
|
+
reason: status === 'leak_blocked'
|
|
2247
|
+
? 'sensitive data detected before publish'
|
|
2248
|
+
: 'Hub did not accept the publish',
|
|
2249
|
+
...(error.retryAfterMs !== undefined ? { retry_after_ms: error.retryAfterMs } : {}),
|
|
2250
|
+
},
|
|
2251
|
+
};
|
|
2252
|
+
}
|
|
2253
|
+
if (error instanceof AuthError || errorName(error) === 'AuthError') {
|
|
2254
|
+
return { statusCode: 502, body: { error: 'hub_auth_failed' } };
|
|
2255
|
+
}
|
|
2256
|
+
if (error instanceof HubUnreachableError || errorName(error) === 'HubUnreachableError') {
|
|
2257
|
+
const retryAfterMs = error instanceof HubUnreachableError ? error.retryAfterMs : positiveFiniteNumber(asRecord(error)['retryAfterMs']);
|
|
2258
|
+
return {
|
|
2259
|
+
statusCode: 503,
|
|
2260
|
+
body: { error: 'hub_unreachable', ...(retryAfterMs !== undefined ? { retry_after_ms: retryAfterMs } : {}) },
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
if (error instanceof HubClientError || errorName(error) === 'HubClientError') {
|
|
2264
|
+
const status = error instanceof HubClientError ? error.status : Number(asRecord(error)['status']);
|
|
2265
|
+
if (status === 429) {
|
|
2266
|
+
const retryAfterMs = error instanceof HubClientError ? error.retryAfterMs : positiveFiniteNumber(asRecord(error)['retryAfterMs']);
|
|
2267
|
+
return {
|
|
2268
|
+
statusCode: 429,
|
|
2269
|
+
body: { error: 'hub_rate_limited', ...(retryAfterMs !== undefined ? { retry_after_ms: retryAfterMs } : {}) },
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
if (status === 402)
|
|
2273
|
+
return { statusCode: 402, body: { error: 'hub_payment_required' } };
|
|
2274
|
+
return { statusCode: status >= 500 ? 503 : 502, body: { error: 'hub_publish_failed' } };
|
|
2275
|
+
}
|
|
2276
|
+
return { statusCode: 502, body: { error: 'hub_publish_failed' } };
|
|
2277
|
+
}
|
|
2278
|
+
function errorName(value) {
|
|
2279
|
+
return typeof asRecord(value)['name'] === 'string' ? String(asRecord(value)['name']) : undefined;
|
|
2280
|
+
}
|
|
2281
|
+
function publishRejectionStatus(value) {
|
|
2282
|
+
return value === 'quarantine'
|
|
2283
|
+
|| value === 'leak_blocked'
|
|
2284
|
+
|| value === 'cooldown'
|
|
2285
|
+
|| value === 'credit_shortage'
|
|
2286
|
+
? value
|
|
2287
|
+
: 'rejected';
|
|
2288
|
+
}
|
|
934
2289
|
function respondAgentDirectory(ctx, result) {
|
|
935
2290
|
if (result.ok) {
|
|
936
2291
|
ctx.json(200, result);
|