@stratusagent/gateway 0.13.7 → 0.14.0

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/index.js CHANGED
@@ -9,17 +9,21 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
9
9
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
10
10
  import { mkdir, readFile, rename, rm } from 'node:fs/promises';
11
11
  import path from 'node:path';
12
- import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, sessionTrustOf, sessionWriteTrust, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, } from '@stratusagent/core';
13
- import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createChannelCreateTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DEFAULT_MAX_DELEGATION_DEPTH, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, } from '@stratusagent/agents';
12
+ import { filePathsOf, isUnaddressedTurn, carriesKey, leastTrusted, senderTrustOf, SENDER_TRUST_METADATA_KEY, workItemState, leftForNextStart, lastResponseIsAnswer, MAX_IDEMPOTENCY_KEY_LENGTH, recordTurnFailure, completeAnsweredTurn, abortErrorFor, sessionTrustOf, sessionWriteTrust, AgentRegistry, AgentRunner, ChannelRegistry, ContributionRegistry, createRoutedMemoryStore, EventBus, RunAbortedError, SkillRegistry, ToolRegistry, createSkillReadTool, missingSkillRequirements, describeToolAllowlistFinding, unmatchedToolAllowlist, CONTEXT_FLOOR_METADATA_KEY, PENDING_APPROVAL_METADATA_KEY, SESSION_TAINTED_BY_METADATA_KEY, SESSION_TRUST_METADATA_KEY, latestTurnReply, readPendingApproval, conversationContextFrom, CONVERSATION_METADATA_KEY, STRATUS_SKILL_ID, imagesWithinReplayBudget, matchesToolAllowlist, } from '@stratusagent/core';
13
+ import { CREDENTIAL_REQUEST_TOOL_NAME, createCredentialRequestTool, createDelegateTool, DELEGATE_TOOL_NAME, createForgetTool, createPinTool, createMessageSendTool, createMessageReadTool, createChannelCreateTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DEFAULT_MAX_DELEGATION_DEPTH, DELEGATION_DEPTH_METADATA_KEY, delegatingSessionIdOf, isDelegatedSession, outstandingDelegationFor, ROOT_SESSION_ID_METADATA_KEY, SCHEDULE_ID_METADATA_KEY, SCHEDULED_TURN_METADATA_KEY, withoutDelegation, loadStratusSkill, STRATUS_SKILL_PATH, credentialVariableProblem, } from '@stratusagent/agents';
14
14
  import { createFleetReader } from "./fleet.js";
15
+ import { createEventIntake, summarizeEvents, } from "./event-intake.js";
16
+ import { SqliteEventStore } from "./events.js";
15
17
  import { createSchedulerRuntime, isScheduleSessionId, SqliteScheduleStore, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
16
18
  import { ShardedSessionStore } from "./sessions.js";
17
19
  export { claimHome, homeLockPath, HomeClaimedError, } from "./lock.js";
18
20
  export { SqliteSessionStore, ShardedSessionStore, FleetSessionIndex, SessionIdTakenError, tightenSqliteFile, } from "./sessions.js";
19
21
  export { SqliteScheduleStore, createSchedulerRuntime, isScheduleSessionId, SCHEDULE_SESSION_ID_PREFIX, } from "./schedules.js";
22
+ export { SqliteEventStore, EVENT_BLOCK_MAX_BYTES, EVENT_DEDUP_WINDOW_MS, EVENT_DELIVERY_ROW_CAP, eventCoalescingKey, eventDispatchKey, eventSessionId, renderEventBlock, } from "./events.js";
23
+ export { createEventIntake, mapGithubDelivery, parseStandardWebhookEvent, verifyGithubSignature, verifyStandardWebhookSignature, DEFAULT_EVENT_MAX_BODY_BYTES, EVENT_SOURCE_REQUESTS_PER_MINUTE, } from "./event-intake.js";
20
24
  import { createLocalCommandExecutor } from '@stratusagent/executor-local';
21
25
  import { loadPlugins, } from '@stratusagent/plugins';
22
- import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, appendCredentialAudit, replaceNamedCredential, NamedCredentialMissingError, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, syncDirectory, writeFileSynced, } from '@stratusagent/state';
26
+ import { BUILTIN_EXECUTOR_NAME, BUILTIN_MEMORY_STORE_NAME, BUILTIN_PROVIDER_NAMES, createDemoTool, createFileCredentialResolver, resolveDaemonCredential, createHomeMemoryStore, drainSharedMemory, hasBracketedLegacyStateIn, createRuntimeProvider, describeServingModel, DEFAULT_STRATUS_AGENT, isRegisteredProviderName, loadChannelTransportSecrets, registeredProviderNameOf, loadOperatorSkills, loadRosterSouls, FALLBACK_ACTIVE_METADATA_KEY, loadSoulFile, grantSoulCredential, withSoulFileLock, addNamedCredential, appendCredentialAudit, replaceNamedCredential, NamedCredentialMissingError, namedCredentialSource, NamedCredentialExistsError, CREDENTIAL_NAME_PATTERN, quoteShellArg, migrateLegacyMemory, PROVIDER_STATE_METADATA_KEYS, ConfigFileError, loadConfigFile, resolveConfigLocation, resolveConfiguredSoul, resolveRuntimeConfig, runsOnHarness, applySoulPins, assertStateCompatible, stratusHomePath, intakeStatePath, fleetDbIn, createAgentWorkspaces, createPluginStateDirectories, createHostProtectedPaths, foldedAgentId, syncDirectory, writeFileSynced, } from '@stratusagent/state';
23
27
  const DEFAULT_IDLE_TIMEOUT_MS = 120_000;
