@stratusagent/gateway 0.13.7 → 0.14.1
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/event-intake.d.ts +159 -0
- package/dist/event-intake.d.ts.map +1 -0
- package/dist/event-intake.js +445 -0
- package/dist/event-intake.js.map +1 -0
- package/dist/events.d.ts +256 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +462 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +108 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +451 -24
- package/dist/index.js.map +1 -1
- package/dist/schedules.d.ts +84 -1
- package/dist/schedules.d.ts.map +1 -1
- package/dist/schedules.js +550 -49
- package/dist/schedules.js.map +1 -1
- package/dist/sessions.d.ts +15 -7
- package/dist/sessions.d.ts.map +1 -1
- package/dist/sessions.js +89 -19
- package/dist/sessions.js.map +1 -1
- package/package.json +7 -7
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, createChannelArchiveTool, createChannelCreateTool, createChannelInviteTool, createChannelJoinTool, directMessageReadRefusal, createRecallTool, createRememberTool, createScheduleTools, DELEGATED_BY_METADATA_KEY, DELEGATED_SESSION_ID_MARKER, 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
|
|
@@ -53,6 +57,8 @@ export const STOPPING_TURN_ERROR = 'Run aborted: stratusd is stopping';
|
|
|
53
57
|
* two windows an operator can hit agree.
|
|
54
58
|
*/
|
|
55
59
|
export const DEFAULT_RESTART_DRAIN_TIMEOUT_MS = 30_000;
|
|
60
|
+
/** How long a roster reload waits on one channel's `reload()`. */
|
|
61
|
+
export const DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS = 30_000;
|
|
56
62
|
/**
|
|
57
63
|
* How long a stop lets in-flight turns finish before aborting them. The
|
|
58
64
|
* daemon's documented drain ("up to 30 seconds"), and shorter than the
|
|
@@ -209,6 +215,24 @@ const allAborted = (signals) => {
|
|
|
209
215
|
check();
|
|
210
216
|
return controller.signal;
|
|
211
217
|
};
|
|
218
|
+
/**
|
|
219
|
+
* A value from a form, without the line breaks around it.
|
|
220
|
+
*
|
|
221
|
+
* Forms take multi-line values now, so a one-line key copied with the line
|
|
222
|
+
* break after it would otherwise be stored with it, and fail later inside
|
|
223
|
+
* whatever header or command it lands in. Only line breaks: a value's own
|
|
224
|
+
* spaces are its own, as on the CLI.
|
|
225
|
+
*/
|
|
226
|
+
const formCredentialValue = (value) => value.replace(/^[\r\n]+|[\r\n]+$/g, '');
|
|
227
|
+
/**
|
|
228
|
+
* A PEM block (a private key, a certificate) with no line breaks left, which
|
|
229
|
+
* is what a one-line field makes of a pasted key file. PEM readers need the
|
|
230
|
+
* line after the `-----BEGIN …-----` header, so storing it would only move
|
|
231
|
+
* the failure to the first call that reads the key. Only a value that *is*
|
|
232
|
+
* one: a PEM inside JSON (a service account file) escapes its line breaks,
|
|
233
|
+
* so it has none of its own and is fine as sent.
|
|
234
|
+
*/
|
|
235
|
+
const isFlattenedPem = (value) => /^\s*-----BEGIN [A-Z0-9 ]+-----/.test(value) && !value.includes('\n');
|
|
212
236
|
/**
|
|
213
237
|
* Recorded on a turn the previous process was still running when it died.
|
|
214
238
|
*
|
|
@@ -393,6 +417,40 @@ export const createGateway = (options = {}) => {
|
|
|
393
417
|
// The same database sessions live in — one file to back up — through the
|
|
394
418
|
// schedule store's own connection (see its docs for why).
|
|
395
419
|
const scheduleStore = new SqliteScheduleStore(fleetDbIn(stateDir), { stateHome: stateDir });
|
|
420
|
+
// ---- events (card 590) ---------------------------------------------------
|
|
421
|
+
// Opened for the first source, so a daemon that never takes events never
|
|
422
|
+
// grows the tables: at construction for the config's sources, or when the
|
|
423
|
+
// first plugin feeding a source loads (phase 2), which is at start(),
|
|
424
|
+
// before the scheduler's start-up pass. Its own connection to fleet.db,
|
|
425
|
+
// like schedules'.
|
|
426
|
+
const eventSources = options.events?.sources ?? {};
|
|
427
|
+
/** Sources plugins feed: source id to the package that declared it, filled as each loads. */
|
|
428
|
+
const pluginEventSources = new Map();
|
|
429
|
+
const eventSourceIds = () => [...Object.keys(eventSources), ...pluginEventSources.keys()];
|
|
430
|
+
let eventStore;
|
|
431
|
+
let eventIntake;
|
|
432
|
+
const openEvents = () => {
|
|
433
|
+
if (!eventStore || !eventIntake) {
|
|
434
|
+
eventStore = new SqliteEventStore(fleetDbIn(stateDir), { stateHome: stateDir });
|
|
435
|
+
eventIntake = createEventIntake({
|
|
436
|
+
store: eventStore,
|
|
437
|
+
sources: () => eventSources,
|
|
438
|
+
pluginSources: () => [...pluginEventSources.keys()],
|
|
439
|
+
// The daemon's own credential, read per delivery: a stored or
|
|
440
|
+
// rotated secret works on the next one, with no restart.
|
|
441
|
+
secret: (name) => resolveDaemonCredential(env, name),
|
|
442
|
+
// The triggers are schedules: `schedule.on` rows on this source.
|
|
443
|
+
// Late-bound, because the scheduler is built further down.
|
|
444
|
+
triggers: (sourceId) => scheduler.eventTriggers(sourceId),
|
|
445
|
+
...(options.events?.maxBodyBytes !== undefined ? { maxBodyBytes: options.events.maxBodyBytes } : {}),
|
|
446
|
+
warn,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
return { store: eventStore, intake: eventIntake };
|
|
450
|
+
};
|
|
451
|
+
if (Object.keys(eventSources).length > 0) {
|
|
452
|
+
openEvents();
|
|
453
|
+
}
|
|
396
454
|
/**
|
|
397
455
|
* The started adapter of one kind that speaks for an agent and has the
|
|
398
456
|
* capability asked for. `carriesOthers` says there were capable adapters
|
|
@@ -456,12 +514,28 @@ export const createGateway = (options = {}) => {
|
|
|
456
514
|
await outboundFor(agentId, destination);
|
|
457
515
|
},
|
|
458
516
|
sessionStatus: async (sessionId) => (await store.get(sessionId))?.status,
|
|
517
|
+
// Card 858: the previous firing, for the next one's checkpoint. The
|
|
518
|
+
// reply and its label, never the transcript.
|
|
519
|
+
previousRun: async (sessionId) => {
|
|
520
|
+
const session = await store.get(sessionId);
|
|
521
|
+
if (!session) {
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
const reply = latestTurnReply(session);
|
|
525
|
+
return {
|
|
526
|
+
status: session.status,
|
|
527
|
+
...(session.stoppedEarly !== undefined ? { stoppedEarly: session.stoppedEarly } : {}),
|
|
528
|
+
...(reply !== undefined ? { reply } : {}),
|
|
529
|
+
trust: sessionWriteTrust(session),
|
|
530
|
+
};
|
|
531
|
+
},
|
|
459
532
|
hasAgent: (agentId) => registry.get(agentId) !== undefined,
|
|
460
533
|
...(options.schedules ? { limits: options.schedules } : {}),
|
|
461
534
|
// The same stamp check every turn runs, before a slot is claimed — see
|
|
462
535
|
// `SchedulerRuntimeOptions.ready`.
|
|
463
536
|
ready: () => assertStateCompatible(env),
|
|
464
537
|
paused: () => intakeState.paused,
|
|
538
|
+
...(eventStore ? { events: { store: eventStore, sources: eventSourceIds } } : {}),
|
|
465
539
|
log,
|
|
466
540
|
warn,
|
|
467
541
|
});
|
|
@@ -1142,6 +1216,59 @@ export const createGateway = (options = {}) => {
|
|
|
1142
1216
|
reloads = next.catch(() => undefined);
|
|
1143
1217
|
await next;
|
|
1144
1218
|
};
|
|
1219
|
+
const channelReloadTimeoutMs = options.channelReloadTimeoutMs ?? DEFAULT_CHANNEL_RELOAD_TIMEOUT_MS;
|
|
1220
|
+
/**
|
|
1221
|
+
* After a roster reload, each started channel that can picks up the
|
|
1222
|
+
* agents it carries now (a new agent's stored app, one whose soul or
|
|
1223
|
+
* secrets are gone). One at a time, in start order, so the log reads in
|
|
1224
|
+
* the order an operator configured them. A channel that fails is
|
|
1225
|
+
* logged and the rest still reload: one bad app must not hold back the
|
|
1226
|
+
* others, the rule `startChannels` follows. A channel still reloading
|
|
1227
|
+
* after the bound is left to finish on its own, and what it ends with is
|
|
1228
|
+
* logged then, so a hung Slack call cannot hold every later reload.
|
|
1229
|
+
*/
|
|
1230
|
+
const reloadChannels = async () => {
|
|
1231
|
+
const outcomes = [];
|
|
1232
|
+
for (const adapter of [...startedChannels]) {
|
|
1233
|
+
if (stopping || channelsClosed) {
|
|
1234
|
+
return outcomes;
|
|
1235
|
+
}
|
|
1236
|
+
if (typeof adapter.reload !== 'function') {
|
|
1237
|
+
continue;
|
|
1238
|
+
}
|
|
1239
|
+
const reload = adapter.reload.bind(adapter);
|
|
1240
|
+
// Settles with the failure, never rejects: one that comes after the
|
|
1241
|
+
// bound has nobody awaiting it, and would take the daemon down.
|
|
1242
|
+
const settled = (async () => reload())().then(() => undefined, (error) => (error instanceof Error ? error.message : String(error)));
|
|
1243
|
+
let timer;
|
|
1244
|
+
const bound = new Promise((resolve) => {
|
|
1245
|
+
timer = setTimeout(() => resolve('timeout'), channelReloadTimeoutMs);
|
|
1246
|
+
timer.unref?.();
|
|
1247
|
+
});
|
|
1248
|
+
const outcome = await Promise.race([settled.then((failure) => ({ failure })), bound]);
|
|
1249
|
+
clearTimeout(timer);
|
|
1250
|
+
if (outcome === 'timeout') {
|
|
1251
|
+
warn(`channel ${adapter.name} is still reloading after ${channelReloadTimeoutMs} ms; the roster reload goes on without it`);
|
|
1252
|
+
void settled.then((failure) => {
|
|
1253
|
+
if (failure !== undefined) {
|
|
1254
|
+
warn(`channel ${adapter.name} could not reload: ${failure}`);
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
log(`channel ${adapter.name} finished reloading`);
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
outcomes.push({ name: adapter.name, outcome: 'still-reloading' });
|
|
1261
|
+
continue;
|
|
1262
|
+
}
|
|
1263
|
+
if (outcome.failure !== undefined) {
|
|
1264
|
+
warn(`channel ${adapter.name} could not reload: ${outcome.failure}`);
|
|
1265
|
+
outcomes.push({ name: adapter.name, outcome: 'failed', reason: outcome.failure });
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
outcomes.push({ name: adapter.name, outcome: 'reloaded' });
|
|
1269
|
+
}
|
|
1270
|
+
return outcomes;
|
|
1271
|
+
};
|
|
1145
1272
|
/**
|
|
1146
1273
|
* A session pins its agent id, never a snapshot of the agent: the soul
|
|
1147
1274
|
* file is re-read on every dispatch, so an edited persona (or tool
|
|
@@ -1294,6 +1421,128 @@ export const createGateway = (options = {}) => {
|
|
|
1294
1421
|
}
|
|
1295
1422
|
return adapter.createConversation({ agentId, ...request });
|
|
1296
1423
|
}));
|
|
1424
|
+
// Joining and inviting follow the same rule: the asking agent's own app
|
|
1425
|
+
// does it, so membership stays per app and its token stays in the adapter.
|
|
1426
|
+
tools.register(createChannelJoinTool(async ({ agentId, channel, conversation }) => {
|
|
1427
|
+
const { adapter, carriesOthers } = channelCarrying(channel, agentId, (candidate) => candidate.joinConversation !== undefined);
|
|
1428
|
+
if (!adapter?.joinConversation) {
|
|
1429
|
+
throw new Error(carriesOthers
|
|
1430
|
+
? `No running '${channel}' channel carries agent ${agentId}, so it has no app to join a channel with.`
|
|
1431
|
+
: `No running channel can join '${channel}' conversations.`);
|
|
1432
|
+
}
|
|
1433
|
+
return adapter.joinConversation({ agentId, conversation });
|
|
1434
|
+
}));
|
|
1435
|
+
tools.register(createChannelInviteTool(async ({ agentId, channel, ...request }) => {
|
|
1436
|
+
const { adapter, carriesOthers } = channelCarrying(channel, agentId, (candidate) => candidate.inviteToConversation !== undefined);
|
|
1437
|
+
if (!adapter?.inviteToConversation) {
|
|
1438
|
+
throw new Error(carriesOthers
|
|
1439
|
+
? `No running '${channel}' channel carries agent ${agentId}, so it has no app to invite anyone with.`
|
|
1440
|
+
: `No running channel can invite people to '${channel}' conversations.`);
|
|
1441
|
+
}
|
|
1442
|
+
return adapter.inviteToConversation({ agentId, ...request });
|
|
1443
|
+
}));
|
|
1444
|
+
/**
|
|
1445
|
+
* Why a `channel.archive` call would archive the conversation its work was
|
|
1446
|
+
* asked from, or can't tell, as a sentence; undefined when it may go on.
|
|
1447
|
+
* That conversation is never archived: the answer to it would have
|
|
1448
|
+
* nowhere to go, and the agent can't unarchive.
|
|
1449
|
+
*
|
|
1450
|
+
* An ordinary session is its own origin. A sub-agent's or a delegated
|
|
1451
|
+
* run's is the root turn that started the chain, read off the id the
|
|
1452
|
+
* host minted for it (each child id is its parent's plus a marker, so the
|
|
1453
|
+
* root is the text before the first one), never off metadata a tool in
|
|
1454
|
+
* the slice could rewrite. The id is the one the session had at its first
|
|
1455
|
+
* tool call, for the same reason. The origin's conversation is asked of
|
|
1456
|
+
* the adapter of this kind that carries the origin's agent. When it can't
|
|
1457
|
+
* be told (the root is gone; no adapter, no `sessionConversation`, a
|
|
1458
|
+
* lookup that fails, or no answer for a session whose id is this
|
|
1459
|
+
* channel's), the call is refused: an archive the agent can't undo
|
|
1460
|
+
* doesn't run on a guess. A session that isn't this channel's at all (an
|
|
1461
|
+
* API call, a schedule, a turn on another transport) has no conversation
|
|
1462
|
+
* here to protect.
|
|
1463
|
+
*/
|
|
1464
|
+
const archiveRefusal = async (kind, conversation, session, archiving) => {
|
|
1465
|
+
const unknown = (why) => `${kind} ${conversation} was not archived: ${why}, so Stratus can't tell which conversation this work was asked from, `
|
|
1466
|
+
+ 'and that one is never archived. Ask for the archive from a conversation.';
|
|
1467
|
+
const id = hostIdOf(session);
|
|
1468
|
+
const marks = [SUB_AGENT_SESSION_ID_MARKER, DELEGATED_SESSION_ID_MARKER]
|
|
1469
|
+
.map((marker) => id.indexOf(marker))
|
|
1470
|
+
.filter((at) => at >= 0);
|
|
1471
|
+
let origin = { agentId: session.agent.id, sessionId: id };
|
|
1472
|
+
let adapter = archiving;
|
|
1473
|
+
if (marks.length > 0) {
|
|
1474
|
+
const rootId = id.slice(0, Math.min(...marks));
|
|
1475
|
+
let root;
|
|
1476
|
+
try {
|
|
1477
|
+
root = rootId ? await store.get(rootId) : undefined;
|
|
1478
|
+
}
|
|
1479
|
+
catch (error) {
|
|
1480
|
+
warn(`${id}: could not read the root session ${rootId} to check a channel.archive (${error instanceof Error ? error.message : String(error)})`);
|
|
1481
|
+
root = undefined;
|
|
1482
|
+
}
|
|
1483
|
+
if (!root) {
|
|
1484
|
+
return unknown('this run was started by another session that no longer exists');
|
|
1485
|
+
}
|
|
1486
|
+
origin = { agentId: root.agent.id, sessionId: root.id };
|
|
1487
|
+
adapter = channelCarrying(kind, origin.agentId, () => true).adapter;
|
|
1488
|
+
}
|
|
1489
|
+
// Channel session ids lead with their kind (`channelSessionKey`).
|
|
1490
|
+
const onThisChannel = origin.sessionId.startsWith(`${kind}:`);
|
|
1491
|
+
if (!adapter) {
|
|
1492
|
+
return onThisChannel ? unknown(`no running '${kind}' channel carries the agent this work was asked of`) : undefined;
|
|
1493
|
+
}
|
|
1494
|
+
if (!adapter.sessionConversation) {
|
|
1495
|
+
return unknown(`the running '${kind}' channel can't say which conversation a session is in`);
|
|
1496
|
+
}
|
|
1497
|
+
let origins;
|
|
1498
|
+
try {
|
|
1499
|
+
origins = adapter.sessionConversation(origin.agentId, origin.sessionId);
|
|
1500
|
+
}
|
|
1501
|
+
catch (error) {
|
|
1502
|
+
warn(`${id}: could not tell which conversation ${origin.sessionId} is in to check a channel.archive (${error instanceof Error ? error.message : String(error)})`);
|
|
1503
|
+
return unknown(`the running '${kind}' channel couldn't say which conversation this work was asked from`);
|
|
1504
|
+
}
|
|
1505
|
+
if (origins === undefined) {
|
|
1506
|
+
return onThisChannel ? unknown(`the running '${kind}' channel can't say which of its conversations this work was asked from`) : undefined;
|
|
1507
|
+
}
|
|
1508
|
+
return origins === conversation
|
|
1509
|
+
? `${kind} ${conversation} is the conversation this work was asked from, so it was not archived: `
|
|
1510
|
+
+ 'the reply would have nowhere to go. Ask for the archive from another conversation.'
|
|
1511
|
+
: undefined;
|
|
1512
|
+
};
|
|
1513
|
+
const archivingAdapter = (kind, agentId) => channelCarrying(kind, agentId, (candidate) => candidate.archiveConversation !== undefined);
|
|
1514
|
+
const noArchivingAdapter = (kind, agentId, carriesOthers) => carriesOthers
|
|
1515
|
+
? `No running '${kind}' channel carries agent ${agentId}, so it has no app to archive a channel with.`
|
|
1516
|
+
: `No running channel can archive '${kind}' conversations.`;
|
|
1517
|
+
tools.register(createChannelArchiveTool(async ({ agentId, channel, conversation }, session) => {
|
|
1518
|
+
const { adapter, carriesOthers } = archivingAdapter(channel, agentId);
|
|
1519
|
+
if (!adapter?.archiveConversation) {
|
|
1520
|
+
throw new Error(noArchivingAdapter(channel, agentId, carriesOthers));
|
|
1521
|
+
}
|
|
1522
|
+
const refusal = await archiveRefusal(channel, conversation, session, adapter);
|
|
1523
|
+
if (refusal !== undefined) {
|
|
1524
|
+
throw new Error(refusal);
|
|
1525
|
+
}
|
|
1526
|
+
return adapter.archiveConversation({ agentId, conversation });
|
|
1527
|
+
}, {
|
|
1528
|
+
// The same check before anyone is asked: an approval, Always allow
|
|
1529
|
+
// above all, for an archive that is bound to be refused is a question
|
|
1530
|
+
// with no useful answer. Held as an approval wait, like message.read's,
|
|
1531
|
+
// since it runs before any tool event and may read the store.
|
|
1532
|
+
refusalFor: async ({ agentId, channel, conversation }, session) => {
|
|
1533
|
+
const { adapter, carriesOthers } = archivingAdapter(channel, agentId);
|
|
1534
|
+
if (!adapter?.archiveConversation) {
|
|
1535
|
+
return noArchivingAdapter(channel, agentId, carriesOthers);
|
|
1536
|
+
}
|
|
1537
|
+
const release = holdApprovalWait(session.id);
|
|
1538
|
+
try {
|
|
1539
|
+
return await archiveRefusal(channel, conversation, session, adapter);
|
|
1540
|
+
}
|
|
1541
|
+
finally {
|
|
1542
|
+
release();
|
|
1543
|
+
}
|
|
1544
|
+
},
|
|
1545
|
+
}));
|
|
1297
1546
|
tools.register(createMessageReadTool(async ({ agentId, source, ...window }) => {
|
|
1298
1547
|
// The same carrying rule as outbound: a read goes through the app of
|
|
1299
1548
|
// the agent asking, never whichever adapter of that kind is running,
|
|
@@ -1367,11 +1616,26 @@ export const createGateway = (options = {}) => {
|
|
|
1367
1616
|
throw new Error(`${JSON.stringify(request.name)} is not a credential name. Use letters, digits, dots, dashes, or underscores, `
|
|
1368
1617
|
+ 'starting with a letter, the way the tool that needs it spells it: search.apiKey, github.token.');
|
|
1369
1618
|
}
|
|
1619
|
+
// The stricter rule, for a name the model picked: checked before anyone
|
|
1620
|
+
// is asked, so an approver never sees a form whose binding the store
|
|
1621
|
+
// would refuse, and so a variable that changes what a judged command
|
|
1622
|
+
// does (NODE_OPTIONS) is never offered at all.
|
|
1623
|
+
if (request.env !== undefined) {
|
|
1624
|
+
const problem = credentialVariableProblem(request.env, { requested: true });
|
|
1625
|
+
if (problem !== undefined) {
|
|
1626
|
+
throw new Error(`${problem} Nothing is pending.`);
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1370
1629
|
// A key already stored is not something to ask a person for again: the
|
|
1371
1630
|
// form only adds, so it could only fail. Say what is actually missing.
|
|
1372
1631
|
const source = await namedCredentialSource(env, agentId, request.name);
|
|
1373
1632
|
const stored = source !== undefined;
|
|
1374
1633
|
const granted = session.agent.credentials?.includes(request.name) === true;
|
|
1634
|
+
// Binding a key the agent already holds asks for no value, and every
|
|
1635
|
+
// form today has a value field, so phase 1 leaves it to the machine.
|
|
1636
|
+
const bindOnMachine = request.env !== undefined
|
|
1637
|
+
? ` 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.`
|
|
1638
|
+
: '';
|
|
1375
1639
|
if (request.replace === true) {
|
|
1376
1640
|
// Checked here, before anyone is asked, so an approver is never shown
|
|
1377
1641
|
// a form whose answer could only be refused.
|
|
@@ -1394,13 +1658,18 @@ export const createGateway = (options = {}) => {
|
|
|
1394
1658
|
}
|
|
1395
1659
|
}
|
|
1396
1660
|
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
|
|
1661
|
+
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}`
|
|
1662
|
+
+ (request.env !== undefined ? ' Nothing is pending.' : ''));
|
|
1398
1663
|
}
|
|
1399
1664
|
else if (stored) {
|
|
1400
1665
|
throw new Error(`${request.name} is already ${source === 'environment' ? "supplied by the daemon's environment" : 'stored'} but not granted to you. `
|
|
1401
|
-
+
|
|
1666
|
+
+ `Ask your operator to add it to the credentials list in your soul; a form would only refuse to store it again.${bindOnMachine}`);
|
|
1402
1667
|
}
|
|
1403
|
-
const onMachine =
|
|
1668
|
+
const onMachine = request.env === undefined
|
|
1669
|
+
? `\`stratus credential set ${request.name}${request.scope === 'agent' ? ` --agent ${quoteShellArg(agentId)}` : ''}\``
|
|
1670
|
+
: request.scope === 'agent'
|
|
1671
|
+
? `\`stratus credential set ${request.name} --agent ${quoteShellArg(agentId)} --env ${request.env}\``
|
|
1672
|
+
: `\`stratus credential set ${request.name}\`, then \`stratus credential env ${request.name} ${request.env} --agent ${quoteShellArg(agentId)}\``;
|
|
1404
1673
|
const record = {
|
|
1405
1674
|
agentId,
|
|
1406
1675
|
sessionId: session.id,
|
|
@@ -1408,6 +1677,7 @@ export const createGateway = (options = {}) => {
|
|
|
1408
1677
|
scope: request.scope,
|
|
1409
1678
|
soulPath,
|
|
1410
1679
|
...(request.replace === true ? { replace: true } : {}),
|
|
1680
|
+
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1411
1681
|
};
|
|
1412
1682
|
const announce = async (requestId, via) => {
|
|
1413
1683
|
await bus.emit({
|
|
@@ -1420,6 +1690,7 @@ export const createGateway = (options = {}) => {
|
|
|
1420
1690
|
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1421
1691
|
via,
|
|
1422
1692
|
...(request.replace === true ? { replace: true } : {}),
|
|
1693
|
+
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1423
1694
|
...(session.metadata ? { metadata: session.metadata } : {}),
|
|
1424
1695
|
});
|
|
1425
1696
|
};
|
|
@@ -1440,11 +1711,14 @@ export const createGateway = (options = {}) => {
|
|
|
1440
1711
|
const kind = session.metadata?.channel;
|
|
1441
1712
|
const { adapter } = typeof kind === 'string'
|
|
1442
1713
|
? channelCarrying(kind, agentId, (candidate) => candidate.requestCredential !== undefined
|
|
1443
|
-
&& (request.replace !== true || candidate.credentialReplaceForms === true)
|
|
1714
|
+
&& (request.replace !== true || candidate.credentialReplaceForms === true)
|
|
1715
|
+
// A form that can't say the key will be shell-readable must not
|
|
1716
|
+
// be the approval for it: a link says it instead.
|
|
1717
|
+
&& (request.env === undefined || candidate.credentialEnvForms === true))
|
|
1444
1718
|
: { adapter: undefined };
|
|
1445
1719
|
if (!adapter?.requestCredential || !session.metadata) {
|
|
1446
1720
|
formUnavailable = typeof kind === 'string'
|
|
1447
|
-
? `This conversation's channel (${kind}) cannot show your operator a ${request.replace === true ? 'replacement' : 'credential'} form here.`
|
|
1721
|
+
? `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
1722
|
: 'This conversation is not in a channel that can show your operator a form.';
|
|
1449
1723
|
}
|
|
1450
1724
|
else {
|
|
@@ -1463,6 +1737,7 @@ export const createGateway = (options = {}) => {
|
|
|
1463
1737
|
scope: request.scope,
|
|
1464
1738
|
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1465
1739
|
...(request.replace === true ? { replace: true } : {}),
|
|
1740
|
+
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1466
1741
|
metadata: session.metadata,
|
|
1467
1742
|
});
|
|
1468
1743
|
await announce(requestId, 'form');
|
|
@@ -1504,6 +1779,7 @@ export const createGateway = (options = {}) => {
|
|
|
1504
1779
|
name: request.name,
|
|
1505
1780
|
scope: request.scope,
|
|
1506
1781
|
...(request.reason !== undefined ? { reason: request.reason } : {}),
|
|
1782
|
+
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1507
1783
|
});
|
|
1508
1784
|
}
|
|
1509
1785
|
catch (error) {
|
|
@@ -1558,6 +1834,7 @@ export const createGateway = (options = {}) => {
|
|
|
1558
1834
|
...(actor !== undefined ? { actor } : {}),
|
|
1559
1835
|
via: request.via ?? 'form',
|
|
1560
1836
|
requestedBy: request.agentId,
|
|
1837
|
+
...(request.env !== undefined ? { env: request.env, bindingFor: request.agentId } : {}),
|
|
1561
1838
|
});
|
|
1562
1839
|
}
|
|
1563
1840
|
catch (error) {
|
|
@@ -1600,7 +1877,24 @@ export const createGateway = (options = {}) => {
|
|
|
1600
1877
|
message: `${request.soulPath} now belongs to ${owner}, not ${request.agentId}, so nothing was stored. Ask ${request.agentId} to request it again.`,
|
|
1601
1878
|
};
|
|
1602
1879
|
}
|
|
1603
|
-
const
|
|
1880
|
+
const value = formCredentialValue(input.value);
|
|
1881
|
+
if (isFlattenedPem(value)) {
|
|
1882
|
+
// Refused, not retired: the same form can still take the key whole.
|
|
1883
|
+
const command = request.scope === 'agent'
|
|
1884
|
+
? `stratus credential set ${request.name} --agent ${quoteShellArg(request.agentId)} < key.pem`
|
|
1885
|
+
: `stratus credential set ${request.name} < key.pem`;
|
|
1886
|
+
return {
|
|
1887
|
+
ok: false,
|
|
1888
|
+
message: 'That looks like a PEM key whose line breaks were lost, so nothing was stored: whatever reads it would fail. '
|
|
1889
|
+
+ `Paste it again from the original file with its line breaks, or store it on the machine: ${command}.`,
|
|
1890
|
+
};
|
|
1891
|
+
}
|
|
1892
|
+
const store = {
|
|
1893
|
+
...(request.scope === 'agent' ? { agentId: request.agentId } : {}),
|
|
1894
|
+
// The binding the form promised, written with the value, always for
|
|
1895
|
+
// the agent that asked: consent is per agent even for a shared key.
|
|
1896
|
+
...(request.env !== undefined ? { bind: { agentId: request.agentId, variable: request.env } } : {}),
|
|
1897
|
+
};
|
|
1604
1898
|
if (request.replace === true) {
|
|
1605
1899
|
// Re-read under the soul lock, so a grant removed while the form
|
|
1606
1900
|
// waited (a shared key most of all) is never rotated on its behalf.
|
|
@@ -1611,7 +1905,7 @@ export const createGateway = (options = {}) => {
|
|
|
1611
1905
|
if (soul.agent.id !== request.agentId || soul.agent.credentials?.includes(request.name) !== true) {
|
|
1612
1906
|
return false;
|
|
1613
1907
|
}
|
|
1614
|
-
await replaceNamedCredential(env, { name: request.name, value
|
|
1908
|
+
await replaceNamedCredential(env, { name: request.name, value, ...store });
|
|
1615
1909
|
return true;
|
|
1616
1910
|
});
|
|
1617
1911
|
}
|
|
@@ -1641,6 +1935,7 @@ export const createGateway = (options = {}) => {
|
|
|
1641
1935
|
name: request.name,
|
|
1642
1936
|
scope: request.scope,
|
|
1643
1937
|
replace: true,
|
|
1938
|
+
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1644
1939
|
...(input.actor !== undefined ? { actor: input.actor } : {}),
|
|
1645
1940
|
});
|
|
1646
1941
|
// The soul already lists it: a replace is only asked for a key held.
|
|
@@ -1649,7 +1944,7 @@ export const createGateway = (options = {}) => {
|
|
|
1649
1944
|
try {
|
|
1650
1945
|
await addNamedCredential(env, {
|
|
1651
1946
|
name: request.name,
|
|
1652
|
-
value
|
|
1947
|
+
value,
|
|
1653
1948
|
...store,
|
|
1654
1949
|
});
|
|
1655
1950
|
}
|
|
@@ -1699,6 +1994,7 @@ export const createGateway = (options = {}) => {
|
|
|
1699
1994
|
requestId: input.requestId,
|
|
1700
1995
|
name: request.name,
|
|
1701
1996
|
scope: request.scope,
|
|
1997
|
+
...(request.env !== undefined ? { env: request.env } : {}),
|
|
1702
1998
|
...(input.actor !== undefined ? { actor: input.actor } : {}),
|
|
1703
1999
|
...(grantError !== undefined ? { grantError } : {}),
|
|
1704
2000
|
});
|
|
@@ -2092,7 +2388,7 @@ export const createGateway = (options = {}) => {
|
|
|
2092
2388
|
const idle = new AbortController();
|
|
2093
2389
|
trackTurn(sessionId, idle);
|
|
2094
2390
|
if (abortingWith !== undefined) {
|
|
2095
|
-
idle.abort(
|
|
2391
|
+
idle.abort(shutdownAbort());
|
|
2096
2392
|
}
|
|
2097
2393
|
try {
|
|
2098
2394
|
return await run(idle.signal);
|
|
@@ -2104,7 +2400,7 @@ export const createGateway = (options = {}) => {
|
|
|
2104
2400
|
const controller = new AbortController();
|
|
2105
2401
|
trackTurn(sessionId, controller);
|
|
2106
2402
|
if (abortingWith !== undefined) {
|
|
2107
|
-
controller.abort(
|
|
2403
|
+
controller.abort(shutdownAbort());
|
|
2108
2404
|
}
|
|
2109
2405
|
// The external reason travels: a caller that aborted with its own
|
|
2110
2406
|
// `RunAbortedError` gets it recorded on the session, like the watchdog's.
|
|
@@ -2348,6 +2644,22 @@ export const createGateway = (options = {}) => {
|
|
|
2348
2644
|
const refusal = () => restartStatus
|
|
2349
2645
|
? new GatewayStoppingError('stratusd is restarting and will accept new work once it is back up.', true)
|
|
2350
2646
|
: new GatewayStoppingError('The gateway is stopping and no longer accepts new work.', false);
|
|
2647
|
+
/**
|
|
2648
|
+
* What the caller of a keyed turn the shutdown cut short hears: the same
|
|
2649
|
+
* refusal, since its message is no more answered than one refused at the
|
|
2650
|
+
* door (a channel keeps it for the next start, as it keeps those), and
|
|
2651
|
+
* worded for a turn that ran and will be continued.
|
|
2652
|
+
*/
|
|
2653
|
+
const leftForNext = () => restartStatus
|
|
2654
|
+
? new GatewayStoppingError('stratusd is restarting; this turn was cut short and continues once it is back up.', true)
|
|
2655
|
+
: new GatewayStoppingError('stratusd is stopping; this turn was cut short and continues when it is started again.', false);
|
|
2656
|
+
/**
|
|
2657
|
+
* The abort a stop's or restart's window ends a turn with. Resumable: a
|
|
2658
|
+
* keyed turn is left for the next start to continue once, as a crash
|
|
2659
|
+
* leaves it, rather than failed (`RunAbortedError.resumable`); every
|
|
2660
|
+
* other turn is failed with the reason, as before.
|
|
2661
|
+
*/
|
|
2662
|
+
const shutdownAbort = () => new RunAbortedError(abortingWith, { resumable: true });
|
|
2351
2663
|
/**
|
|
2352
2664
|
* The turn id running on each session, for callers that named one.
|
|
2353
2665
|
*
|
|
@@ -2478,6 +2790,27 @@ export const createGateway = (options = {}) => {
|
|
|
2478
2790
|
? runsOnHarness(fallback)
|
|
2479
2791
|
: runsOnHarness(config) || (fallback !== undefined && runsOnHarness(fallback));
|
|
2480
2792
|
};
|
|
2793
|
+
/**
|
|
2794
|
+
* A keyed turn the shutdown cut short and the runner left for the next
|
|
2795
|
+
* start (`leftForNextStart`): its caller is told so, the way a message
|
|
2796
|
+
* refused at the door is, and keeps it for the next daemon, which
|
|
2797
|
+
* continues the turn. Read back on the chain, so it is this dispatch's
|
|
2798
|
+
* own message the session is still running on.
|
|
2799
|
+
*/
|
|
2800
|
+
const settleLeftForNextStart = async (input, turn) => {
|
|
2801
|
+
try {
|
|
2802
|
+
return await turn;
|
|
2803
|
+
}
|
|
2804
|
+
catch (error) {
|
|
2805
|
+
if (input.idempotencyKey !== undefined && error instanceof RunAbortedError && error.resumable) {
|
|
2806
|
+
const left = await store.get(input.sessionId).catch(() => undefined);
|
|
2807
|
+
if (left !== undefined && workItemState(left, input.idempotencyKey) === 'unfinished' && leftForNextStart(left, error)) {
|
|
2808
|
+
throw leftForNext();
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
throw error;
|
|
2812
|
+
}
|
|
2813
|
+
};
|
|
2481
2814
|
/**
|
|
2482
2815
|
* `subAgent` is the narrowed definition `subAgents.run` built: the turn
|
|
2483
2816
|
* runs as that, on the parent's resolved provider, with no memory and no
|
|
@@ -2546,7 +2879,7 @@ export const createGateway = (options = {}) => {
|
|
|
2546
2879
|
// has switched for good, the primary is never called again, and only
|
|
2547
2880
|
// the fallback counts.
|
|
2548
2881
|
const hostedLoop = input.idempotencyKey !== undefined && reachesHarness(config, switchedToFallback);
|
|
2549
|
-
return withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
|
|
2882
|
+
return settleLeftForNextStart(input, withWatchdog(input.sessionId, input.signal, effectiveStreams, fallbackStreams, async (signal) => {
|
|
2550
2883
|
// The preflight above (agent refresh, config resolution, session
|
|
2551
2884
|
// load) awaited filesystem work; the entry check has long passed.
|
|
2552
2885
|
// Recheck before the runner touches durable state, or a dispatch
|
|
@@ -2625,7 +2958,7 @@ export const createGateway = (options = {}) => {
|
|
|
2625
2958
|
runtime: runtimeContextFor(source, config, switchedToFallback, input.metadata, subAgent !== undefined),
|
|
2626
2959
|
signal,
|
|
2627
2960
|
});
|
|
2628
|
-
});
|
|
2961
|
+
}));
|
|
2629
2962
|
};
|
|
2630
2963
|
/**
|
|
2631
2964
|
* Finishes the turns that were parked on a human when this daemon last
|
|
@@ -2792,7 +3125,7 @@ export const createGateway = (options = {}) => {
|
|
|
2792
3125
|
if (record) {
|
|
2793
3126
|
trackTurn(sessionId, controller);
|
|
2794
3127
|
if (abortingWith !== undefined) {
|
|
2795
|
-
controller.abort(
|
|
3128
|
+
controller.abort(shutdownAbort());
|
|
2796
3129
|
}
|
|
2797
3130
|
}
|
|
2798
3131
|
try {
|
|
@@ -3156,7 +3489,21 @@ export const createGateway = (options = {}) => {
|
|
|
3156
3489
|
if (stopping) {
|
|
3157
3490
|
throw refusal();
|
|
3158
3491
|
}
|
|
3159
|
-
return onSessionChain(input.sessionId, () =>
|
|
3492
|
+
return onSessionChain(input.sessionId, async () => {
|
|
3493
|
+
// A keyed firing (an event trigger's batch) that the session already
|
|
3494
|
+
// holds is that turn, not a new one: a daemon that died after the
|
|
3495
|
+
// turn and before the firing was recorded re-sends it at the next
|
|
3496
|
+
// start, and must answer with the finished turn (or settle the
|
|
3497
|
+
// unfinished one, as the start-up sweep would) instead of running the
|
|
3498
|
+
// event again. The same check the public door makes, on the chain.
|
|
3499
|
+
if (input.idempotencyKey !== undefined) {
|
|
3500
|
+
const repeated = await settleRepeat(input.sessionId, input.idempotencyKey, input.agentId, input.turnId, input.onRepeat);
|
|
3501
|
+
if (repeated !== undefined) {
|
|
3502
|
+
return repeated;
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
return dispatchInternal(input);
|
|
3506
|
+
});
|
|
3160
3507
|
};
|
|
3161
3508
|
const dispatch = async (input) => {
|
|
3162
3509
|
if (stopping) {
|
|
@@ -3642,6 +3989,41 @@ export const createGateway = (options = {}) => {
|
|
|
3642
3989
|
onRead: (method) => log(`fleet read: ${method} by plugin ${packageName}`),
|
|
3643
3990
|
}),
|
|
3644
3991
|
providerCallGuards,
|
|
3992
|
+
eventSources: {
|
|
3993
|
+
reserved: Object.keys(eventSources),
|
|
3994
|
+
register: (packageName, sourceIds) => {
|
|
3995
|
+
if (!eventStore) {
|
|
3996
|
+
// The first source on a daemon whose config has none: the
|
|
3997
|
+
// scheduler was built without events, so it takes them now,
|
|
3998
|
+
// before its start-up pass reads what is waiting. First, so a
|
|
3999
|
+
// store that won't open refuses the plugin with no source left
|
|
4000
|
+
// behind.
|
|
4001
|
+
scheduler.attachEvents({ store: openEvents().store, sources: eventSourceIds });
|
|
4002
|
+
}
|
|
4003
|
+
for (const sourceId of sourceIds) {
|
|
4004
|
+
pluginEventSources.set(sourceId, packageName);
|
|
4005
|
+
}
|
|
4006
|
+
},
|
|
4007
|
+
ingest: async (packageName, sourceId, event) => {
|
|
4008
|
+
if (pluginEventSources.get(sourceId) !== packageName) {
|
|
4009
|
+
throw new Error(`Plugin ${packageName} does not feed event source "${sourceId}" on this daemon.`);
|
|
4010
|
+
}
|
|
4011
|
+
// The store closes last in a stop. Until then an event is kept
|
|
4012
|
+
// and goes out from the next start; after, refused, so a poller
|
|
4013
|
+
// keeps its cursor where it was.
|
|
4014
|
+
if (storesClosed) {
|
|
4015
|
+
throw new Error('stratusd has stopped, so this event was not stored. Feed it again once the daemon is back.');
|
|
4016
|
+
}
|
|
4017
|
+
const answer = openEvents().intake.ingest(sourceId, event);
|
|
4018
|
+
// Sent now rather than at the next tick; the scheduler decides
|
|
4019
|
+
// hold or fire, and does nothing while it is stopped or not yet
|
|
4020
|
+
// started.
|
|
4021
|
+
if (answer.status === 'matched') {
|
|
4022
|
+
scheduler.kickEvents();
|
|
4023
|
+
}
|
|
4024
|
+
return answer;
|
|
4025
|
+
},
|
|
4026
|
+
},
|
|
3645
4027
|
});
|
|
3646
4028
|
loadedPlugins = result.loaded;
|
|
3647
4029
|
pluginFailures = result.failures;
|
|
@@ -3654,6 +4036,7 @@ export const createGateway = (options = {}) => {
|
|
|
3654
4036
|
...plugin.contributions.memory.map((name) => `memory store ${name}`),
|
|
3655
4037
|
...plugin.contributions.executors.map((name) => `executor ${name}`),
|
|
3656
4038
|
...(plugin.providerCallGuards > 0 ? ['guards model calls'] : []),
|
|
4039
|
+
...plugin.eventSources.map((sourceId) => `event source ${sourceId}`),
|
|
3657
4040
|
];
|
|
3658
4041
|
log(`plugin ${plugin.package} loaded — ${contributed.join(', ') || 'no tools'}`);
|
|
3659
4042
|
}
|
|
@@ -3948,6 +4331,7 @@ export const createGateway = (options = {}) => {
|
|
|
3948
4331
|
}
|
|
3949
4332
|
storesClosed = true;
|
|
3950
4333
|
scheduleStore.close();
|
|
4334
|
+
eventStore?.close();
|
|
3951
4335
|
store.close();
|
|
3952
4336
|
};
|
|
3953
4337
|
/**
|
|
@@ -4085,12 +4469,15 @@ export const createGateway = (options = {}) => {
|
|
|
4085
4469
|
// The watchdog's own mechanism: the runner fails each session with
|
|
4086
4470
|
// the reason on the signal, saves it, and emits session.failed —
|
|
4087
4471
|
// so the next daemon finds a finished turn, not an abandoned one.
|
|
4472
|
+
// Except a keyed turn, which the resumable abort leaves running for
|
|
4473
|
+
// the next daemon to continue once, as a crash would have left it
|
|
4474
|
+
// (`shutdownAbort`); its caller is refused (`leftForNext`).
|
|
4088
4475
|
// Before the loop, so a turn queued behind one aborted here, which
|
|
4089
4476
|
// starts as soon as that one settles, is aborted the same way.
|
|
4090
4477
|
abortingWith = kind === 'stop' ? STOPPING_TURN_ERROR : RESTARTING_TURN_ERROR;
|
|
4091
4478
|
for (const running of turnControllers.values()) {
|
|
4092
4479
|
for (const controller of running) {
|
|
4093
|
-
controller.abort(
|
|
4480
|
+
controller.abort(shutdownAbort());
|
|
4094
4481
|
}
|
|
4095
4482
|
}
|
|
4096
4483
|
drained = await settlesWithin(drain, remaining());
|
|
@@ -4332,12 +4719,23 @@ export const createGateway = (options = {}) => {
|
|
|
4332
4719
|
return registry.list();
|
|
4333
4720
|
},
|
|
4334
4721
|
async reloadRoster() {
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4722
|
+
return (await gateway.reloadRosterAndChannels()).agents;
|
|
4723
|
+
},
|
|
4724
|
+
async reloadRosterAndChannels() {
|
|
4725
|
+
let roster = [];
|
|
4726
|
+
let channels = [];
|
|
4727
|
+
await reloadsInOrder(async () => {
|
|
4728
|
+
// Under the soul lock, so a repoint cannot land between a credential
|
|
4729
|
+
// grant's check of the served path and its write (`grantSoulCredential`).
|
|
4730
|
+
await withSoulFileLock(loadRoster);
|
|
4731
|
+
roster = registry.list();
|
|
4732
|
+
log(`roster reloaded — ${roster.length} agent(s): ${roster.map((agent) => agent.name).join(', ')}`);
|
|
4733
|
+
// Outside the soul lock, which guards files and must not wait on a
|
|
4734
|
+
// network; inside the reload chain, so two reloads never interleave
|
|
4735
|
+
// their channel work (the bound keeps one from holding it).
|
|
4736
|
+
channels = await reloadChannels();
|
|
4737
|
+
});
|
|
4738
|
+
return { agents: roster, channels };
|
|
4341
4739
|
},
|
|
4342
4740
|
async reloadSkills() {
|
|
4343
4741
|
try {
|
|
@@ -4425,6 +4823,35 @@ export const createGateway = (options = {}) => {
|
|
|
4425
4823
|
cancelSchedule(scheduleId) {
|
|
4426
4824
|
return scheduler.cancel(scheduleId);
|
|
4427
4825
|
},
|
|
4826
|
+
async receiveEvent(request) {
|
|
4827
|
+
if (!eventIntake) {
|
|
4828
|
+
return { status: 404, body: { error: 'unknown_source', message: 'No event source is configured at this path.' } };
|
|
4829
|
+
}
|
|
4830
|
+
const answer = await eventIntake.receive(request);
|
|
4831
|
+
// The delivery and its firings are committed; send them now rather
|
|
4832
|
+
// than at the next tick. The scheduler decides hold or fire, and does
|
|
4833
|
+
// nothing while it is stopped or not yet started.
|
|
4834
|
+
if (answer.body.status === 'matched') {
|
|
4835
|
+
scheduler.kickEvents();
|
|
4836
|
+
}
|
|
4837
|
+
return answer;
|
|
4838
|
+
},
|
|
4839
|
+
eventSummary() {
|
|
4840
|
+
const since = new Date(Date.now() - 86_400_000).toISOString();
|
|
4841
|
+
if (!eventStore || !eventIntake) {
|
|
4842
|
+
return { since, sources: {}, unknownSource: 0 };
|
|
4843
|
+
}
|
|
4844
|
+
return summarizeEvents({
|
|
4845
|
+
sourceIds: eventSourceIds(),
|
|
4846
|
+
plugins: Object.fromEntries(pluginEventSources),
|
|
4847
|
+
since,
|
|
4848
|
+
activity: eventStore.activity(since),
|
|
4849
|
+
counters: eventIntake.counters(),
|
|
4850
|
+
});
|
|
4851
|
+
},
|
|
4852
|
+
eventDeliveries(options = {}) {
|
|
4853
|
+
return eventStore?.deliveries(options) ?? [];
|
|
4854
|
+
},
|
|
4428
4855
|
tools() {
|
|
4429
4856
|
// From the registry, not from the load records: the risk reported is
|
|
4430
4857
|
// the one a call will actually be judged at, floor applied, and a
|