@agent-relay/factory 0.1.16 → 0.1.18
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 +36 -10
- package/dist/cli/fleet.d.ts +2 -0
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +1 -1
- package/dist/cli/fleet.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 +41 -1
- 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 +316 -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/mount/relayfile-cloud-mount-client.js +3 -1
- package/dist/mount/relayfile-cloud-mount-client.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 +203 -22
- 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 +16 -0
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +22 -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,133 @@
|
|
|
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
|
+
messaging.agents.list({ status: 'all' }),
|
|
130
|
+
messaging.nodes.list(),
|
|
72
131
|
]);
|
|
73
132
|
return {
|
|
74
133
|
agents: agents.map((agent) => ({ name: agent.name })),
|
|
@@ -79,18 +138,17 @@ export class RelayFleetClient {
|
|
|
79
138
|
})),
|
|
80
139
|
};
|
|
81
140
|
}
|
|
141
|
+
// `from`/`data` are not representable on the agent-scoped messaging surface:
|
|
142
|
+
// every send is authored by the factory's own agent identity.
|
|
82
143
|
async sendMessage(input) {
|
|
83
|
-
await this.#
|
|
144
|
+
await this.#send(input);
|
|
84
145
|
}
|
|
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 };
|
|
146
|
+
// The SDK exposes no sender-side delivery query yet, so a successful send —
|
|
147
|
+
// a durable inbox row on the engine — is the injected signal. Upstream:
|
|
148
|
+
// deliveries.forMessage(messageId) would make this authoritative.
|
|
149
|
+
async waitForInjected(input, _opts) {
|
|
150
|
+
const message = await this.#send(input);
|
|
151
|
+
return { eventId: message.id, targets: [input.to] };
|
|
94
152
|
}
|
|
95
153
|
onDeliveryFailed(listener) {
|
|
96
154
|
this.#ensureEventSubscription();
|
|
@@ -109,207 +167,254 @@ export class RelayFleetClient {
|
|
|
109
167
|
onAgentExit(listener) {
|
|
110
168
|
this.#ensureEventSubscription();
|
|
111
169
|
this.#agentExitListeners.add(listener);
|
|
170
|
+
this.#syncExitWatcher();
|
|
112
171
|
return () => {
|
|
113
172
|
this.#agentExitListeners.delete(listener);
|
|
173
|
+
this.#syncExitWatcher();
|
|
114
174
|
};
|
|
115
175
|
}
|
|
116
176
|
async dispose() {
|
|
117
177
|
if (this.#disposed)
|
|
118
178
|
return;
|
|
119
179
|
this.#disposed = true;
|
|
180
|
+
if (this.#watchTimer) {
|
|
181
|
+
clearInterval(this.#watchTimer);
|
|
182
|
+
this.#watchTimer = undefined;
|
|
183
|
+
}
|
|
120
184
|
for (const unsubscribe of this.#eventUnsubscribers.splice(0)) {
|
|
121
185
|
unsubscribe();
|
|
122
186
|
}
|
|
123
187
|
this.#agentExitListeners.clear();
|
|
124
188
|
this.#deliveryFailedListeners.clear();
|
|
125
189
|
this.#agentMessageListeners.clear();
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
190
|
+
this.#tracked.clear();
|
|
191
|
+
if (this.#eventsStarted) {
|
|
192
|
+
await this.#messaging?.events.disconnect().catch(() => { });
|
|
193
|
+
}
|
|
129
194
|
}
|
|
130
|
-
async #
|
|
131
|
-
|
|
195
|
+
async #send(input) {
|
|
196
|
+
const messaging = await this.#ensureMessaging();
|
|
197
|
+
try {
|
|
198
|
+
if (input.to.startsWith('#')) {
|
|
199
|
+
return await messaging.messages.send({ channel: input.to.slice(1), text: input.text });
|
|
200
|
+
}
|
|
201
|
+
return await messaging.messages.direct({
|
|
202
|
+
to: input.to.startsWith('@') ? input.to.slice(1) : input.to,
|
|
203
|
+
text: input.text,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
// Keep the orchestrator's registration-lag retry classification working:
|
|
208
|
+
// a DM to an agent the engine has not registered yet is retryable.
|
|
209
|
+
if (isUnknownRecipientError(error)) {
|
|
210
|
+
throw new Error(`recipient unavailable: ${errorMessage(error)}`);
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
#ensureMessaging() {
|
|
216
|
+
if (this.#messaging)
|
|
217
|
+
return Promise.resolve(this.#messaging);
|
|
218
|
+
this.#messagingReady ??= this.#bootstrapMessaging().catch((error) => {
|
|
219
|
+
// Allow a later call to retry a failed bootstrap (transient network, etc).
|
|
220
|
+
this.#messagingReady = undefined;
|
|
221
|
+
throw error;
|
|
222
|
+
});
|
|
223
|
+
return this.#messagingReady;
|
|
224
|
+
}
|
|
225
|
+
async #bootstrapMessaging() {
|
|
226
|
+
const env = this.#options.env;
|
|
227
|
+
const workspaceKey = resolveRelayWorkspaceKey({
|
|
228
|
+
workspaceKey: this.#options.workspaceKey,
|
|
229
|
+
...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
|
|
230
|
+
});
|
|
231
|
+
let agentToken = resolveRelayAgentToken({
|
|
232
|
+
agentToken: this.#options.agentToken,
|
|
233
|
+
...(env ? { env } : {}),
|
|
234
|
+
});
|
|
235
|
+
if (!workspaceKey && !agentToken) {
|
|
236
|
+
throw new Error('RelayFleetClient requires a workspace key (rk_live_…) or agent token (at_live_…); set RELAY_WORKSPACE_KEY or RELAY_AGENT_TOKEN');
|
|
237
|
+
}
|
|
238
|
+
if (!agentToken) {
|
|
239
|
+
agentToken = await this.#registerFactoryAgent(workspaceKey);
|
|
240
|
+
}
|
|
241
|
+
const relay = this.#createRelay({
|
|
242
|
+
...(workspaceKey ? { workspaceKey } : {}),
|
|
243
|
+
agentToken,
|
|
244
|
+
...(this.#options.baseUrl ? { baseUrl: this.#options.baseUrl } : {}),
|
|
245
|
+
});
|
|
246
|
+
this.#messaging = relay.messaging;
|
|
247
|
+
return this.#messaging;
|
|
248
|
+
}
|
|
249
|
+
// Rotate-on-start is idempotent and leaves nothing secret on disk: the
|
|
250
|
+
// factory adopts its standing workspace identity and mints a fresh token.
|
|
251
|
+
async #registerFactoryAgent(workspaceKey) {
|
|
252
|
+
const bootstrap = this.#createRelay({
|
|
253
|
+
workspaceKey,
|
|
254
|
+
...(this.#options.baseUrl ? { baseUrl: this.#options.baseUrl } : {}),
|
|
255
|
+
});
|
|
256
|
+
const agents = bootstrap.messaging.agents;
|
|
257
|
+
const register = agents.registerOrRotate?.bind(agents) ?? agents.register.bind(agents);
|
|
258
|
+
const registration = await register({ name: this.#agentName });
|
|
259
|
+
return registration.token;
|
|
260
|
+
}
|
|
261
|
+
async #awaitInvocation(actionName, ack) {
|
|
262
|
+
const messaging = await this.#ensureMessaging();
|
|
263
|
+
let status = ack.status ?? 'pending';
|
|
132
264
|
let invocation;
|
|
133
|
-
const deadline = Date.now() + this.#
|
|
265
|
+
const deadline = Date.now() + this.#spawnAckTimeoutMs;
|
|
134
266
|
while (!terminalStatuses.has(status)) {
|
|
135
267
|
if (Date.now() > deadline) {
|
|
136
|
-
throw new Error(`Timed out waiting for ${actionName} invocation ${invocationId} to complete (last status: ${status})`);
|
|
268
|
+
throw new Error(`Timed out waiting for ${actionName} invocation ${ack.invocationId} to complete (last status: ${status})`);
|
|
137
269
|
}
|
|
138
270
|
if (!openStatuses.has(status)) {
|
|
139
|
-
throw new Error(`Unexpected ${actionName} invocation ${invocationId} status: ${status}`);
|
|
271
|
+
throw new Error(`Unexpected ${actionName} invocation ${ack.invocationId} status: ${status}`);
|
|
140
272
|
}
|
|
141
273
|
await this.#sleep(this.#pollIntervalMs);
|
|
142
|
-
invocation = await
|
|
143
|
-
status = invocation.status
|
|
274
|
+
invocation = await messaging.commands.getInvocation(actionName, ack.invocationId);
|
|
275
|
+
status = invocation.status || 'pending';
|
|
144
276
|
}
|
|
145
|
-
invocation ??= await
|
|
277
|
+
invocation ??= await messaging.commands.getInvocation(actionName, ack.invocationId);
|
|
146
278
|
if (status === 'failed' || status === 'denied') {
|
|
147
|
-
throw new Error(`${actionName} invocation ${invocationId} ${status}${invocation.error ? `: ${invocation.error}` : ''}`);
|
|
279
|
+
throw new Error(`${actionName} invocation ${ack.invocationId} ${status}${invocation.error ? `: ${invocation.error}` : ''}`);
|
|
148
280
|
}
|
|
149
281
|
return invocation;
|
|
150
282
|
}
|
|
151
|
-
#
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
283
|
+
#track(name, ack) {
|
|
284
|
+
this.#tracked.set(name, {
|
|
285
|
+
invocationId: ack.invocationId,
|
|
286
|
+
node: ack.placement?.node ?? ack.dispatchedNodeId ?? undefined,
|
|
287
|
+
spawnedAtMs: this.#now(),
|
|
288
|
+
});
|
|
289
|
+
this.#syncExitWatcher();
|
|
290
|
+
}
|
|
291
|
+
#syncExitWatcher() {
|
|
292
|
+
const shouldRun = !this.#disposed && this.#tracked.size > 0 && this.#agentExitListeners.size > 0;
|
|
293
|
+
if (shouldRun && !this.#watchTimer) {
|
|
294
|
+
this.#watchTimer = setInterval(() => {
|
|
295
|
+
void this.reconcileTrackedAgents().catch((error) => {
|
|
296
|
+
this.#log(`relay fleet exit reconciliation failed: ${errorMessage(error)}`);
|
|
297
|
+
});
|
|
298
|
+
}, this.#exitWatchIntervalMs);
|
|
299
|
+
this.#watchTimer.unref?.();
|
|
300
|
+
}
|
|
301
|
+
else if (!shouldRun && this.#watchTimer) {
|
|
302
|
+
clearInterval(this.#watchTimer);
|
|
303
|
+
this.#watchTimer = undefined;
|
|
304
|
+
}
|
|
158
305
|
}
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
306
|
+
async #reconcileTracked() {
|
|
307
|
+
if (this.#tracked.size === 0)
|
|
308
|
+
return;
|
|
309
|
+
const messaging = await this.#ensureMessaging();
|
|
310
|
+
const [agents, nodes] = await Promise.all([
|
|
311
|
+
messaging.agents.list({ status: 'all' }),
|
|
312
|
+
messaging.nodes.list(),
|
|
313
|
+
]);
|
|
314
|
+
const agentsByName = new Map(agents.map((agent) => [agent.name, agent]));
|
|
315
|
+
const nodeLive = new Map(nodes.map((node) => [node.name, node.live ?? node.status === 'online']));
|
|
316
|
+
const nowMs = this.#now();
|
|
317
|
+
for (const [name, entry] of [...this.#tracked]) {
|
|
318
|
+
const row = agentsByName.get(name);
|
|
319
|
+
if (!row || row.status === 'offline') {
|
|
320
|
+
// A just-spawned agent may not have registered with the engine yet;
|
|
321
|
+
// absence only counts as an exit once the grace window has passed.
|
|
322
|
+
if (nowMs - entry.spawnedAtMs < this.#registrationGraceMs)
|
|
323
|
+
continue;
|
|
324
|
+
this.#emitExit(name, 'exited');
|
|
325
|
+
continue;
|
|
164
326
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
327
|
+
if (!entry.node)
|
|
328
|
+
continue;
|
|
329
|
+
if (nodeLive.get(entry.node) === false || !nodeLive.has(entry.node)) {
|
|
330
|
+
entry.nodeOfflineSinceMs ??= nowMs;
|
|
331
|
+
if (nowMs - entry.nodeOfflineSinceMs >= this.#nodeOfflineGraceMs) {
|
|
332
|
+
this.#emitExit(name, 'node-offline');
|
|
333
|
+
}
|
|
170
334
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (exit) {
|
|
174
|
-
for (const listener of this.#agentExitListeners) {
|
|
175
|
-
listener(exit.name, exit.reason);
|
|
335
|
+
else {
|
|
336
|
+
entry.nodeOfflineSinceMs = undefined;
|
|
176
337
|
}
|
|
177
338
|
}
|
|
178
339
|
}
|
|
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)');
|
|
340
|
+
#emitExit(name, reason) {
|
|
341
|
+
this.#tracked.delete(name);
|
|
342
|
+
this.#syncExitWatcher();
|
|
343
|
+
for (const listener of this.#agentExitListeners) {
|
|
344
|
+
listener(name, reason);
|
|
191
345
|
}
|
|
192
|
-
this.#fetch = options.fetch ?? globalFetch;
|
|
193
346
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
347
|
+
#ensureEventSubscription() {
|
|
348
|
+
if (this.#eventsStarted)
|
|
349
|
+
return;
|
|
350
|
+
this.#eventsStarted = true;
|
|
351
|
+
void this.#subscribeEvents().catch((error) => {
|
|
352
|
+
this.#eventsStarted = false;
|
|
353
|
+
this.#log(`relay fleet event subscription failed: ${errorMessage(error)}`);
|
|
199
354
|
});
|
|
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
355
|
}
|
|
241
|
-
|
|
242
|
-
this.#
|
|
243
|
-
this.#
|
|
244
|
-
|
|
356
|
+
async #subscribeEvents() {
|
|
357
|
+
const messaging = await this.#ensureMessaging();
|
|
358
|
+
if (this.#disposed)
|
|
359
|
+
return;
|
|
360
|
+
messaging.events.connect();
|
|
361
|
+
this.#eventUnsubscribers.push(messaging.events.on('any', (event) => this.#handleEvent(event)));
|
|
245
362
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
363
|
+
#handleEvent(event) {
|
|
364
|
+
switch (event.type) {
|
|
365
|
+
case 'dmReceived':
|
|
366
|
+
case 'groupDmReceived':
|
|
367
|
+
this.#emitAgentMessage(event.message, this.#agentName);
|
|
368
|
+
break;
|
|
369
|
+
case 'messageCreated':
|
|
370
|
+
case 'threadReply':
|
|
371
|
+
this.#emitAgentMessage(event.message, event.channel);
|
|
372
|
+
break;
|
|
373
|
+
case 'agentOffline':
|
|
374
|
+
this.#handleAgentOffline(event.agent.name);
|
|
375
|
+
break;
|
|
376
|
+
default:
|
|
377
|
+
break;
|
|
261
378
|
}
|
|
262
|
-
return record && 'data' in record ? record.data : payload;
|
|
263
379
|
}
|
|
264
|
-
#
|
|
265
|
-
|
|
380
|
+
#emitAgentMessage(message, fallbackTarget) {
|
|
381
|
+
const from = message.from?.name;
|
|
382
|
+
if (!from || from === this.#agentName)
|
|
266
383
|
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
|
-
}
|
|
384
|
+
let target;
|
|
385
|
+
const messageTarget = message.target;
|
|
386
|
+
if (messageTarget?.kind === 'agent' && typeof messageTarget.agentName === 'string') {
|
|
387
|
+
target = messageTarget.agentName;
|
|
388
|
+
}
|
|
389
|
+
else if (messageTarget?.kind === 'channel' && typeof messageTarget.channelName === 'string') {
|
|
390
|
+
target = messageTarget.channelName;
|
|
391
|
+
}
|
|
392
|
+
const agentMessage = {
|
|
393
|
+
from,
|
|
394
|
+
target: target ?? fallbackTarget,
|
|
395
|
+
body: message.text,
|
|
396
|
+
...(message.threadId || message.parentId ? { threadId: message.threadId ?? message.parentId } : {}),
|
|
397
|
+
...(message.id ? { eventId: message.id } : {}),
|
|
298
398
|
};
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
for (const listener of this.#eventListeners) {
|
|
302
|
-
listener(event);
|
|
399
|
+
for (const listener of this.#agentMessageListeners) {
|
|
400
|
+
listener(agentMessage);
|
|
303
401
|
}
|
|
304
402
|
}
|
|
403
|
+
// Presence offline is a push hint for exits of agents this client spawned.
|
|
404
|
+
// The roster reconciliation watcher remains the authoritative exit detector.
|
|
405
|
+
#handleAgentOffline(name) {
|
|
406
|
+
if (!this.#tracked.has(name))
|
|
407
|
+
return;
|
|
408
|
+
this.#emitExit(name, 'offline');
|
|
409
|
+
}
|
|
305
410
|
}
|
|
306
|
-
|
|
411
|
+
// Placement injects capability/node/target_node/repo/cli on top of this
|
|
412
|
+
// payload; spawn_mode/exit_after_task request task-exit lifecycle from the
|
|
413
|
+
// broker on the placed node.
|
|
414
|
+
function spawnActionInput(input) {
|
|
307
415
|
return definedRecord({
|
|
308
|
-
capability: input.capability,
|
|
309
416
|
name: input.name,
|
|
310
417
|
agent: input.name,
|
|
311
|
-
node: input.node && (opts.includeSelfNode || input.node !== 'self') ? input.node : undefined,
|
|
312
|
-
repo: input.repo,
|
|
313
418
|
clone_path: input.clonePath,
|
|
314
419
|
clonePath: input.clonePath,
|
|
315
420
|
session_ref: input.sessionRef,
|
|
@@ -321,6 +426,7 @@ function spawnActionInput(input, opts) {
|
|
|
321
426
|
cwd: input.cwd,
|
|
322
427
|
channels: input.channel ? [input.channel] : undefined,
|
|
323
428
|
restart_policy: input.restartPolicy,
|
|
429
|
+
...(input.capability.startsWith('spawn:') ? { spawn_mode: 'task_exit', exit_after_task: true } : {}),
|
|
324
430
|
});
|
|
325
431
|
}
|
|
326
432
|
function spawnResultFromInvocation(fallbackName, fallbackSessionRef, invocation) {
|
|
@@ -341,109 +447,16 @@ function spawnResultFromInvocation(fallbackName, fallbackSessionRef, invocation)
|
|
|
341
447
|
}
|
|
342
448
|
function normalizeCapabilities(capabilities) {
|
|
343
449
|
const names = (capabilities ?? [])
|
|
344
|
-
.map((capability) =>
|
|
345
|
-
.filter((
|
|
450
|
+
.map((capability) => capability.name)
|
|
451
|
+
.filter((name) => knownCapabilities.has(name));
|
|
346
452
|
return [...new Set(names)];
|
|
347
453
|
}
|
|
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
|
-
});
|
|
454
|
+
function isUnknownRecipientError(error) {
|
|
455
|
+
const message = errorMessage(error);
|
|
456
|
+
return /not[ _]found|unknown (agent|recipient)|no such (agent|recipient)|unregistered/i.test(message);
|
|
360
457
|
}
|
|
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
|
-
}
|
|
458
|
+
function errorMessage(error) {
|
|
459
|
+
return error instanceof Error ? error.message : String(error);
|
|
447
460
|
}
|
|
448
461
|
function definedRecord(input) {
|
|
449
462
|
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
@@ -473,16 +486,6 @@ function readNumber(record, ...keys) {
|
|
|
473
486
|
}
|
|
474
487
|
return undefined;
|
|
475
488
|
}
|
|
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
489
|
function readNumberArray(record, ...keys) {
|
|
487
490
|
if (!record)
|
|
488
491
|
return undefined;
|
|
@@ -496,14 +499,4 @@ function readNumberArray(record, ...keys) {
|
|
|
496
499
|
}
|
|
497
500
|
return undefined;
|
|
498
501
|
}
|
|
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
502
|
//# sourceMappingURL=relay-fleet-client.js.map
|