24
28
  /**
25
29
  * How long a parked call waits for a person. Long enough to survive a
@@ -393,6 +397,27 @@ export const createGateway = (options = {}) => {
393
397
  // The same database sessions live in — one file to back up — through the
394
398
  // schedule store's own connection (see its docs for why).
395
399
  const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir), { stateHome: stateDir });
400
+ // ---- events (card 590) ---------------------------------------------------
401
+ // Opened only for a daemon with sources, so one that never takes events
402
+ // never grows the tables. Its own connection to fleet.db, like schedules'.
403
+ const eventSources = options.events?.sources ?? {};
404
+ const eventStore = Object.keys(eventSources).length > 0
405
+ ? new SqliteEventStore(fleetDbIn(stateDir), { stateHome: stateDir })
406
+ : undefined;
407
+ const eventIntake = eventStore
408
+ ? createEventIntake({
409
+ store: eventStore,
410
+ sources: () => eventSources,
411
+ // The daemon's own credential, read per delivery: a stored or
412
+ // rotated secret works on the next one, with no restart.
413
+ secret: (name) => resolveDaemonCredential(env, name),
414
+ // The triggers are schedules: `schedule.on` rows on this source.
415
+ // Late-bound, because the scheduler is built further down.
416
+ triggers: (sourceId) => scheduler.eventTriggers(sourceId),
417
+ ...(options.events?.maxBodyBytes !== undefined ? { maxBodyBytes: options.events.maxBodyBytes } : {}),
418
+ warn,
419
+ })
420
+ : undefined;
396
421
  /**
397
422
  * The started adapter of one kind that speaks for an agent and has the
398
423
  * capability asked for. `carriesOthers` says there were capable adapters
@@ -462,6 +487,7 @@ export const createGateway = (options = {}) => {
462
487
  // `SchedulerRuntimeOptions.ready`.
463
488
  ready: () => assertStateCompatible(env),
464
489
  paused: () => intakeState.paused,
490
+ ...(eventStore ? { events: { store: eventStore, sources: () => Object.keys(eventSources) } } : {}),
465
491
  log,
466
492
  warn,
467
493
  });
@@ -1367,11 +1393,26 @@ export const createGateway = (options = {}) => {
1367
1393
  throw new Error(`${JSON.stringify(request.name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
1368
1394
  + 'starting with a letter, the way the tool that needs it spells it: search.apiKey, github.token.');
1369
1395
  }
1396
+ // The stricter rule, for a name the model picked: checked before anyone
1397
+ // is asked, so an approver never sees a form whose binding the store
1398
+ // would refuse, and so a variable that changes what a judged command
1399
+ // does (NODE_OPTIONS) is never offered at all.
1400
+ if (request.env !== undefined) {
1401
+ const problem = credentialVariableProblem(request.env, { requested: true });
1402
+ if (problem !== undefined) {
1403
+ throw new Error(`${problem} Nothing is pending.`);
1404
+ }
1405
+ }
1370
1406
  // A key already stored is not something to ask a person for again: the
1371
1407
  // form only adds, so it could only fail. Say what is actually missing.
1372
1408
  const source = await namedCredentialSource(env, agentId, request.name);
1373
1409
  const stored = source !== undefined;
1374
1410
  const granted = session.agent.credentials?.includes(request.name) === true;
1411
+ // Binding a key the agent already holds asks for no value, and every
1412
+ // form today has a value field, so phase 1 leaves it to the machine.
1413
+ const bindOnMachine = request.env !== undefined
1414
+ ? ` To have your shell read it as ${request.env}, ask your operator to run \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\` on the machine.`
1415
+ : '';
1375
1416
  if (request.replace === true) {
1376
1417
  // Checked here, before anyone is asked, so an approver is never shown
1377
1418
  // a form whose answer could only be refused.
@@ -1394,13 +1435,18 @@ export const createGateway = (options = {}) => {
1394
1435
  }
1395
1436
  }
1396
1437
  else if (stored && granted) {
1397
- throw new Error(`You already hold ${request.name}; the tools that need it use it for you. To rotate it for a new value, ask again with replace: true.`);
1438
+ throw new Error(`You already hold ${request.name}; the tools that need it use it for you. To rotate it for a new value, ask again with replace: true.${bindOnMachine}`
1439
+ + (request.env !== undefined ? ' Nothing is pending.' : ''));
1398
1440
  }
1399
1441
  else if (stored) {
1400
1442
  throw new Error(`${request.name} is already ${source === 'environment' ? "supplied by the daemon's environment" : 'stored'} but not granted to you. `
1401
- + 'Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.');
1443
+ + `Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.${bindOnMachine}`);
1402
1444
  }
1403
- const onMachine = `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``;
1445
+ const onMachine = request.env === undefined
1446
+ ? `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``
1447
+ : request.scope === 'agent'
1448
+ ? `\`stratus credential set ${request.name} --agent ${quoteShellArg(agentId)} --env ${request.env}\``
1449
+ : `\`stratus credential set ${request.name}\`, then \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\``;
1404
1450
  const record = {
1405
1451
  agentId,
1406
1452
  sessionId: session.id,
@@ -1408,6 +1454,7 @@ export const createGateway = (options = {}) => {
1408
1454
  scope: request.scope,
1409
1455
  soulPath,
1410
1456
  ...(request.replace === true ? { replace: true } : {}),
1457
+ ...(request.env !== undefined ? { env: request.env } : {}),
1411
1458
  };
1412
1459
  const announce = async (requestId, via) => {
1413
1460
  await bus.emit({
@@ -1420,6 +1467,7 @@ export const createGateway = (options = {}) => {
1420
1467
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
1421
1468
  via,
1422
1469
  ...(request.replace === true ? { replace: true } : {}),
1470
+ ...(request.env !== undefined ? { env: request.env } : {}),
1423
1471
  ...(session.metadata ? { metadata: session.metadata } : {}),
1424
1472
  });
1425
1473
  };
@@ -1440,11 +1488,14 @@ export const createGateway = (options = {}) => {
1440
1488
  const kind = session.metadata?.channel;
1441
1489
  const { adapter } = typeof kind === 'string'
1442
1490
  ? channelCarrying(kind, agentId, (candidate) => candidate.requestCredential !== undefined
1443
- && (request.replace !== true || candidate.credentialReplaceForms === true))
1491
+ && (request.replace !== true || candidate.credentialReplaceForms === true)
1492
+ // A form that can't say the key will be shell-readable must not
1493
+ // be the approval for it: a link says it instead.
1494
+ && (request.env === undefined || candidate.credentialEnvForms === true))
1444
1495
  : { adapter: undefined };
1445
1496
  if (!adapter?.requestCredential || !session.metadata) {
1446
1497
  formUnavailable = typeof kind === 'string'
1447
- ? `This conversation's channel (${kind}) cannot show your operator a ${request.replace === true ? 'replacement' : 'credential'} form here.`
1498
+ ? `This conversation's channel (${kind}) cannot show your operator a ${request.replace === true ? 'replacement' : 'credential'} form here${request.env !== undefined ? ' that says which variable your shell will read it from' : ''}.`
1448
1499
  : 'This conversation is not in a channel that can show your operator a form.';
1449
1500
  }
1450
1501
  else {
@@ -1463,6 +1514,7 @@ export const createGateway = (options = {}) => {
1463
1514
  scope: request.scope,
1464
1515
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
1465
1516
  ...(request.replace === true ? { replace: true } : {}),
1517
+ ...(request.env !== undefined ? { env: request.env } : {}),
1466
1518
  metadata: session.metadata,
1467
1519
  });
1468
1520
  await announce(requestId, 'form');
@@ -1504,6 +1556,7 @@ export const createGateway = (options = {}) => {
1504
1556
  name: request.name,
1505
1557
  scope: request.scope,
1506
1558
  ...(request.reason !== undefined ? { reason: request.reason } : {}),
1559
+ ...(request.env !== undefined ? { env: request.env } : {}),
1507
1560
  });
1508
1561
  }
1509
1562
  catch (error) {
@@ -1558,6 +1611,7 @@ export const createGateway = (options = {}) => {
1558
1611
  ...(actor !== undefined ? { actor } : {}),
1559
1612
  via: request.via ?? 'form',
1560
1613
  requestedBy: request.agentId,
1614
+ ...(request.env !== undefined ? { env: request.env, bindingFor: request.agentId } : {}),
1561
1615
  });
1562
1616
  }
1563
1617
  catch (error) {
@@ -1600,7 +1654,12 @@ export const createGateway = (options = {}) => {
1600
1654
  message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
1601
1655
  };
1602
1656
  }
1603
- const store = request.scope === 'agent' ? { agentId: request.agentId } : {};
1657
+ const store = {
1658
+ ...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
1659
+ // The binding the form promised, written with the value, always for
1660
+ // the agent that asked: consent is per agent even for a shared key.
1661
+ ...(request.env !== undefined ? { bind: { agentId: request.agentId, variable: request.env } } : {}),
1662
+ };
1604
1663
  if (request.replace === true) {
1605
1664
  // Re-read under the soul lock, so a grant removed while the form
1606
1665
  // waited (a shared key most of all) is never rotated on its behalf.
@@ -1641,6 +1700,7 @@ export const createGateway = (options = {}) => {
1641
1700
  name: request.name,
1642
1701
  scope: request.scope,
1643
1702
  replace: true,
1703
+ ...(request.env !== undefined ? { env: request.env } : {}),
1644
1704
  ...(input.actor !== undefined ? { actor: input.actor } : {}),
1645
1705
  });
1646
1706
  // The soul already lists it: a replace is only asked for a key held.
@@ -1699,6 +1759,7 @@ export const createGateway = (options = {}) => {
1699
1759
  requestId: input.requestId,
1700
1760
  name: request.name,
1701
1761
  scope: request.scope,
1762
+ ...(request.env !== undefined ? { env: request.env } : {}),
1702
1763
  ...(input.actor !== undefined ? { actor: input.actor } : {}),
1703
1764
  ...(grantError !== undefined ? { grantError } : {}),
1704
1765
  });
@@ -2092,7 +2153,7 @@ export const createGateway = (options = {}) => {
2092
2153
  const idle = new AbortController();
2093
2154
  trackTurn(sessionId, idle);
2094
2155
  if (abortingWith !== undefined) {
2095
- idle.abort(new RunAbortedError(abortingWith));
2156
+ idle.abort(shutdownAbort());
2096
2157
  }
2097
2158
  try {
2098
2159
  return await run(idle.signal);
@@ -2104,7 +2165,7 @@ export const createGateway = (options = {}) => {
2104
2165
  const controller = new AbortController();
2105
2166
  trackTurn(sessionId, controller);
2106
2167
  if (abortingWith !== undefined) {
2107
- controller.abort(new RunAbortedError(abortingWith));
2168
+ controller.abort(shutdownAbort());
2108
2169
  }
2109
2170
  // The external reason travels: a caller that aborted with its own
2110
2171
  // `RunAbortedError` gets it recorded on the session, like the watchdog's.
@@ -2348,6 +2409,22 @@ export const createGateway = (options = {}) => {
2348
2409
  const refusal = () => restartStatus
2349
2410
  ? new GatewayStoppingError('stratusd is restarting and will accept new work once it is back up.', true)
2350
2411
  : new GatewayStoppingError('The gateway is stopping and no longer accepts new work.', false);
2412
+ /**
2413
+ * What the caller of a keyed turn the shutdown cut short hears: the same
2414
+ * refusal, since its message is no more answered than one refused at the
2415
+ * door (a channel keeps it for the next start, as it keeps those), and
2416
+ * worded for a turn that ran and will be continued.
2417
+ */
2418
+ const leftForNext = () => restartStatus
2419
+ ? new GatewayStoppingError('stratusd is restarting; this turn was cut short and continues once it is back up.', true)
2420
+ : new GatewayStoppingError('stratusd is stopping; this turn was cut short and continues when it is started again.', false);
2421
+ /**
2422
+ * The abort a stop's or restart's window ends a turn with. Resumable: a
2423
+ * keyed turn is left for the next start to continue once, as a crash
2424
+ * leaves it, rather than failed (`RunAbortedError.resumable`); every
2425
+ * other turn is failed with the reason, as before.
2426
+ */
2427
+ const shutdownAbort = () => new RunAbortedError(abortingWith, { resumable: true });
2351
2428
  /**
2352
2429
  * The turn id running on each session, for callers that named one.
2353
2430
  *
@@ -2478,6 +2555,27 @@ export const createGateway = (options = {}) => {
2478
2555
  ? runsOnHarness(fallback)
2479
2556
  : runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
2480
2557
  };
2558
+ /**
2559
+ * A keyed turn the shutdown cut short and the runner left for the next
2560
+ * start (`leftForNextStart`): its caller is told so, the way a message
2561
+ * refused at the door is, and keeps it for the next daemon, which
2562
+ * continues the turn. Read back on the chain, so it is this dispatch's
2563
+ * own message the session is still running on.
2564
+ */
2565
+ const settleLeftForNextStart = async (input, turn) => {
2566
+ try {
2567
+ return await turn;
2568
+ }
2569
+ catch (error) {
2570
+ if (input.idempotencyKey !== undefined && error instanceof RunAbortedError && error.resumable) {
2571
+ const left = await store.get(input.sessionId).catch(() => undefined);
2572
+ if (left !== undefined && workItemState(left, input.idempotencyKey) === 'unfinished' && leftForNextStart(left, error)) {
2573
+ throw leftForNext();
2574
+ }
2575
+ }
2576
+ throw error;
2577
+ }
2578
+ };
2481
2579
  /**
2482
2580
  * `subAgent` is the narrowed definition `subAgents.run` built: the turn
2483
2581
  * runs as that, on the parent's resolved provider, with no memory and no
@@ -2546,7 +2644,7 @@ export const createGateway = (options = {}) => {
2546
2644
  // has switched for good, the primary is never called again, and only
2547
2645
  // the fallback counts.
2548
2646
  const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
2549
- return withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
2647
+ return settleLeftForNextStart(input, withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
2550
2648
  // The preflight above (agent refresh, config resolution, session
2551
2649
  // load) awaited filesystem work; the entry check has long passed.
2552
2650
  // Recheck before the runner touches durable state, or a dispatch
@@ -2625,7 +2723,7 @@ export const createGateway = (options = {}) => {
2625
2723
  runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
2626
2724
  signal,
2627
2725
  });
2628
- });
2726
+ }));
2629
2727
  };
2630
2728
  /**
2631
2729
  * Finishes the turns that were parked on a human when this daemon last
@@ -2792,7 +2890,7 @@ export const createGateway = (options = {}) => {
2792
2890
  if (record) {
2793
2891
  trackTurn(sessionId, controller);
2794
2892
  if (abortingWith !== undefined) {
2795
- controller.abort(new RunAbortedError(abortingWith));
2893
+ controller.abort(shutdownAbort());
2796
2894
  }
2797
2895
  }
2798
2896
  try {
@@ -3156,7 +3254,21 @@ export const createGateway = (options = {}) => {
3156
3254
  if (stopping) {
3157
3255
  throw refusal();
3158
3256
  }
3159
- return onSessionChain(input.sessionId, () => dispatchInternal(input));
3257
+ return onSessionChain(input.sessionId, async () => {
3258
+ // A keyed firing (an event trigger's batch) that the session already
3259
+ // holds is that turn, not a new one: a daemon that died after the
3260
+ // turn and before the firing was recorded re-sends it at the next
3261
+ // start, and must answer with the finished turn (or settle the
3262
+ // unfinished one, as the start-up sweep would) instead of running the
3263
+ // event again. The same check the public door makes, on the chain.
3264
+ if (input.idempotencyKey !== undefined) {
3265
+ const repeated = await settleRepeat(input.sessionId, input.idempotencyKey, input.agentId, input.turnId, input.onRepeat);
3266
+ if (repeated !== undefined) {
3267
+ return repeated;
3268
+ }
3269
+ }
3270
+ return dispatchInternal(input);
3271
+ });
3160
3272
  };
3161
3273
  const dispatch = async (input) => {
3162
3274
  if (stopping) {
@@ -3948,6 +4060,7 @@ export const createGateway = (options = {}) => {
3948
4060
  }
3949
4061
  storesClosed = true;
3950
4062
  scheduleStore.close();
4063
+ eventStore?.close();
3951
4064
  store.close();
3952
4065
  };
3953
4066
  /**
@@ -4085,12 +4198,15 @@ export const createGateway = (options = {}) => {
4085
4198
  // The watchdog's own mechanism: the runner fails each session with
4086
4199
  // the reason on the signal, saves it, and emits session.failed —
4087
4200
  // so the next daemon finds a finished turn, not an abandoned one.
4201
+ // Except a keyed turn, which the resumable abort leaves running for
4202
+ // the next daemon to continue once, as a crash would have left it
4203
+ // (`shutdownAbort`); its caller is refused (`leftForNext`).
4088
4204
  // Before the loop, so a turn queued behind one aborted here, which
4089
4205
  // starts as soon as that one settles, is aborted the same way.
4090
4206
  abortingWith = kind === 'stop' ? STOPPING_TURN_ERROR : RESTARTING_TURN_ERROR;
4091
4207
  for (const running of turnControllers.values()) {
4092
4208
  for (const controller of running) {
4093
- controller.abort(new RunAbortedError(abortingWith));
4209
+ controller.abort(shutdownAbort());
4094
4210
  }
4095
4211
  }
4096
4212
  drained = await settlesWithin(drain, remaining());
@@ -4425,6 +4541,29 @@ export const createGateway = (options = {}) => {
4425
4541
  cancelSchedule(scheduleId) {
4426
4542
  return scheduler.cancel(scheduleId);
4427
4543
  },
4544
+ async receiveEvent(request) {
4545
+ if (!eventIntake) {
4546
+ return { status: 404, body: { error: 'unknown_source', message: 'No event source is configured at this path.' } };
4547
+ }
4548
+ const answer = await eventIntake.receive(request);
4549
+ // The delivery and its firings are committed; send them now rather
4550
+ // than at the next tick. The scheduler decides hold or fire, and does
4551
+ // nothing while it is stopped or not yet started.
4552
+ if (answer.body.status === 'matched') {
4553
+ scheduler.kickEvents();
4554
+ }
4555
+ return answer;
4556
+ },
4557
+ eventSummary() {
4558
+ const since = new Date(Date.now() - 86_400_000).toISOString();
4559
+ if (!eventStore || !eventIntake) {
4560
+ return { since, sources: {}, unknownSource: 0 };
4561
+ }
4562
+ return summarizeEvents({ sourceIds: Object.keys(eventSources), since, activity: eventStore.activity(since), counters: eventIntake.counters() });
4563
+ },
4564
+ eventDeliveries(options = {}) {
4565
+ return eventStore?.deliveries(options) ?? [];
4566
+ },
4428
4567
  tools() {
4429
4568
  // From the registry, not from the load records: the risk reported is
4430
4569
  // the one a call will actually be judged at, floor applied, and a