@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.
Files changed (69) hide show
  1. package/dist/bin/evolver-llm-proxy.js +0 -0
  2. package/dist/bin/evolver-proxy.d.ts +105 -7
  3. package/dist/bin/evolver-proxy.js +877 -121
  4. package/dist/daemon/atpConsent.js +5 -2
  5. package/dist/daemon/collaborationFacade.js +26 -16
  6. package/dist/daemon/proxyDaemon.d.ts +65 -0
  7. package/dist/daemon/proxyDaemon.js +1384 -29
  8. package/dist/daemon/publishRecallVerifier.d.ts +114 -0
  9. package/dist/daemon/publishRecallVerifier.js +495 -0
  10. package/dist/daemon/selectHub.js +5 -3
  11. package/dist/daemon/systemdNotifier.d.ts +48 -0
  12. package/dist/daemon/systemdNotifier.js +163 -0
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.js +4 -1
  15. package/dist/lifecycle/claimNudge.d.ts +20 -0
  16. package/dist/lifecycle/claimNudge.js +124 -0
  17. package/dist/lifecycle/legacyNodeId.d.ts +11 -13
  18. package/dist/lifecycle/legacyNodeId.js +35 -20
  19. package/dist/lifecycle/manager.d.ts +4 -0
  20. package/dist/lifecycle/manager.js +15 -2
  21. package/dist/llm/server.js +24 -4
  22. package/dist/llm/traceControl.js +1 -1
  23. package/dist/llm/upstream.d.ts +5 -1
  24. package/dist/llm/upstream.js +72 -2
  25. package/dist/private/accountAssetCompatibility.d.ts +29 -0
  26. package/dist/private/accountAssetCompatibility.js +196 -0
  27. package/dist/private/adapterLoader.d.ts +21 -1
  28. package/dist/private/adapterLoader.js +242 -7
  29. package/dist/private/nodeCredentialStore.d.ts +23 -0
  30. package/dist/private/nodeCredentialStore.js +210 -0
  31. package/dist/router/messagesRoute.js +9 -3
  32. package/dist/router/providerRoutes.js +7 -3
  33. package/dist/selfUpdate/bootstrap.d.ts +162 -0
  34. package/dist/selfUpdate/bootstrap.js +3524 -0
  35. package/dist/selfUpdate/bootstrapReadiness.d.ts +9 -0
  36. package/dist/selfUpdate/bootstrapReadiness.js +153 -0
  37. package/dist/selfUpdate/builtinKey.d.ts +4 -0
  38. package/dist/selfUpdate/builtinKey.js +16 -0
  39. package/dist/selfUpdate/controllerLifecycleAuthority.d.ts +45 -0
  40. package/dist/selfUpdate/controllerLifecycleAuthority.js +61 -0
  41. package/dist/selfUpdate/executor.d.ts +27 -11
  42. package/dist/selfUpdate/executor.js +233 -58
  43. package/dist/selfUpdate/failureCodes.d.ts +10 -0
  44. package/dist/selfUpdate/failureCodes.js +13 -0
  45. package/dist/selfUpdate/index.d.ts +5 -1
  46. package/dist/selfUpdate/index.js +5 -1
  47. package/dist/selfUpdate/lastUpdate.d.ts +3 -1
  48. package/dist/selfUpdate/lastUpdate.js +37 -6
  49. package/dist/selfUpdate/migration.d.ts +158 -0
  50. package/dist/selfUpdate/migration.js +2672 -0
  51. package/dist/selfUpdate/policy.d.ts +19 -2
  52. package/dist/selfUpdate/policy.js +76 -2
  53. package/dist/selfUpdate/recoveryChildStartGate.d.ts +29 -0
  54. package/dist/selfUpdate/recoveryChildStartGate.js +319 -0
  55. package/dist/selfUpdate/releaseBinary.d.ts +13 -0
  56. package/dist/selfUpdate/releaseBinary.js +93 -10
  57. package/dist/selfUpdate/transaction.d.ts +117 -0
  58. package/dist/selfUpdate/transaction.js +1322 -0
  59. package/dist/selfUpdate/unixController.d.ts +23 -0
  60. package/dist/selfUpdate/unixController.js +514 -0
  61. package/dist/selfUpdate/version.d.ts +6 -2
  62. package/dist/selfUpdate/version.js +5 -3
  63. package/dist/selfUpdate/windowsController.d.ts +35 -0
  64. package/dist/selfUpdate/windowsController.js +655 -0
  65. package/dist/selfUpdate/windowsUpdater.d.ts +104 -0
  66. package/dist/selfUpdate/windowsUpdater.js +882 -0
  67. package/dist/sync/engine.d.ts +12 -0
  68. package/dist/sync/engine.js +255 -64
  69. 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
- const hubBindings = hubNs.makeHubBindings(hubToUse);
70
- const proxyHandler = hubBindings.asProxyHandler();
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) => this.collaborationFacade.handleOutboundSucceeded(envelope, result),
110
- onOutboundTerminal: (envelope, error) => this.collaborationFacade.handleOutboundTerminal(envelope, error),
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.ipc.listen(this.deps.ipcPort ?? DEFAULT_IPC_PORT);
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: isFatalTickCandidate(outbound, inbound, failedPhases) } : { failedPhases: [], fatalCandidate: false }),
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
- if (await this.collaborationFacade.handle(ctx))
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 = Math.max(1, Math.min(Number(body.limit ?? 10), 50));
593
- const messages = this.store.list({ status: 'pending', limit: 500 })
594
- .filter((m) => (body.type ? m.type === body.type : true))
595
- .filter((m) => (body.direction ? m.direction === body.direction : true))
596
- .slice(0, limit);
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
- const limit = Math.max(1, Math.min(Number(body.limit ?? 5), 25));
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 (!body.assets && !body.asset_id) {
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
- const env = mailbox.createEnvelope({ type: 'asset_submit', payload: body, now: ctx.now });
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(200, { id: env.id, message_id: env.id, receiptId: r.receiptId, status: 'pending', stored: r.stored });
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 parsed = parseReuseResultReport(await ctx.readJson());
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 distill = await hubNs.distillConversation(body, { persist: body.persist === true, store: this.assetStore });
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, { ...distill, queued: false, submission: null });
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 (body['publish'] === true) {
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: { source: 'conversation_distillation', distill_id: distill.distill_id, assets: [distill.gene, distill.capsule] },
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, { ...distill, queued: submission !== null, submission });
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 = (await this.deps.hub.search(query)).filter((asset) => asset.type !== 'AntiGene');
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
- return Boolean(asset && asset.asset_id === assetId);
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);