@agent-relay/factory 0.1.17 → 0.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -10
- package/dist/cli/fleet.d.ts +8 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +150 -3
- package/dist/cli/fleet.js.map +1 -1
- package/dist/dispatch/templates.d.ts +14 -0
- package/dist/dispatch/templates.d.ts.map +1 -1
- package/dist/dispatch/templates.js +59 -3
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/fleet/create-fleet.d.ts +2 -0
- package/dist/fleet/create-fleet.d.ts.map +1 -1
- package/dist/fleet/create-fleet.js +5 -1
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/internal-fleet-client.d.ts +1 -0
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +3 -0
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +52 -63
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +319 -323
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/fleet/relay-workspace-key.d.ts +5 -0
- package/dist/fleet/relay-workspace-key.d.ts.map +1 -1
- package/dist/fleet/relay-workspace-key.js +7 -0
- package/dist/fleet/relay-workspace-key.js.map +1 -1
- package/dist/github/index.d.ts +2 -0
- package/dist/github/index.d.ts.map +1 -1
- package/dist/github/index.js +1 -0
- package/dist/github/index.js.map +1 -1
- package/dist/github/standalone-babysitter.d.ts +33 -0
- package/dist/github/standalone-babysitter.d.ts.map +1 -0
- package/dist/github/standalone-babysitter.js +214 -0
- package/dist/github/standalone-babysitter.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/node/factory-node.d.ts.map +1 -1
- package/dist/node/factory-node.js +14 -0
- package/dist/node/factory-node.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +27 -1
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/ports/fleet.d.ts +12 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/testing/fakes.d.ts +18 -0
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +26 -0
- package/dist/testing/fakes.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -7
|
@@ -1,74 +1,136 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AgentRelay } from '@agent-relay/sdk';
|
|
2
|
+
import { resolveRelayAgentToken, resolveRelayWorkspaceKey } from './relay-workspace-key.js';
|
|
2
3
|
const knownCapabilities = new Set(['spawn:claude', 'spawn:codex', 'workflow:run']);
|
|
3
4
|
const openStatuses = new Set(['pending', 'dispatched', 'invoked']);
|
|
4
5
|
const terminalStatuses = new Set(['completed', 'failed', 'denied']);
|
|
5
|
-
const
|
|
6
|
+
const DEFAULT_AGENT_NAME = 'factory';
|
|
7
|
+
const DEFAULT_SPAWN_ACK_TIMEOUT_MS = 5 * 60_000;
|
|
6
8
|
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
9
|
+
const DEFAULT_EXIT_WATCH_INTERVAL_MS = 15_000;
|
|
10
|
+
// 2× the engine's 45s node-liveness TTL so one missed heartbeat sweep cannot
|
|
11
|
+
// synthesize a false exit.
|
|
12
|
+
const DEFAULT_NODE_OFFLINE_GRACE_MS = 90_000;
|
|
13
|
+
const DEFAULT_REGISTRATION_GRACE_MS = 60_000;
|
|
7
14
|
export class RelayFleetClient {
|
|
8
15
|
#options;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
// token is configured. The token check lives in HttpRelayFleetTransport's
|
|
12
|
-
// constructor and only fires when a method actually needs the transport.
|
|
13
|
-
#transport;
|
|
14
|
-
#spawnActionName;
|
|
15
|
-
#releaseActionName;
|
|
16
|
-
#completionTimeoutMs;
|
|
16
|
+
#agentName;
|
|
17
|
+
#spawnAckTimeoutMs;
|
|
17
18
|
#pollIntervalMs;
|
|
19
|
+
#exitWatchIntervalMs;
|
|
20
|
+
#nodeOfflineGraceMs;
|
|
21
|
+
#registrationGraceMs;
|
|
22
|
+
#createRelay;
|
|
23
|
+
#now;
|
|
18
24
|
#sleep;
|
|
25
|
+
#log;
|
|
19
26
|
#agentExitListeners = new Set();
|
|
20
27
|
#deliveryFailedListeners = new Set();
|
|
21
28
|
#agentMessageListeners = new Set();
|
|
22
29
|
#eventUnsubscribers = [];
|
|
23
|
-
#
|
|
30
|
+
#tracked = new Map();
|
|
31
|
+
// Resolved lazily on first network use so constructing the client (and
|
|
32
|
+
// therefore createFleet({ backend: 'relay' })) never throws merely because no
|
|
33
|
+
// token is configured.
|
|
34
|
+
#messaging;
|
|
35
|
+
#messagingReady;
|
|
36
|
+
#eventsStarted = false;
|
|
24
37
|
#disposed = false;
|
|
38
|
+
#watchTimer;
|
|
39
|
+
#reconciling;
|
|
25
40
|
constructor(options = {}) {
|
|
26
41
|
this.#options = options;
|
|
27
|
-
this.#
|
|
28
|
-
this.#
|
|
29
|
-
this.#completionTimeoutMs = options.completionTimeoutMs ?? DEFAULT_COMPLETION_TIMEOUT_MS;
|
|
42
|
+
this.#agentName = options.agentName ?? DEFAULT_AGENT_NAME;
|
|
43
|
+
this.#spawnAckTimeoutMs = options.spawnAckTimeoutMs ?? DEFAULT_SPAWN_ACK_TIMEOUT_MS;
|
|
30
44
|
this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
45
|
+
this.#exitWatchIntervalMs = options.exitWatchIntervalMs ?? DEFAULT_EXIT_WATCH_INTERVAL_MS;
|
|
46
|
+
this.#nodeOfflineGraceMs = options.nodeOfflineGraceMs ?? DEFAULT_NODE_OFFLINE_GRACE_MS;
|
|
47
|
+
this.#registrationGraceMs = options.registrationGraceMs ?? DEFAULT_REGISTRATION_GRACE_MS;
|
|
48
|
+
this.#createRelay = options.createRelay ?? ((relayOptions) => new AgentRelay(relayOptions));
|
|
49
|
+
this.#now = options.now ?? Date.now;
|
|
31
50
|
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
32
|
-
this.#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
51
|
+
this.#log = options.log ?? (() => { });
|
|
52
|
+
this.#messaging = options.messaging;
|
|
53
|
+
}
|
|
54
|
+
/** Agents spawned through this client that have not exited or been released. */
|
|
55
|
+
trackedAgents() {
|
|
56
|
+
return this.#tracked;
|
|
57
|
+
}
|
|
58
|
+
/** Re-adopt agents recorded in the in-flight registry after a restart. */
|
|
59
|
+
hydrateTracked(agents) {
|
|
60
|
+
for (const agent of agents) {
|
|
61
|
+
if (this.#tracked.has(agent.name))
|
|
62
|
+
continue;
|
|
63
|
+
// spawnedAtMs of 0 skips the registration grace: a hydrated agent was
|
|
64
|
+
// registered long ago, so absence from the roster is a real exit.
|
|
65
|
+
this.#tracked.set(agent.name, {
|
|
66
|
+
invocationId: agent.invocationId,
|
|
67
|
+
node: agent.node,
|
|
68
|
+
spawnedAtMs: 0,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
this.#syncExitWatcher();
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Reconcile tracked agents against the engine roster, synthesizing exits for
|
|
75
|
+
* agents that went offline (or whose node died) without a push signal. The
|
|
76
|
+
* exit watcher runs this on an interval; callers may invoke it directly for
|
|
77
|
+
* a deterministic sweep (startup recovery, tests).
|
|
78
|
+
*/
|
|
79
|
+
reconcileTrackedAgents() {
|
|
80
|
+
this.#reconciling ??= this.#reconcileTracked().finally(() => {
|
|
81
|
+
this.#reconciling = undefined;
|
|
82
|
+
});
|
|
83
|
+
return this.#reconciling;
|
|
37
84
|
}
|
|
38
85
|
async spawn(input) {
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
86
|
+
const messaging = await this.#ensureMessaging();
|
|
87
|
+
const ack = await messaging.placement.spawn({
|
|
88
|
+
capability: input.capability,
|
|
89
|
+
// 'self' from the orchestrator means "no placement preference": let the
|
|
90
|
+
// engine pick the least-loaded eligible node.
|
|
91
|
+
...(input.node && input.node !== 'self' ? { node: input.node } : {}),
|
|
92
|
+
...(input.repo ? { repo: input.repo } : {}),
|
|
93
|
+
input: spawnActionInput(input),
|
|
94
|
+
...(this.#options.placementTtlMs !== undefined ? { ttlMs: this.#options.placementTtlMs } : {}),
|
|
95
|
+
log: this.#log,
|
|
96
|
+
});
|
|
97
|
+
const invocation = await this.#awaitInvocation(ack.actionName || 'spawn', ack);
|
|
98
|
+
const result = spawnResultFromInvocation(input.name, input.sessionRef, invocation);
|
|
99
|
+
this.#track(result.name, ack);
|
|
100
|
+
return result;
|
|
44
101
|
}
|
|
45
102
|
async resume(input) {
|
|
46
103
|
const name = input.name ?? input.sessionRef;
|
|
47
|
-
|
|
48
|
-
const actionInput = spawnActionInput({
|
|
104
|
+
return await this.spawn({
|
|
49
105
|
name,
|
|
50
106
|
capability: input.capability ?? 'spawn:codex',
|
|
51
107
|
node: input.node,
|
|
52
108
|
sessionRef: input.sessionRef,
|
|
53
|
-
}
|
|
54
|
-
const ack = await this.#getTransport().invokeAction(this.#spawnActionName, actionInput, { invocationId });
|
|
55
|
-
const invocation = await this.#awaitInvocation(this.#spawnActionName, ack.invocationId || invocationId, ack);
|
|
56
|
-
return spawnResultFromInvocation(name, input.sessionRef, invocation);
|
|
109
|
+
});
|
|
57
110
|
}
|
|
58
111
|
async release(name, reason) {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
112
|
+
const messaging = await this.#ensureMessaging();
|
|
113
|
+
try {
|
|
114
|
+
const ack = await messaging.commands.invoke('release', {
|
|
115
|
+
name,
|
|
116
|
+
agent: name,
|
|
117
|
+
...(reason ? { reason } : {}),
|
|
118
|
+
});
|
|
119
|
+
await this.#awaitInvocation(ack.actionName || 'release', ack);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
this.#tracked.delete(name);
|
|
123
|
+
this.#syncExitWatcher();
|
|
124
|
+
}
|
|
66
125
|
}
|
|
67
126
|
async roster() {
|
|
68
|
-
const
|
|
127
|
+
const messaging = await this.#ensureMessaging();
|
|
69
128
|
const [agents, nodes] = await Promise.all([
|
|
70
|
-
|
|
71
|
-
|
|
129
|
+
// Roster means agents that can currently collide with a spawn. Including
|
|
130
|
+
// offline task-exit rows makes deterministic one-shot names permanently
|
|
131
|
+
// sticky and prevents a later review round from spawning a fresh worker.
|
|
132
|
+
messaging.agents.list({ status: 'online' }),
|
|
133
|
+
messaging.nodes.list(),
|
|
72
134
|
]);
|
|
73
135
|
return {
|
|
74
136
|
agents: agents.map((agent) => ({ name: agent.name })),
|
|
@@ -79,18 +141,17 @@ export class RelayFleetClient {
|
|
|
79
141
|
})),
|
|
80
142
|
};
|
|
81
143
|
}
|
|
144
|
+
// `from`/`data` are not representable on the agent-scoped messaging surface:
|
|
145
|
+
// every send is authored by the factory's own agent identity.
|
|
82
146
|
async sendMessage(input) {
|
|
83
|
-
await this.#
|
|
147
|
+
await this.#send(input);
|
|
84
148
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
return { eventId: newInvocationId('message', input.to), targets };
|
|
92
|
-
}
|
|
93
|
-
return await transport.waitForDelivery?.(eventId, opts) ?? { eventId, targets };
|
|
149
|
+
// The SDK exposes no sender-side delivery query yet, so a successful send —
|
|
150
|
+
// a durable inbox row on the engine — is the injected signal. Upstream:
|
|
151
|
+
// deliveries.forMessage(messageId) would make this authoritative.
|
|
152
|
+
async waitForInjected(input, _opts) {
|
|
153
|
+
const message = await this.#send(input);
|
|
154
|
+
return { eventId: message.id, targets: [input.to] };
|
|
94
155
|
}
|
|
95
156
|
onDeliveryFailed(listener) {
|
|
96
157
|
this.#ensureEventSubscription();
|
|
@@ -109,207 +170,254 @@ export class RelayFleetClient {
|
|
|
109
170
|
onAgentExit(listener) {
|
|
110
171
|
this.#ensureEventSubscription();
|
|
111
172
|
this.#agentExitListeners.add(listener);
|
|
173
|
+
this.#syncExitWatcher();
|
|
112
174
|
return () => {
|
|
113
175
|
this.#agentExitListeners.delete(listener);
|
|
176
|
+
this.#syncExitWatcher();
|
|
114
177
|
};
|
|
115
178
|
}
|
|
116
179
|
async dispose() {
|
|
117
180
|
if (this.#disposed)
|
|
118
181
|
return;
|
|
119
182
|
this.#disposed = true;
|
|
183
|
+
if (this.#watchTimer) {
|
|
184
|
+
clearInterval(this.#watchTimer);
|
|
185
|
+
this.#watchTimer = undefined;
|
|
186
|
+
}
|
|
120
187
|
for (const unsubscribe of this.#eventUnsubscribers.splice(0)) {
|
|
121
188
|
unsubscribe();
|
|
122
189
|
}
|
|
123
190
|
this.#agentExitListeners.clear();
|
|
124
191
|
this.#deliveryFailedListeners.clear();
|
|
125
192
|
this.#agentMessageListeners.clear();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
193
|
+
this.#tracked.clear();
|
|
194
|
+
if (this.#eventsStarted) {
|
|
195
|
+
await this.#messaging?.events.disconnect().catch(() => { });
|
|
196
|
+
}
|
|
129
197
|
}
|
|
130
|
-
async #
|
|
131
|
-
|
|
198
|
+
async #send(input) {
|
|
199
|
+
const messaging = await this.#ensureMessaging();
|
|
200
|
+
try {
|
|
201
|
+
if (input.to.startsWith('#')) {
|
|
202
|
+
return await messaging.messages.send({ channel: input.to.slice(1), text: input.text });
|
|
203
|
+
}
|
|
204
|
+
return await messaging.messages.direct({
|
|
205
|
+
to: input.to.startsWith('@') ? input.to.slice(1) : input.to,
|
|
206
|
+
text: input.text,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
// Keep the orchestrator's registration-lag retry classification working:
|
|
211
|
+
// a DM to an agent the engine has not registered yet is retryable.
|
|
212
|
+
if (isUnknownRecipientError(error)) {
|
|
213
|
+
throw new Error(`recipient unavailable: ${errorMessage(error)}`);
|
|
214
|
+
}
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
#ensureMessaging() {
|
|
219
|
+
if (this.#messaging)
|
|
220
|
+
return Promise.resolve(this.#messaging);
|
|
221
|
+
this.#messagingReady ??= this.#bootstrapMessaging().catch((error) => {
|
|
222
|
+
// Allow a later call to retry a failed bootstrap (transient network, etc).
|
|
223
|
+
this.#messagingReady = undefined;
|
|
224
|
+
throw error;
|
|
225
|
+
});
|
|
226
|
+
return this.#messagingReady;
|
|
227
|
+
}
|
|
228
|
+
async #bootstrapMessaging() {
|
|
229
|
+
const env = this.#options.env;
|
|
230
|
+
const workspaceKey = resolveRelayWorkspaceKey({
|
|
231
|
+
workspaceKey: this.#options.workspaceKey,
|
|
232
|
+
...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
|
|
233
|
+
});
|
|
234
|
+
let agentToken = resolveRelayAgentToken({
|
|
235
|
+
agentToken: this.#options.agentToken,
|
|
236
|
+
...(env ? { env } : {}),
|
|
237
|
+
});
|
|
238
|
+
if (!workspaceKey && !agentToken) {
|
|
239
|
+
throw new Error('RelayFleetClient requires a workspace key (rk_live_…) or agent token (at_live_…); set RELAY_WORKSPACE_KEY or RELAY_AGENT_TOKEN');
|
|
240
|
+
}
|
|
241
|
+
if (!agentToken) {
|
|
242
|
+
agentToken = await this.#registerFactoryAgent(workspaceKey);
|
|
243
|
+
}
|
|
244
|
+
const relay = this.#createRelay({
|
|
245
|
+
...(workspaceKey ? { workspaceKey } : {}),
|
|
246
|
+
agentToken,
|
|
247
|
+
...(this.#options.baseUrl ? { baseUrl: this.#options.baseUrl } : {}),
|
|
248
|
+
});
|
|
249
|
+
this.#messaging = relay.messaging;
|
|
250
|
+
return this.#messaging;
|
|
251
|
+
}
|
|
252
|
+
// Rotate-on-start is idempotent and leaves nothing secret on disk: the
|
|
253
|
+
// factory adopts its standing workspace identity and mints a fresh token.
|
|
254
|
+
async #registerFactoryAgent(workspaceKey) {
|
|
255
|
+
const bootstrap = this.#createRelay({
|
|
256
|
+
workspaceKey,
|
|
257
|
+
...(this.#options.baseUrl ? { baseUrl: this.#options.baseUrl } : {}),
|
|
258
|
+
});
|
|
259
|
+
const agents = bootstrap.messaging.agents;
|
|
260
|
+
const register = agents.registerOrRotate?.bind(agents) ?? agents.register.bind(agents);
|
|
261
|
+
const registration = await register({ name: this.#agentName });
|
|
262
|
+
return registration.token;
|
|
263
|
+
}
|
|
264
|
+
async #awaitInvocation(actionName, ack) {
|
|
265
|
+
const messaging = await this.#ensureMessaging();
|
|
266
|
+
let status = ack.status ?? 'pending';
|
|
132
267
|
let invocation;
|
|
133
|
-
const deadline = Date.now() + this.#
|
|
268
|
+
const deadline = Date.now() + this.#spawnAckTimeoutMs;
|
|
134
269
|
while (!terminalStatuses.has(status)) {
|
|
135
270
|
if (Date.now() > deadline) {
|
|
136
|
-
throw new Error(`Timed out waiting for ${actionName} invocation ${invocationId} to complete (last status: ${status})`);
|
|
271
|
+
throw new Error(`Timed out waiting for ${actionName} invocation ${ack.invocationId} to complete (last status: ${status})`);
|
|
137
272
|
}
|
|
138
273
|
if (!openStatuses.has(status)) {
|
|
139
|
-
throw new Error(`Unexpected ${actionName} invocation ${invocationId} status: ${status}`);
|
|
274
|
+
throw new Error(`Unexpected ${actionName} invocation ${ack.invocationId} status: ${status}`);
|
|
140
275
|
}
|
|
141
276
|
await this.#sleep(this.#pollIntervalMs);
|
|
142
|
-
invocation = await
|
|
143
|
-
status = invocation.status
|
|
277
|
+
invocation = await messaging.commands.getInvocation(actionName, ack.invocationId);
|
|
278
|
+
status = invocation.status || 'pending';
|
|
144
279
|
}
|
|
145
|
-
invocation ??= await
|
|
280
|
+
invocation ??= await messaging.commands.getInvocation(actionName, ack.invocationId);
|
|
146
281
|
if (status === 'failed' || status === 'denied') {
|
|
147
|
-
throw new Error(`${actionName} invocation ${invocationId} ${status}${invocation.error ? `: ${invocation.error}` : ''}`);
|
|
282
|
+
throw new Error(`${actionName} invocation ${ack.invocationId} ${status}${invocation.error ? `: ${invocation.error}` : ''}`);
|
|
148
283
|
}
|
|
149
284
|
return invocation;
|
|
150
285
|
}
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
286
|
+
#track(name, ack) {
|
|
287
|
+
this.#tracked.set(name, {
|
|
288
|
+
invocationId: ack.invocationId,
|
|
289
|
+
node: ack.placement?.node ?? ack.dispatchedNodeId ?? undefined,
|
|
290
|
+
spawnedAtMs: this.#now(),
|
|
291
|
+
});
|
|
292
|
+
this.#syncExitWatcher();
|
|
293
|
+
}
|
|
294
|
+
#syncExitWatcher() {
|
|
295
|
+
const shouldRun = !this.#disposed && this.#tracked.size > 0 && this.#agentExitListeners.size > 0;
|
|
296
|
+
if (shouldRun && !this.#watchTimer) {
|
|
297
|
+
this.#watchTimer = setInterval(() => {
|
|
298
|
+
void this.reconcileTrackedAgents().catch((error) => {
|
|
299
|
+
this.#log(`relay fleet exit reconciliation failed: ${errorMessage(error)}`);
|
|
300
|
+
});
|
|
301
|
+
}, this.#exitWatchIntervalMs);
|
|
302
|
+
this.#watchTimer.unref?.();
|
|
303
|
+
}
|
|
304
|
+
else if (!shouldRun && this.#watchTimer) {
|
|
305
|
+
clearInterval(this.#watchTimer);
|
|
306
|
+
this.#watchTimer = undefined;
|
|
307
|
+
}
|
|
158
308
|
}
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
309
|
+
async #reconcileTracked() {
|
|
310
|
+
if (this.#tracked.size === 0)
|
|
311
|
+
return;
|
|
312
|
+
const messaging = await this.#ensureMessaging();
|
|
313
|
+
const [agents, nodes] = await Promise.all([
|
|
314
|
+
messaging.agents.list({ status: 'all' }),
|
|
315
|
+
messaging.nodes.list(),
|
|
316
|
+
]);
|
|
317
|
+
const agentsByName = new Map(agents.map((agent) => [agent.name, agent]));
|
|
318
|
+
const nodeLive = new Map(nodes.map((node) => [node.name, node.live ?? node.status === 'online']));
|
|
319
|
+
const nowMs = this.#now();
|
|
320
|
+
for (const [name, entry] of [...this.#tracked]) {
|
|
321
|
+
const row = agentsByName.get(name);
|
|
322
|
+
if (!row || row.status === 'offline') {
|
|
323
|
+
// A just-spawned agent may not have registered with the engine yet;
|
|
324
|
+
// absence only counts as an exit once the grace window has passed.
|
|
325
|
+
if (nowMs - entry.spawnedAtMs < this.#registrationGraceMs)
|
|
326
|
+
continue;
|
|
327
|
+
this.#emitExit(name, 'exited');
|
|
328
|
+
continue;
|
|
164
329
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
330
|
+
if (!entry.node)
|
|
331
|
+
continue;
|
|
332
|
+
if (nodeLive.get(entry.node) === false || !nodeLive.has(entry.node)) {
|
|
333
|
+
entry.nodeOfflineSinceMs ??= nowMs;
|
|
334
|
+
if (nowMs - entry.nodeOfflineSinceMs >= this.#nodeOfflineGraceMs) {
|
|
335
|
+
this.#emitExit(name, 'node-offline');
|
|
336
|
+
}
|
|
170
337
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (exit) {
|
|
174
|
-
for (const listener of this.#agentExitListeners) {
|
|
175
|
-
listener(exit.name, exit.reason);
|
|
338
|
+
else {
|
|
339
|
+
entry.nodeOfflineSinceMs = undefined;
|
|
176
340
|
}
|
|
177
341
|
}
|
|
178
342
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
#eventListeners = new Set();
|
|
185
|
-
#socket;
|
|
186
|
-
constructor(options) {
|
|
187
|
-
this.#baseUrl = (options.baseUrl ?? env('RELAYCAST_BASE_URL') ?? env('AGENT_RELAY_BASE_URL') ?? 'https://api.relaycast.dev').replace(/\/+$/, '');
|
|
188
|
-
this.#token = options.agentToken ?? env('RELAY_AGENT_TOKEN') ?? options.workspaceKey ?? env('RELAY_WORKSPACE_KEY') ?? env('RELAY_API_KEY') ?? '';
|
|
189
|
-
if (!this.#token) {
|
|
190
|
-
throw new Error('RelayFleetClient requires agentToken or workspaceKey (or RELAY_AGENT_TOKEN / RELAY_WORKSPACE_KEY)');
|
|
343
|
+
#emitExit(name, reason) {
|
|
344
|
+
this.#tracked.delete(name);
|
|
345
|
+
this.#syncExitWatcher();
|
|
346
|
+
for (const listener of this.#agentExitListeners) {
|
|
347
|
+
listener(name, reason);
|
|
191
348
|
}
|
|
192
|
-
this.#fetch = options.fetch ?? globalFetch;
|
|
193
349
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
350
|
+
#ensureEventSubscription() {
|
|
351
|
+
if (this.#eventsStarted)
|
|
352
|
+
return;
|
|
353
|
+
this.#eventsStarted = true;
|
|
354
|
+
void this.#subscribeEvents().catch((error) => {
|
|
355
|
+
this.#eventsStarted = false;
|
|
356
|
+
this.#log(`relay fleet event subscription failed: ${errorMessage(error)}`);
|
|
199
357
|
});
|
|
200
|
-
return invocationAckFrom(data, opts.invocationId, actionName);
|
|
201
|
-
}
|
|
202
|
-
async getInvocation(actionName, invocationId) {
|
|
203
|
-
const data = await this.#request(`/v1/actions/${encodeURIComponent(actionName)}/invocations/${encodeURIComponent(invocationId)}`);
|
|
204
|
-
return invocationFrom(data, invocationId, actionName);
|
|
205
|
-
}
|
|
206
|
-
async listAgents() {
|
|
207
|
-
const data = await this.#request('/v1/agents?status=all');
|
|
208
|
-
return Array.isArray(data) ? data.map(agentFrom).filter(isRelayFleetAgent) : [];
|
|
209
|
-
}
|
|
210
|
-
async listNodes() {
|
|
211
|
-
const data = await this.#request('/v1/nodes');
|
|
212
|
-
return Array.isArray(data) ? data.map(nodeFrom).filter(isRelayFleetNode) : [];
|
|
213
|
-
}
|
|
214
|
-
async sendMessage(input) {
|
|
215
|
-
const data = input.to.startsWith('#')
|
|
216
|
-
? await this.#request(`/v1/channels/${encodeURIComponent(input.to.slice(1))}/messages`, {
|
|
217
|
-
text: input.text,
|
|
218
|
-
...(input.data ? { metadata: input.data } : {}),
|
|
219
|
-
})
|
|
220
|
-
: await this.#request('/v1/dm', {
|
|
221
|
-
to: input.to.startsWith('@') ? input.to.slice(1) : input.to,
|
|
222
|
-
text: input.text,
|
|
223
|
-
...(input.data ? { metadata: input.data } : {}),
|
|
224
|
-
});
|
|
225
|
-
return {
|
|
226
|
-
eventId: readString(asRecord(data), 'id', 'message_id', 'event_id'),
|
|
227
|
-
targets: [input.to],
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
onEvent(listener) {
|
|
231
|
-
this.#eventListeners.add(listener);
|
|
232
|
-
this.#connectEvents();
|
|
233
|
-
return () => {
|
|
234
|
-
this.#eventListeners.delete(listener);
|
|
235
|
-
if (this.#eventListeners.size === 0) {
|
|
236
|
-
this.#socket?.close();
|
|
237
|
-
this.#socket = undefined;
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
358
|
}
|
|
241
|
-
|
|
242
|
-
this.#
|
|
243
|
-
this.#
|
|
244
|
-
|
|
359
|
+
async #subscribeEvents() {
|
|
360
|
+
const messaging = await this.#ensureMessaging();
|
|
361
|
+
if (this.#disposed)
|
|
362
|
+
return;
|
|
363
|
+
messaging.events.connect();
|
|
364
|
+
this.#eventUnsubscribers.push(messaging.events.on('any', (event) => this.#handleEvent(event)));
|
|
245
365
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
366
|
+
#handleEvent(event) {
|
|
367
|
+
switch (event.type) {
|
|
368
|
+
case 'dmReceived':
|
|
369
|
+
case 'groupDmReceived':
|
|
370
|
+
this.#emitAgentMessage(event.message, this.#agentName);
|
|
371
|
+
break;
|
|
372
|
+
case 'messageCreated':
|
|
373
|
+
case 'threadReply':
|
|
374
|
+
this.#emitAgentMessage(event.message, event.channel);
|
|
375
|
+
break;
|
|
376
|
+
case 'agentOffline':
|
|
377
|
+
this.#handleAgentOffline(event.agent.name);
|
|
378
|
+
break;
|
|
379
|
+
default:
|
|
380
|
+
break;
|
|
261
381
|
}
|
|
262
|
-
return record && 'data' in record ? record.data : payload;
|
|
263
382
|
}
|
|
264
|
-
#
|
|
265
|
-
|
|
383
|
+
#emitAgentMessage(message, fallbackTarget) {
|
|
384
|
+
const from = message.from?.name;
|
|
385
|
+
if (!from || from === this.#agentName)
|
|
266
386
|
return;
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
}
|
|
281
|
-
catch {
|
|
282
|
-
// Drop malformed event frames; the polling path remains authoritative for invocations.
|
|
283
|
-
}
|
|
284
|
-
};
|
|
285
|
-
socket.onclose = () => {
|
|
286
|
-
if (this.#socket === socket)
|
|
287
|
-
this.#socket = undefined;
|
|
288
|
-
};
|
|
289
|
-
socket.onerror = () => {
|
|
290
|
-
if (this.#socket === socket)
|
|
291
|
-
this.#socket = undefined;
|
|
292
|
-
try {
|
|
293
|
-
socket.close();
|
|
294
|
-
}
|
|
295
|
-
catch {
|
|
296
|
-
// noop
|
|
297
|
-
}
|
|
387
|
+
let target;
|
|
388
|
+
const messageTarget = message.target;
|
|
389
|
+
if (messageTarget?.kind === 'agent' && typeof messageTarget.agentName === 'string') {
|
|
390
|
+
target = messageTarget.agentName;
|
|
391
|
+
}
|
|
392
|
+
else if (messageTarget?.kind === 'channel' && typeof messageTarget.channelName === 'string') {
|
|
393
|
+
target = messageTarget.channelName;
|
|
394
|
+
}
|
|
395
|
+
const agentMessage = {
|
|
396
|
+
from,
|
|
397
|
+
target: target ?? fallbackTarget,
|
|
398
|
+
body: message.text,
|
|
399
|
+
...(message.threadId || message.parentId ? { threadId: message.threadId ?? message.parentId } : {}),
|
|
400
|
+
...(message.id ? { eventId: message.id } : {}),
|
|
298
401
|
};
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
for (const listener of this.#eventListeners) {
|
|
302
|
-
listener(event);
|
|
402
|
+
for (const listener of this.#agentMessageListeners) {
|
|
403
|
+
listener(agentMessage);
|
|
303
404
|
}
|
|
304
405
|
}
|
|
406
|
+
// Presence offline is a push hint for exits of agents this client spawned.
|
|
407
|
+
// The roster reconciliation watcher remains the authoritative exit detector.
|
|
408
|
+
#handleAgentOffline(name) {
|
|
409
|
+
if (!this.#tracked.has(name))
|
|
410
|
+
return;
|
|
411
|
+
this.#emitExit(name, 'offline');
|
|
412
|
+
}
|
|
305
413
|
}
|
|
306
|
-
|
|
414
|
+
// Placement injects capability/node/target_node/repo/cli on top of this
|
|
415
|
+
// payload; spawn_mode/exit_after_task request task-exit lifecycle from the
|
|
416
|
+
// broker on the placed node.
|
|
417
|
+
function spawnActionInput(input) {
|
|
307
418
|
return definedRecord({
|
|
308
|
-
capability: input.capability,
|
|
309
419
|
name: input.name,
|
|
310
420
|
agent: input.name,
|
|
311
|
-
node: input.node && (opts.includeSelfNode || input.node !== 'self') ? input.node : undefined,
|
|
312
|
-
repo: input.repo,
|
|
313
421
|
clone_path: input.clonePath,
|
|
314
422
|
clonePath: input.clonePath,
|
|
315
423
|
session_ref: input.sessionRef,
|
|
@@ -321,6 +429,7 @@ function spawnActionInput(input, opts) {
|
|
|
321
429
|
cwd: input.cwd,
|
|
322
430
|
channels: input.channel ? [input.channel] : undefined,
|
|
323
431
|
restart_policy: input.restartPolicy,
|
|
432
|
+
...(input.capability.startsWith('spawn:') ? { spawn_mode: 'task_exit', exit_after_task: true } : {}),
|
|
324
433
|
});
|
|
325
434
|
}
|
|
326
435
|
function spawnResultFromInvocation(fallbackName, fallbackSessionRef, invocation) {
|
|
@@ -341,109 +450,16 @@ function spawnResultFromInvocation(fallbackName, fallbackSessionRef, invocation)
|
|
|
341
450
|
}
|
|
342
451
|
function normalizeCapabilities(capabilities) {
|
|
343
452
|
const names = (capabilities ?? [])
|
|
344
|
-
.map((capability) =>
|
|
345
|
-
.filter((
|
|
453
|
+
.map((capability) => capability.name)
|
|
454
|
+
.filter((name) => knownCapabilities.has(name));
|
|
346
455
|
return [...new Set(names)];
|
|
347
456
|
}
|
|
348
|
-
function
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
return undefined;
|
|
352
|
-
const to = readString(event, 'to', 'recipient', 'recipient_name', 'recipientName', 'agent_name', 'agentName');
|
|
353
|
-
if (!to)
|
|
354
|
-
return undefined;
|
|
355
|
-
return definedRecord({
|
|
356
|
-
to,
|
|
357
|
-
msgId: readString(event, 'message_id', 'messageId', 'msg_id', 'msgId', 'id'),
|
|
358
|
-
reason: readString(event, 'reason', 'error'),
|
|
359
|
-
});
|
|
457
|
+
function isUnknownRecipientError(error) {
|
|
458
|
+
const message = errorMessage(error);
|
|
459
|
+
return /not[ _]found|unknown (agent|recipient)|no such (agent|recipient)|unregistered/i.test(message);
|
|
360
460
|
}
|
|
361
|
-
function
|
|
362
|
-
|
|
363
|
-
if (type !== 'message.created' && type !== 'dm.received' && type !== 'group_dm.received' && type !== 'thread.reply') {
|
|
364
|
-
return undefined;
|
|
365
|
-
}
|
|
366
|
-
const message = asRecord(event.message) ?? event;
|
|
367
|
-
const fromRecord = asRecord(message.from);
|
|
368
|
-
const target = asRecord(message.target);
|
|
369
|
-
const from = readString(fromRecord, 'name') ?? readString(message, 'from', 'from_name', 'fromName', 'agent_name', 'agentName');
|
|
370
|
-
const body = readString(message, 'text', 'body');
|
|
371
|
-
if (!from || body === undefined)
|
|
372
|
-
return undefined;
|
|
373
|
-
const targetName = readString(target, 'agentName', 'agent_name', 'channelName', 'channel_name')
|
|
374
|
-
?? readString(message, 'to', 'target', 'channel', 'channel_name', 'channelName')
|
|
375
|
-
?? 'factory';
|
|
376
|
-
return definedRecord({
|
|
377
|
-
from,
|
|
378
|
-
target: targetName,
|
|
379
|
-
body,
|
|
380
|
-
threadId: readString(message, 'threadId', 'thread_id', 'parentId', 'parent_id'),
|
|
381
|
-
eventId: readString(message, 'id', 'messageId', 'message_id', 'event_id', 'eventId'),
|
|
382
|
-
});
|
|
383
|
-
}
|
|
384
|
-
function agentExitFromEvent(event) {
|
|
385
|
-
const type = event.type;
|
|
386
|
-
if (type !== 'agent.exited' && type !== 'agent.exit' && type !== 'agent.status.offline' && type !== 'session.released') {
|
|
387
|
-
return undefined;
|
|
388
|
-
}
|
|
389
|
-
const agent = asRecord(event.agent);
|
|
390
|
-
const name = readString(event, 'name', 'agent_name', 'agentName') ?? readString(agent, 'name');
|
|
391
|
-
if (!name)
|
|
392
|
-
return undefined;
|
|
393
|
-
return definedRecord({
|
|
394
|
-
name,
|
|
395
|
-
reason: readString(event, 'reason', 'status') ?? (type === 'agent.status.offline' ? 'offline' : undefined),
|
|
396
|
-
});
|
|
397
|
-
}
|
|
398
|
-
function invocationAckFrom(value, fallbackInvocationId, fallbackActionName) {
|
|
399
|
-
const record = asRecord(value) ?? {};
|
|
400
|
-
return {
|
|
401
|
-
invocationId: readString(record, 'invocation_id', 'invocationId') ?? fallbackInvocationId,
|
|
402
|
-
actionName: readString(record, 'action_name', 'actionName') ?? fallbackActionName,
|
|
403
|
-
status: readString(record, 'status'),
|
|
404
|
-
dispatchedNodeId: readString(record, 'dispatched_node_id', 'dispatchedNodeId') ?? null,
|
|
405
|
-
input: asRecord(record.input),
|
|
406
|
-
};
|
|
407
|
-
}
|
|
408
|
-
function invocationFrom(value, fallbackInvocationId, fallbackActionName) {
|
|
409
|
-
const record = asRecord(value) ?? {};
|
|
410
|
-
return {
|
|
411
|
-
...invocationAckFrom(record, fallbackInvocationId, fallbackActionName),
|
|
412
|
-
output: record.output,
|
|
413
|
-
error: readString(record, 'error') ?? null,
|
|
414
|
-
completedAt: readString(record, 'completed_at', 'completedAt') ?? null,
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
function agentFrom(value) {
|
|
418
|
-
const record = asRecord(value);
|
|
419
|
-
const name = readString(record, 'name');
|
|
420
|
-
return name ? { name } : undefined;
|
|
421
|
-
}
|
|
422
|
-
function isRelayFleetAgent(value) {
|
|
423
|
-
return !!value;
|
|
424
|
-
}
|
|
425
|
-
function nodeFrom(value) {
|
|
426
|
-
const record = asRecord(value);
|
|
427
|
-
const name = readString(record, 'name');
|
|
428
|
-
if (!record || !name)
|
|
429
|
-
return undefined;
|
|
430
|
-
return {
|
|
431
|
-
name,
|
|
432
|
-
status: readString(record, 'status'),
|
|
433
|
-
live: readBoolean(record, 'live') ?? readBoolean(record, 'handlers_live', 'handlersLive'),
|
|
434
|
-
capabilities: Array.isArray(record.capabilities) ? record.capabilities : [],
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
function isRelayFleetNode(value) {
|
|
438
|
-
return !!value;
|
|
439
|
-
}
|
|
440
|
-
async function readResponsePayload(response) {
|
|
441
|
-
try {
|
|
442
|
-
return await response.json();
|
|
443
|
-
}
|
|
444
|
-
catch {
|
|
445
|
-
return await response.text();
|
|
446
|
-
}
|
|
461
|
+
function errorMessage(error) {
|
|
462
|
+
return error instanceof Error ? error.message : String(error);
|
|
447
463
|
}
|
|
448
464
|
function definedRecord(input) {
|
|
449
465
|
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
@@ -473,16 +489,6 @@ function readNumber(record, ...keys) {
|
|
|
473
489
|
}
|
|
474
490
|
return undefined;
|
|
475
491
|
}
|
|
476
|
-
function readBoolean(record, ...keys) {
|
|
477
|
-
if (!record)
|
|
478
|
-
return undefined;
|
|
479
|
-
for (const key of keys) {
|
|
480
|
-
const value = record[key];
|
|
481
|
-
if (typeof value === 'boolean')
|
|
482
|
-
return value;
|
|
483
|
-
}
|
|
484
|
-
return undefined;
|
|
485
|
-
}
|
|
486
492
|
function readNumberArray(record, ...keys) {
|
|
487
493
|
if (!record)
|
|
488
494
|
return undefined;
|
|
@@ -496,14 +502,4 @@ function readNumberArray(record, ...keys) {
|
|
|
496
502
|
}
|
|
497
503
|
return undefined;
|
|
498
504
|
}
|
|
499
|
-
function newInvocationId(prefix, name) {
|
|
500
|
-
return `factory:${prefix}:${name}:${randomUUID()}`;
|
|
501
|
-
}
|
|
502
|
-
function env(name) {
|
|
503
|
-
const value = process.env[name];
|
|
504
|
-
return value && value.trim() ? value.trim() : undefined;
|
|
505
|
-
}
|
|
506
|
-
function globalFetch(...args) {
|
|
507
|
-
return fetch(...args);
|
|
508
|
-
}
|
|
509
505
|
//# sourceMappingURL=relay-fleet-client.js.map
|