@agent-relay/factory 0.1.72 → 0.1.74
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 +9 -0
- package/dist/cli/diagnose.d.ts +18 -2
- package/dist/cli/diagnose.d.ts.map +1 -1
- package/dist/cli/diagnose.js +94 -5
- package/dist/cli/diagnose.js.map +1 -1
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +10 -0
- package/dist/cli/fleet.js.map +1 -1
- package/dist/cli/teammate-mcp.d.ts +19 -0
- package/dist/cli/teammate-mcp.d.ts.map +1 -0
- package/dist/cli/teammate-mcp.js +102 -0
- package/dist/cli/teammate-mcp.js.map +1 -0
- package/dist/environments/load-profile.d.ts +4 -4
- package/dist/environments/stack-descriptor.d.ts +17 -17
- package/dist/environments/verification-stack-descriptor.d.ts +32 -32
- package/dist/fleet/control-plane-circuit.d.ts +10 -0
- package/dist/fleet/control-plane-circuit.d.ts.map +1 -1
- package/dist/fleet/control-plane-circuit.js +9 -1
- package/dist/fleet/control-plane-circuit.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 +1 -0
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/internal-fleet-client.d.ts +29 -1
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +161 -11
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +43 -2
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +264 -19
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/fleet/teammates.d.ts +46 -0
- package/dist/fleet/teammates.d.ts.map +1 -0
- package/dist/fleet/teammates.js +424 -0
- package/dist/fleet/teammates.js.map +1 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +4 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +18 -6
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/mount/relayfile-github-connection-write.d.ts +3 -2
- package/dist/mount/relayfile-github-connection-write.d.ts.map +1 -1
- package/dist/mount/relayfile-github-connection-write.js +33 -0
- package/dist/mount/relayfile-github-connection-write.js.map +1 -1
- package/dist/node/factory-node-runtime.d.ts +17 -0
- package/dist/node/factory-node-runtime.d.ts.map +1 -0
- package/dist/node/factory-node-runtime.js +100 -0
- package/dist/node/factory-node-runtime.js.map +1 -0
- package/dist/node/factory-node.d.ts +10 -1
- package/dist/node/factory-node.d.ts.map +1 -1
- package/dist/node/factory-node.js +13 -2
- package/dist/node/factory-node.js.map +1 -1
- package/dist/node/factory-persona-card.d.ts +38 -0
- package/dist/node/factory-persona-card.d.ts.map +1 -0
- package/dist/node/factory-persona-card.js +163 -0
- package/dist/node/factory-persona-card.js.map +1 -0
- package/dist/node/factory.node.d.ts +1 -1
- package/dist/observability/events.d.ts +38 -38
- package/dist/orchestrator/dispatch-failure-reason.d.ts +60 -0
- package/dist/orchestrator/dispatch-failure-reason.d.ts.map +1 -0
- package/dist/orchestrator/dispatch-failure-reason.js +127 -0
- package/dist/orchestrator/dispatch-failure-reason.js.map +1 -0
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +1175 -103
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/public-health.d.ts.map +1 -1
- package/dist/orchestrator/public-health.js +152 -6
- package/dist/orchestrator/public-health.js.map +1 -1
- package/dist/orchestrator/release-error.d.ts +26 -0
- package/dist/orchestrator/release-error.d.ts.map +1 -0
- package/dist/orchestrator/release-error.js +33 -0
- package/dist/orchestrator/release-error.js.map +1 -0
- package/dist/ports/fleet.d.ts +104 -0
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/fleet.js +10 -1
- package/dist/ports/fleet.js.map +1 -1
- package/dist/ports/index.d.ts +4 -3
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/index.js +1 -1
- package/dist/ports/index.js.map +1 -1
- package/dist/ports/mount.d.ts +10 -2
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/writeback.d.ts +45 -4
- package/dist/ports/writeback.d.ts.map +1 -1
- package/dist/state/watch-state-document.js +4 -1
- package/dist/state/watch-state-document.js.map +1 -1
- package/dist/testing/fakes.d.ts +8 -3
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +25 -1
- package/dist/testing/fakes.js.map +1 -1
- package/dist/triage/schema.d.ts +14 -14
- package/dist/types.d.ts +170 -14
- package/dist/types.d.ts.map +1 -1
- package/dist/writeback/github.d.ts +17 -9
- package/dist/writeback/github.d.ts.map +1 -1
- package/dist/writeback/github.js +331 -9
- package/dist/writeback/github.js.map +1 -1
- package/dist/writeback/linear.d.ts +5 -1
- package/dist/writeback/linear.d.ts.map +1 -1
- package/dist/writeback/linear.js +89 -1
- package/dist/writeback/linear.js.map +1 -1
- package/package.json +4 -2
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { AgentRelay } from '@agent-relay/sdk';
|
|
3
|
+
import { describeControlPlaneError } from './control-plane-circuit.js';
|
|
2
4
|
import { resolveRelayAgentToken, resolveRelayWorkspaceKey } from './relay-workspace-key.js';
|
|
5
|
+
import { RelaycastTeammateDirectory } from './teammates.js';
|
|
3
6
|
const knownCapabilities = new Set(['spawn:claude', 'spawn:codex', 'workflow:run', 'preview:tailscale-serve']);
|
|
4
7
|
const openStatuses = new Set(['pending', 'dispatched', 'invoked']);
|
|
5
8
|
const terminalStatuses = new Set(['completed', 'failed', 'denied']);
|
|
@@ -112,11 +115,26 @@ export class RelayFleetClient {
|
|
|
112
115
|
// therefore createFleet({ backend: 'relay' })) never throws merely because no
|
|
113
116
|
// token is configured.
|
|
114
117
|
#messaging;
|
|
118
|
+
#teammateDirectory;
|
|
115
119
|
#messagingReady;
|
|
120
|
+
#messageStreamIdentityReady;
|
|
116
121
|
#lifecycleActionReady;
|
|
117
122
|
#authenticatedAgentName;
|
|
118
123
|
#eventsStarted = false;
|
|
124
|
+
#eventSubscriptionReady;
|
|
125
|
+
#messageObservabilityReady;
|
|
126
|
+
#resolveMessageObservability;
|
|
127
|
+
#rejectMessageObservability;
|
|
119
128
|
#disposed = false;
|
|
129
|
+
/**
|
|
130
|
+
* The fleet socket's own status.
|
|
131
|
+
*
|
|
132
|
+
* Kept here rather than inferred by callers because the dial is
|
|
133
|
+
* fire-and-forget: `#ensureEventSubscription` cannot return its outcome, so
|
|
134
|
+
* without this the ONLY trace of a failed connect was a `#log` line that no
|
|
135
|
+
* health surface reads.
|
|
136
|
+
*/
|
|
137
|
+
#fleetConnect = { state: 'never-attempted', attempts: 0 };
|
|
120
138
|
#watchTimer;
|
|
121
139
|
#reconciling;
|
|
122
140
|
#pendingReleaseRetry;
|
|
@@ -146,6 +164,7 @@ export class RelayFleetClient {
|
|
|
146
164
|
this.#log = options.log ?? (() => { });
|
|
147
165
|
this.#readOnly = options.readOnly ?? false;
|
|
148
166
|
this.#messaging = options.messaging;
|
|
167
|
+
this.#teammateDirectory = options.teammateDirectory;
|
|
149
168
|
}
|
|
150
169
|
/** Agents spawned through this client that have not exited or been released. */
|
|
151
170
|
trackedAgents() {
|
|
@@ -416,8 +435,84 @@ export class RelayFleetClient {
|
|
|
416
435
|
})),
|
|
417
436
|
};
|
|
418
437
|
}
|
|
438
|
+
async discoverTeammates(query) {
|
|
439
|
+
this.#teammateDirectory ??= this.#createTeammateDirectory();
|
|
440
|
+
return await this.#teammateDirectory.discover(query);
|
|
441
|
+
}
|
|
419
442
|
// `from`/`data` are not representable on the agent-scoped messaging surface:
|
|
420
443
|
// every send is authored by the factory's own agent identity.
|
|
444
|
+
//
|
|
445
|
+
// That identity is also the `target` stamped on every inbound message (see
|
|
446
|
+
// `#emitAgentMessage`), so a reply waiter must match against it rather than
|
|
447
|
+
// against the `from` its caller asked to send as.
|
|
448
|
+
//
|
|
449
|
+
// `#authenticatedAgentName` starts life as the CONFIGURED `agentName` and is
|
|
450
|
+
// only replaced with the server's answer once something has called
|
|
451
|
+
// `agents.me()`. Returning it synchronously would hand out that pre-auth
|
|
452
|
+
// guess, which is wrong whenever an injected `messaging` or an existing
|
|
453
|
+
// `agentToken` authenticates as a different name. Resolve it for real.
|
|
454
|
+
async effectiveSender() {
|
|
455
|
+
const messaging = await this.#ensureMessaging();
|
|
456
|
+
const identity = await messaging.agents.me();
|
|
457
|
+
const resolved = identity.name?.trim();
|
|
458
|
+
if (resolved)
|
|
459
|
+
this.#authenticatedAgentName = resolved;
|
|
460
|
+
return this.#authenticatedAgentName;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Identify the actual Relay workspace stream rather than this wrapper.
|
|
464
|
+
* Multiple clients authenticated to one workspace receive the same direct
|
|
465
|
+
* messages, so their uncorrelated ask/reply claims must contend.
|
|
466
|
+
*/
|
|
467
|
+
async messageStreamIdentity() {
|
|
468
|
+
this.#messageStreamIdentityReady ??= (async () => {
|
|
469
|
+
const messaging = await this.#ensureMessaging();
|
|
470
|
+
const baseUrl = canonicalRelayBaseUrl(this.#options.baseUrl);
|
|
471
|
+
const explicitScope = this.#options.messageStreamScope;
|
|
472
|
+
if (this.#options.messaging && explicitScope !== undefined) {
|
|
473
|
+
if (typeof explicitScope === 'string') {
|
|
474
|
+
const fingerprint = createHash('sha256').update(explicitScope).digest('base64url');
|
|
475
|
+
return `relay:${baseUrl}:explicit:${fingerprint}`;
|
|
476
|
+
}
|
|
477
|
+
return explicitScope;
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
const workspace = await messaging.workspace.info();
|
|
481
|
+
const workspaceId = typeof workspace.id === 'string' ? workspace.id.trim() : '';
|
|
482
|
+
if (workspaceId)
|
|
483
|
+
return `relay:${baseUrl}:workspace:${workspaceId}`;
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
// Older/injected messaging surfaces may not expose workspace.info(). A
|
|
487
|
+
// credential fingerprint or the injected object below still preserves
|
|
488
|
+
// a stable boundary without failing an otherwise usable message path.
|
|
489
|
+
this.#log(`Unable to resolve Relay workspace identity for teammate claims: ${errorMessage(error)}`);
|
|
490
|
+
}
|
|
491
|
+
// Separate injected wrappers are not evidence of separate streams. If
|
|
492
|
+
// the surface cannot identify its workspace and the caller supplied no
|
|
493
|
+
// explicit scope, fail closed by sharing one endpoint-scoped registry.
|
|
494
|
+
// This may conservatively serialize identical participants in two legacy
|
|
495
|
+
// workspaces, but it cannot admit two waiters for one unknown stream.
|
|
496
|
+
if (this.#options.messaging)
|
|
497
|
+
return `relay:${baseUrl}:injected:unknown`;
|
|
498
|
+
const env = this.#options.env;
|
|
499
|
+
const agentToken = resolveRelayAgentToken({
|
|
500
|
+
agentToken: this.#registeredAgentToken ?? this.#options.agentToken,
|
|
501
|
+
...(env ? { env } : {}),
|
|
502
|
+
});
|
|
503
|
+
const workspaceKey = resolveRelayWorkspaceKey({
|
|
504
|
+
workspaceKey: this.#options.workspaceKey,
|
|
505
|
+
...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
|
|
506
|
+
});
|
|
507
|
+
const credential = agentToken ?? workspaceKey;
|
|
508
|
+
if (credential) {
|
|
509
|
+
const fingerprint = createHash('sha256').update(credential).digest('base64url');
|
|
510
|
+
return `relay:${baseUrl}:credential:${fingerprint}`;
|
|
511
|
+
}
|
|
512
|
+
return messaging;
|
|
513
|
+
})();
|
|
514
|
+
return await this.#messageStreamIdentityReady;
|
|
515
|
+
}
|
|
421
516
|
async sendMessage(input) {
|
|
422
517
|
await this.#send(input);
|
|
423
518
|
}
|
|
@@ -484,6 +579,7 @@ export class RelayFleetClient {
|
|
|
484
579
|
}
|
|
485
580
|
}
|
|
486
581
|
this.#disposed = true;
|
|
582
|
+
this.#settleMessageObservability(new Error('Relay fleet client disposed before its event stream connected'));
|
|
487
583
|
if (this.#watchTimer) {
|
|
488
584
|
clearInterval(this.#watchTimer);
|
|
489
585
|
this.#watchTimer = undefined;
|
|
@@ -527,6 +623,25 @@ export class RelayFleetClient {
|
|
|
527
623
|
throw error;
|
|
528
624
|
}
|
|
529
625
|
}
|
|
626
|
+
#createTeammateDirectory() {
|
|
627
|
+
const env = this.#options.env;
|
|
628
|
+
const token = resolveRelayWorkspaceKey({
|
|
629
|
+
workspaceKey: this.#options.workspaceKey,
|
|
630
|
+
...(env ? { env, activeWorkspaceKey: () => undefined } : {}),
|
|
631
|
+
}) ?? resolveRelayAgentToken({
|
|
632
|
+
agentToken: this.#options.agentToken,
|
|
633
|
+
...(env ? { env } : {}),
|
|
634
|
+
});
|
|
635
|
+
if (!token) {
|
|
636
|
+
throw new Error('RelayFleetClient teammate discovery requires a workspace key or agent token');
|
|
637
|
+
}
|
|
638
|
+
return new RelaycastTeammateDirectory({
|
|
639
|
+
baseUrl: this.#options.baseUrl,
|
|
640
|
+
token,
|
|
641
|
+
fetch: this.#options.directoryFetch,
|
|
642
|
+
timeoutMs: this.#options.directoryTimeoutMs,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
530
645
|
#ensureMessaging() {
|
|
531
646
|
if (this.#messaging)
|
|
532
647
|
return Promise.resolve(this.#messaging);
|
|
@@ -586,7 +701,7 @@ export class RelayFleetClient {
|
|
|
586
701
|
async #registerFactoryAgent(workspaceKey) {
|
|
587
702
|
this.#registrationAttempts += 1;
|
|
588
703
|
if (this.#registrationAttempts > MAX_REGISTRATION_ATTEMPTS) {
|
|
589
|
-
throw new FactoryAgentRegistrationError(this.#agentName, `gave up after ${MAX_REGISTRATION_ATTEMPTS} registration attempts`);
|
|
704
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'MAX_ATTEMPTS', `gave up after ${MAX_REGISTRATION_ATTEMPTS} registration attempts`);
|
|
590
705
|
}
|
|
591
706
|
const bootstrap = this.#createRelay({
|
|
592
707
|
workspaceKey,
|
|
@@ -721,7 +836,7 @@ export class RelayFleetClient {
|
|
|
721
836
|
const existing = asRecord(await agents.get(this.#agentName).catch(() => undefined));
|
|
722
837
|
const agentId = readString(existing, 'id', 'agent_id', 'agentId');
|
|
723
838
|
if (!agentId) {
|
|
724
|
-
throw new FactoryAgentRegistrationError(this.#agentName, `name is taken but the record could not be read back: ${errorMessage(lastError)}`, { cause: lastError });
|
|
839
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'RECORD_UNREADABLE', `name is taken but the record could not be read back: ${errorMessage(lastError)}`, { cause: lastError });
|
|
725
840
|
}
|
|
726
841
|
await this.#assertAgentNotLive(agents, existing);
|
|
727
842
|
try {
|
|
@@ -733,10 +848,10 @@ export class RelayFleetClient {
|
|
|
733
848
|
continue;
|
|
734
849
|
if (error instanceof FactoryAgentRegistrationError)
|
|
735
850
|
throw error;
|
|
736
|
-
throw new FactoryAgentRegistrationError(this.#agentName, `could not reclaim the existing identity: ${errorMessage(error)}`, { cause: error });
|
|
851
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'TAKEOVER_FAILED', `could not reclaim the existing identity: ${errorMessage(error)}`, { cause: error });
|
|
737
852
|
}
|
|
738
853
|
}
|
|
739
|
-
throw new FactoryAgentRegistrationError(this.#agentName, `could not reclaim the existing identity: ${errorMessage(lastError)}`, { cause: lastError });
|
|
854
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'TAKEOVER_EXHAUSTED', `could not reclaim the existing identity: ${errorMessage(lastError)}`, { cause: lastError });
|
|
740
855
|
}
|
|
741
856
|
// Seizing a name from a LIVE agent would strand its credential. Our case is a
|
|
742
857
|
// collision with our own dead record, but the guard is coded rather than
|
|
@@ -748,7 +863,7 @@ export class RelayFleetClient {
|
|
|
748
863
|
// record that says offline gets as far as the presence check.
|
|
749
864
|
const status = readString(existing, 'status');
|
|
750
865
|
if (status !== 'offline') {
|
|
751
|
-
throw new FactoryAgentRegistrationError(this.#agentName, `refusing to take over agent in status "${status ?? 'unknown'}"; another factory may still hold this identity`);
|
|
866
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'STATUS_NOT_OFFLINE', `refusing to take over agent in status "${status ?? 'unknown'}"; another factory may still hold this identity`);
|
|
752
867
|
}
|
|
753
868
|
// Presence is the canonical liveness signal, and the agent record can be
|
|
754
869
|
// stale. Two outcomes are routed apart here, and collapsing them swaps one
|
|
@@ -782,10 +897,10 @@ export class RelayFleetClient {
|
|
|
782
897
|
}
|
|
783
898
|
const seenStatus = readString(entry, 'status');
|
|
784
899
|
if (seenStatus === undefined) {
|
|
785
|
-
throw new FactoryAgentRegistrationError(this.#agentName, 'presence
|
|
900
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_STATUS_MISSING', 'presence reported no status for this agent, so it cannot be confirmed offline');
|
|
786
901
|
}
|
|
787
902
|
if (LIVE_AGENT_STATUSES.has(seenStatus)) {
|
|
788
|
-
throw new FactoryAgentRegistrationError(this.#agentName, `presence reports this agent as "${seenStatus}"; another factory may still hold this identity`);
|
|
903
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_REPORTS_LIVE', `presence reports this agent as "${seenStatus}"; another factory may still hold this identity`);
|
|
789
904
|
}
|
|
790
905
|
}
|
|
791
906
|
/**
|
|
@@ -803,10 +918,10 @@ export class RelayFleetClient {
|
|
|
803
918
|
presence = await agents.presence();
|
|
804
919
|
}
|
|
805
920
|
catch (error) {
|
|
806
|
-
throw new FactoryAgentRegistrationError(this.#agentName, `presence is unreadable, so the agent cannot be confirmed offline: ${errorMessage(error)}`, { cause: error });
|
|
921
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_UNREADABLE', `presence is unreadable, so the agent cannot be confirmed offline: ${errorMessage(error)}`, { cause: error });
|
|
807
922
|
}
|
|
808
923
|
if (!Array.isArray(presence)) {
|
|
809
|
-
throw new FactoryAgentRegistrationError(this.#agentName, 'presence is unreadable (it did not return a list), so the agent cannot be confirmed offline');
|
|
924
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_NOT_A_LIST', 'presence is unreadable (it did not return a list), so the agent cannot be confirmed offline');
|
|
810
925
|
}
|
|
811
926
|
// A list whose rows we cannot name is not readable either, however
|
|
812
927
|
// well-formed the array around them is. Omission only means absence if a
|
|
@@ -817,7 +932,7 @@ export class RelayFleetClient {
|
|
|
817
932
|
// here, on the read, rather than inferred from the lookup missing.
|
|
818
933
|
const unnamed = presence.findIndex((row) => readString(asRecord(row), 'agentName', 'agent_name', 'name') === undefined);
|
|
819
934
|
if (unnamed !== -1) {
|
|
820
|
-
throw new FactoryAgentRegistrationError(this.#agentName, `presence is unreadable (row ${unnamed} carries no agent name), so the agent cannot be confirmed offline`);
|
|
935
|
+
throw new FactoryAgentRegistrationError(this.#agentName, 'PRESENCE_ROW_UNNAMED', `presence is unreadable (row ${unnamed} carries no agent name), so the agent cannot be confirmed offline`);
|
|
821
936
|
}
|
|
822
937
|
return presence;
|
|
823
938
|
}
|
|
@@ -977,20 +1092,103 @@ export class RelayFleetClient {
|
|
|
977
1092
|
if (this.#eventsStarted)
|
|
978
1093
|
return;
|
|
979
1094
|
this.#eventsStarted = true;
|
|
980
|
-
|
|
1095
|
+
this.#fleetConnect = {
|
|
1096
|
+
...this.#fleetConnect,
|
|
1097
|
+
state: 'connecting',
|
|
1098
|
+
attempts: this.#fleetConnect.attempts + 1,
|
|
1099
|
+
lastAttemptAtMs: this.#now(),
|
|
1100
|
+
};
|
|
1101
|
+
this.#eventSubscriptionReady = this.#subscribeEvents().catch((error) => {
|
|
981
1102
|
this.#eventsStarted = false;
|
|
1103
|
+
this.#eventSubscriptionReady = undefined;
|
|
1104
|
+
// The rejection now lands in a field a health surface can publish. It kept
|
|
1105
|
+
// going only to `#log` before, which is why a fleet client that never
|
|
1106
|
+
// connected was indistinguishable from a healthy one everywhere.
|
|
1107
|
+
this.#fleetConnect = {
|
|
1108
|
+
...this.#fleetConnect,
|
|
1109
|
+
state: 'failed',
|
|
1110
|
+
lastFailureAtMs: this.#now(),
|
|
1111
|
+
lastError: describeControlPlaneError(error),
|
|
1112
|
+
};
|
|
982
1113
|
this.#log(`relay fleet event subscription failed: ${errorMessage(error)}`);
|
|
1114
|
+
throw error;
|
|
983
1115
|
});
|
|
1116
|
+
// Nothing here awaits it -- callers that must not miss an inbound message
|
|
1117
|
+
// use `whenMessagesObservable()`. The rejection is re-thrown for them and
|
|
1118
|
+
// swallowed here so a background subscription failure stays non-fatal.
|
|
1119
|
+
void this.#eventSubscriptionReady.catch(() => { });
|
|
1120
|
+
}
|
|
1121
|
+
// `onAgentMessage` returns as soon as the listener is in the local set, but
|
|
1122
|
+
// the SDK handler behind it is installed by an async chain (messaging
|
|
1123
|
+
// bootstrap, then lifecycle registration, then `events.connect()`). A caller
|
|
1124
|
+
// that registers a listener and immediately sends can therefore lose a fast
|
|
1125
|
+
// reply that lands before the transport is actually listening. Await this
|
|
1126
|
+
// between the two.
|
|
1127
|
+
async whenMessagesObservable() {
|
|
1128
|
+
this.#ensureEventSubscription();
|
|
1129
|
+
await this.#eventSubscriptionReady;
|
|
1130
|
+
if (this.#fleetConnect.state === 'connected')
|
|
1131
|
+
return;
|
|
1132
|
+
await this.#messageObservabilityPromise();
|
|
1133
|
+
}
|
|
1134
|
+
#messageObservabilityPromise() {
|
|
1135
|
+
if (this.#fleetConnect.state === 'connected')
|
|
1136
|
+
return Promise.resolve();
|
|
1137
|
+
if (!this.#messageObservabilityReady) {
|
|
1138
|
+
const pending = new Promise((resolve, reject) => {
|
|
1139
|
+
this.#resolveMessageObservability = resolve;
|
|
1140
|
+
this.#rejectMessageObservability = reject;
|
|
1141
|
+
});
|
|
1142
|
+
this.#messageObservabilityReady = pending;
|
|
1143
|
+
// The ask-level deadline may stop awaiting this shared handshake first.
|
|
1144
|
+
// Retain the waiter for another caller, but never surface its later
|
|
1145
|
+
// transport rejection as an unhandled promise.
|
|
1146
|
+
void pending.catch(() => { });
|
|
1147
|
+
}
|
|
1148
|
+
return this.#messageObservabilityReady;
|
|
1149
|
+
}
|
|
1150
|
+
#settleMessageObservability(error) {
|
|
1151
|
+
const resolve = this.#resolveMessageObservability;
|
|
1152
|
+
const reject = this.#rejectMessageObservability;
|
|
1153
|
+
this.#messageObservabilityReady = undefined;
|
|
1154
|
+
this.#resolveMessageObservability = undefined;
|
|
1155
|
+
this.#rejectMessageObservability = undefined;
|
|
1156
|
+
if (error)
|
|
1157
|
+
reject?.(error);
|
|
1158
|
+
else
|
|
1159
|
+
resolve?.();
|
|
1160
|
+
}
|
|
1161
|
+
fleetConnectStatus() {
|
|
1162
|
+
return { ...this.#fleetConnect };
|
|
984
1163
|
}
|
|
985
1164
|
async #subscribeEvents() {
|
|
986
1165
|
const messaging = await this.#ensureMessaging();
|
|
987
|
-
|
|
1166
|
+
if (this.#options.registerLifecycleAction !== false) {
|
|
1167
|
+
await this.#ensureLifecycleAction(messaging);
|
|
1168
|
+
}
|
|
988
1169
|
if (this.#disposed)
|
|
989
1170
|
return;
|
|
990
|
-
|
|
991
|
-
|
|
1171
|
+
// Listen before dialing so a synchronous lifecycle event cannot race past
|
|
1172
|
+
// the observer. `connect()` is void: returning only proves the SDK accepted
|
|
1173
|
+
// the dial, not that the WebSocket handshake completed.
|
|
1174
|
+
const unsubscribe = messaging.events.on('any', (event) => this.#handleEvent(event));
|
|
1175
|
+
const statusBeforeDial = this.#fleetConnect;
|
|
1176
|
+
try {
|
|
1177
|
+
messaging.events.connect();
|
|
1178
|
+
}
|
|
1179
|
+
catch (error) {
|
|
1180
|
+
unsubscribe();
|
|
1181
|
+
throw error;
|
|
1182
|
+
}
|
|
1183
|
+
this.#eventUnsubscribers.push(unsubscribe);
|
|
1184
|
+
this.#fleetConnect = {
|
|
1185
|
+
...this.#fleetConnect,
|
|
1186
|
+
...(this.#fleetConnect === statusBeforeDial ? { state: 'dialed' } : {}),
|
|
1187
|
+
lastDialedAtMs: this.#now(),
|
|
1188
|
+
};
|
|
992
1189
|
}
|
|
993
1190
|
#handleEvent(event) {
|
|
1191
|
+
this.#observeFleetConnectionEvent(event);
|
|
994
1192
|
switch (event.type) {
|
|
995
1193
|
case 'dmReceived':
|
|
996
1194
|
case 'groupDmReceived':
|
|
@@ -1012,6 +1210,45 @@ export class RelayFleetClient {
|
|
|
1012
1210
|
break;
|
|
1013
1211
|
}
|
|
1014
1212
|
}
|
|
1213
|
+
/** Translate the SDK's real stream lifecycle into the published status. */
|
|
1214
|
+
#observeFleetConnectionEvent(event) {
|
|
1215
|
+
const now = this.#now();
|
|
1216
|
+
switch (event.type) {
|
|
1217
|
+
case 'disconnected':
|
|
1218
|
+
case 'permanentlyDisconnected':
|
|
1219
|
+
this.#fleetConnect = {
|
|
1220
|
+
...this.#fleetConnect,
|
|
1221
|
+
state: 'failed',
|
|
1222
|
+
lastFailureAtMs: now,
|
|
1223
|
+
lastError: 'RelayEventStreamDisconnected',
|
|
1224
|
+
};
|
|
1225
|
+
this.#settleMessageObservability(new Error('Relay event stream disconnected before becoming observable'));
|
|
1226
|
+
return;
|
|
1227
|
+
case 'error':
|
|
1228
|
+
this.#fleetConnect = {
|
|
1229
|
+
...this.#fleetConnect,
|
|
1230
|
+
state: 'failed',
|
|
1231
|
+
lastFailureAtMs: now,
|
|
1232
|
+
lastError: 'RelayEventStreamError',
|
|
1233
|
+
};
|
|
1234
|
+
this.#settleMessageObservability(new Error('Relay event stream failed before becoming observable'));
|
|
1235
|
+
return;
|
|
1236
|
+
case 'reconnecting':
|
|
1237
|
+
this.#fleetConnect = { ...this.#fleetConnect, state: 'connecting' };
|
|
1238
|
+
return;
|
|
1239
|
+
default:
|
|
1240
|
+
// `connected` and every data event are confirmations that the event
|
|
1241
|
+
// stream opened. A void `connect()` call alone never reaches this arm.
|
|
1242
|
+
this.#fleetConnect = {
|
|
1243
|
+
...this.#fleetConnect,
|
|
1244
|
+
state: 'connected',
|
|
1245
|
+
firstEventAtMs: this.#fleetConnect.firstEventAtMs ?? now,
|
|
1246
|
+
...(this.#fleetConnect.state !== 'connected' ? { lastConnectedAtMs: now } : {}),
|
|
1247
|
+
lastError: undefined,
|
|
1248
|
+
};
|
|
1249
|
+
this.#settleMessageObservability();
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1015
1252
|
#emitAgentMessage(message, fallbackTarget) {
|
|
1016
1253
|
const from = message.from?.name;
|
|
1017
1254
|
if (!from || from === this.#authenticatedAgentName || from === this.#agentName)
|
|
@@ -1303,16 +1540,15 @@ function previewReference(value, placementNode) {
|
|
|
1303
1540
|
...(node ? { node } : {}),
|
|
1304
1541
|
};
|
|
1305
1542
|
}
|
|
1306
|
-
/**
|
|
1307
|
-
* Registration could not converge on a usable agent identity. Named so a
|
|
1308
|
-
* six-day silent 409 loop surfaces as one actionable failure instead.
|
|
1309
|
-
*/
|
|
1310
1543
|
export class FactoryAgentRegistrationError extends Error {
|
|
1311
1544
|
agentName;
|
|
1312
|
-
|
|
1545
|
+
/** Uppercase token so `describeControlPlaneError` renders it after the name. */
|
|
1546
|
+
code;
|
|
1547
|
+
constructor(agentName, code, detail, options) {
|
|
1313
1548
|
super(`Factory agent "${agentName}" could not register with the relay workspace: ${detail}`);
|
|
1314
1549
|
this.name = 'FactoryAgentRegistrationError';
|
|
1315
1550
|
this.agentName = agentName;
|
|
1551
|
+
this.code = code;
|
|
1316
1552
|
if (options && 'cause' in options) {
|
|
1317
1553
|
;
|
|
1318
1554
|
this.cause = options.cause;
|
|
@@ -1341,6 +1577,15 @@ function isIdentityMovedError(error) {
|
|
|
1341
1577
|
return true;
|
|
1342
1578
|
return /agent_identity_conflict|no longer has expected id/i.test(errorMessage(error));
|
|
1343
1579
|
}
|
|
1580
|
+
function canonicalRelayBaseUrl(value) {
|
|
1581
|
+
const candidate = value ?? DEFAULT_RELAY_BASE_URL;
|
|
1582
|
+
try {
|
|
1583
|
+
return new URL(candidate).toString().replace(/\/$/u, '');
|
|
1584
|
+
}
|
|
1585
|
+
catch {
|
|
1586
|
+
return candidate.replace(/\/$/u, '');
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1344
1589
|
function isUnknownRecipientError(error) {
|
|
1345
1590
|
const message = errorMessage(error);
|
|
1346
1591
|
return /not[ _]found|unknown (agent|recipient)|no such (agent|recipient)|unregistered/i.test(message);
|