@zackbart/connecta 0.7.6 → 0.7.7
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/CHANGELOG.md +57 -0
- package/dist/activity.d.ts +17 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js.map +1 -1
- package/dist/auth/clerk.d.ts.map +1 -1
- package/dist/auth/clerk.js +101 -0
- package/dist/auth/clerk.js.map +1 -1
- package/dist/auth/downstream-oauth.d.ts +57 -20
- package/dist/auth/downstream-oauth.d.ts.map +1 -1
- package/dist/auth/downstream-oauth.js +275 -67
- package/dist/auth/downstream-oauth.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +165 -103
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +70 -15
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +238 -19
- package/dist/server.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +8 -0
- package/dist/storage/file.js.map +1 -1
- package/dist/types.d.ts +21 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +7 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +118 -4
- package/dist/ui.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/activity.ts +20 -0
- package/src/auth/clerk.ts +124 -0
- package/src/auth/downstream-oauth.ts +359 -68
- package/src/connectors/remote-mcp.ts +172 -104
- package/src/index.ts +3 -0
- package/src/registry.ts +79 -19
- package/src/server.ts +306 -16
- package/src/storage/file.ts +7 -0
- package/src/types.ts +23 -0
- package/src/ui.ts +124 -3
- package/src/version.ts +1 -1
|
@@ -393,7 +393,7 @@ interface ConnectionState {
|
|
|
393
393
|
connecting: Promise<void> | null;
|
|
394
394
|
authRequired: boolean;
|
|
395
395
|
provider: KvOAuthProvider | null;
|
|
396
|
-
connectedGeneration:
|
|
396
|
+
connectedGeneration: string | null;
|
|
397
397
|
/**
|
|
398
398
|
* One-way latch: set by closeScope and never cleared, so neither a late
|
|
399
399
|
* connect nor a `reset()` can cache a client into a scope that is already
|
|
@@ -451,6 +451,16 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
451
451
|
{ cause },
|
|
452
452
|
);
|
|
453
453
|
|
|
454
|
+
class OperatorDisconnectedError extends ConnectorCallError {
|
|
455
|
+
constructor() {
|
|
456
|
+
super(
|
|
457
|
+
"auth_required",
|
|
458
|
+
`Connector "${id}" was disconnected by an operator — explicitly start authorization to reconnect it.`,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const operatorDisconnectedError = () => new OperatorDisconnectedError();
|
|
463
|
+
|
|
454
464
|
const scopeEndedError = () =>
|
|
455
465
|
new Error(`Connector "${id}" scope ended during connection.`);
|
|
456
466
|
|
|
@@ -518,16 +528,23 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
518
528
|
return state.provider;
|
|
519
529
|
};
|
|
520
530
|
|
|
531
|
+
const newProvider = (ctx: ConnectorContext): KvOAuthProvider =>
|
|
532
|
+
new KvOAuthProvider(
|
|
533
|
+
id,
|
|
534
|
+
ctx.storage,
|
|
535
|
+
`${ctx.baseUrl}/oauth/callback/${id}`,
|
|
536
|
+
);
|
|
537
|
+
|
|
521
538
|
const buildTransport = (
|
|
522
539
|
ctx: ConnectorContext,
|
|
523
|
-
|
|
540
|
+
provider: KvOAuthProvider | null,
|
|
524
541
|
): Transport => {
|
|
525
542
|
if (opts._transportFactory) return opts._transportFactory(ctx);
|
|
526
543
|
const url = new URL(opts.url);
|
|
527
544
|
const guardedFetch = redirectSafeFetch(id, opts.redirects);
|
|
528
545
|
if (opts.auth?.type === "oauth") {
|
|
529
546
|
return new StreamableHTTPClientTransport(url, {
|
|
530
|
-
authProvider:
|
|
547
|
+
authProvider: provider ?? newProvider(ctx),
|
|
531
548
|
fetch: guardedFetch,
|
|
532
549
|
});
|
|
533
550
|
}
|
|
@@ -547,6 +564,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
547
564
|
state.transport = null;
|
|
548
565
|
state.connecting = null;
|
|
549
566
|
state.authRequired = false;
|
|
567
|
+
state.provider = null;
|
|
550
568
|
state.connectedGeneration = null;
|
|
551
569
|
// `closed` is deliberately not cleared — see ConnectionState.
|
|
552
570
|
};
|
|
@@ -563,97 +581,160 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
563
581
|
new UnauthorizedError("Downstream authorization is no longer valid."),
|
|
564
582
|
);
|
|
565
583
|
}
|
|
584
|
+
// Read the OAuth epoch before trusting either a cached client or starting a
|
|
585
|
+
// transport. A disconnected epoch is a durable operator instruction, not
|
|
586
|
+
// merely the absence of credentials: passive status/tool probes must not
|
|
587
|
+
// turn it back into a pending consent flow.
|
|
588
|
+
let oauthGeneration: string | undefined;
|
|
589
|
+
if (isOauth && (state.client || state.connecting)) {
|
|
590
|
+
const provider = getProvider(ctx, state);
|
|
591
|
+
oauthGeneration = await provider.generation();
|
|
592
|
+
if (provider.isOperatorDisconnectedGeneration(oauthGeneration)) {
|
|
593
|
+
const connecting = state.connecting;
|
|
594
|
+
const client = state.client;
|
|
595
|
+
const transport = state.transport;
|
|
596
|
+
reset(state);
|
|
597
|
+
void connecting?.catch(() => {});
|
|
598
|
+
try {
|
|
599
|
+
if (client) await client.close();
|
|
600
|
+
else await transport?.close();
|
|
601
|
+
} catch {
|
|
602
|
+
// The disconnected epoch is authoritative even if local close fails.
|
|
603
|
+
}
|
|
604
|
+
throw operatorDisconnectedError();
|
|
605
|
+
}
|
|
606
|
+
}
|
|
566
607
|
// Cross-isolate force re-auth: another isolate bumped the KV generation and
|
|
567
608
|
// wiped credentials. This request's cached client still speaks the old
|
|
568
609
|
// token — drop it so the next connect runs against current state.
|
|
569
|
-
if (state.client &&
|
|
570
|
-
const generation = await getProvider(ctx, state).generation();
|
|
610
|
+
if (state.client && oauthGeneration !== undefined && state.connectedGeneration !== null) {
|
|
571
611
|
if (state.closed) throw scopeEndedError();
|
|
572
|
-
if (
|
|
612
|
+
if (oauthGeneration !== state.connectedGeneration) {
|
|
573
613
|
reset(state);
|
|
574
614
|
}
|
|
575
615
|
}
|
|
576
616
|
if (state.closed) throw scopeEndedError();
|
|
577
617
|
if (state.client) return;
|
|
578
|
-
state.connecting
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
// the resulting client — is dropped if a concurrent force bumps the
|
|
585
|
-
// generation past this point, instead of re-persisting wiped credentials.
|
|
586
|
-
provider?.captureGeneration(genAtStart);
|
|
587
|
-
// The SDK defaults to AJV, which compiles every advertised outputSchema
|
|
588
|
-
// with `new Function`. Cloudflare Workers prohibit dynamic code
|
|
589
|
-
// generation, so a remote such as Stripe fails during tools/list unless
|
|
590
|
-
// the SDK's edge-safe validator is selected explicitly.
|
|
591
|
-
const c = new Client(
|
|
592
|
-
{ name: "connecta", version: CONNECTA_VERSION },
|
|
593
|
-
{ jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
|
|
594
|
-
);
|
|
595
|
-
const t = buildTransport(ctx, state);
|
|
596
|
-
state.transport = t;
|
|
597
|
-
try {
|
|
598
|
-
await c.connect(t);
|
|
599
|
-
// A probe deadline can end its scope while connect is still in flight.
|
|
600
|
-
// The transport is closed immediately by closeScope; if connect wins
|
|
601
|
-
// that race anyway, close the resulting client rather than resurrecting
|
|
602
|
-
// a session in the detached state object.
|
|
603
|
-
if (state.closed) {
|
|
618
|
+
if (!state.connecting) {
|
|
619
|
+
let attempt!: Promise<void>;
|
|
620
|
+
attempt = (async () => {
|
|
621
|
+
const ownsAttempt = () =>
|
|
622
|
+
state.connecting === attempt && !state.closed;
|
|
623
|
+
const abandon = async (owner: Client | Transport) => {
|
|
604
624
|
try {
|
|
605
|
-
await
|
|
625
|
+
await owner.close();
|
|
606
626
|
} catch {
|
|
607
|
-
// The
|
|
627
|
+
// The attempt is detached either way.
|
|
608
628
|
}
|
|
609
629
|
throw scopeEndedError();
|
|
630
|
+
};
|
|
631
|
+
// Let the assignment immediately below this async IIFE publish
|
|
632
|
+
// `state.connecting = attempt` before ownership is checked. OAuth's
|
|
633
|
+
// generation read naturally yields; unauthenticated transports do not.
|
|
634
|
+
await Promise.resolve();
|
|
635
|
+
// A provider belongs to exactly one connect attempt. A force reset can
|
|
636
|
+
// abandon that attempt while its transport still holds the provider;
|
|
637
|
+
// the replacement must never mutate the abandoned provider's epoch.
|
|
638
|
+
const provider = isOauth ? newProvider(ctx) : null;
|
|
639
|
+
const genAtStart = provider ? await provider.generation() : "";
|
|
640
|
+
if (!ownsAttempt()) throw scopeEndedError();
|
|
641
|
+
if (provider?.isOperatorDisconnectedGeneration(genAtStart)) {
|
|
642
|
+
throw operatorDisconnectedError();
|
|
610
643
|
}
|
|
611
|
-
|
|
612
|
-
//
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
//
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
644
|
+
provider?.captureGeneration(genAtStart);
|
|
645
|
+
// The SDK defaults to AJV, which compiles every advertised outputSchema
|
|
646
|
+
// with `new Function`. Cloudflare Workers prohibit dynamic code
|
|
647
|
+
// generation, so a remote such as Stripe fails during tools/list unless
|
|
648
|
+
// the SDK's edge-safe validator is selected explicitly.
|
|
649
|
+
const c = new Client(
|
|
650
|
+
{ name: "connecta", version: CONNECTA_VERSION },
|
|
651
|
+
{ jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
|
|
652
|
+
);
|
|
653
|
+
const t = buildTransport(ctx, provider);
|
|
654
|
+
if (!ownsAttempt()) await abandon(t);
|
|
655
|
+
state.transport = t;
|
|
656
|
+
try {
|
|
657
|
+
await c.connect(t);
|
|
658
|
+
// A probe deadline can end its scope while connect is still in flight.
|
|
659
|
+
// The transport is closed immediately by closeScope; if connect wins
|
|
660
|
+
// that race anyway, close the resulting client rather than
|
|
661
|
+
// resurrecting a session in the detached state object.
|
|
662
|
+
if (!ownsAttempt()) await abandon(c);
|
|
663
|
+
// A force re-auth that landed WHILE we were connecting wiped the
|
|
664
|
+
// credentials this client just bound to. Discard it rather than
|
|
665
|
+
// cache a stale-isolate connection.
|
|
666
|
+
if (provider) {
|
|
667
|
+
const generation = await provider.generation();
|
|
668
|
+
// closeScope can land while the generation read is pending, after
|
|
669
|
+
// connect succeeded but before this client is cached. Discard the
|
|
670
|
+
// client on that side of the await too.
|
|
671
|
+
if (!ownsAttempt()) await abandon(c);
|
|
672
|
+
if (generation !== genAtStart) {
|
|
673
|
+
try {
|
|
674
|
+
await c.close();
|
|
675
|
+
} catch {
|
|
676
|
+
// discarding either way
|
|
677
|
+
}
|
|
678
|
+
throw new UnauthorizedError(
|
|
679
|
+
"Connector was re-authorized during connect; reconnect required.",
|
|
680
|
+
);
|
|
626
681
|
}
|
|
627
|
-
throw scopeEndedError();
|
|
628
682
|
}
|
|
629
|
-
if (
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
683
|
+
if (!ownsAttempt()) await abandon(c);
|
|
684
|
+
state.client = c;
|
|
685
|
+
state.connectedGeneration = genAtStart;
|
|
686
|
+
state.authRequired = false;
|
|
687
|
+
} catch (err) {
|
|
688
|
+
// Only a real 401/UnauthorizedError means auth is the problem — a
|
|
689
|
+
// network error on an oauth connector must surface as "error", not
|
|
690
|
+
// "auth_required".
|
|
691
|
+
if (err instanceof UnauthorizedError && ownsAttempt()) {
|
|
692
|
+
state.authRequired = true;
|
|
638
693
|
}
|
|
694
|
+
if (err instanceof UnauthorizedError) {
|
|
695
|
+
throw authRequiredError(err);
|
|
696
|
+
}
|
|
697
|
+
throw err;
|
|
698
|
+
} finally {
|
|
699
|
+
// Force reset may have abandoned this attempt and installed a new one
|
|
700
|
+
// in the same request scope. An old completion must not erase the new
|
|
701
|
+
// promise and allow a third concurrent connect.
|
|
702
|
+
if (state.connecting === attempt) state.connecting = null;
|
|
639
703
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
704
|
+
})();
|
|
705
|
+
state.connecting = attempt;
|
|
706
|
+
}
|
|
707
|
+
return state.connecting;
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const disconnectAuthorization = async (
|
|
711
|
+
ctx: ConnectorContext,
|
|
712
|
+
state: ConnectionState,
|
|
713
|
+
operatorDisconnected = false,
|
|
714
|
+
): Promise<void> => {
|
|
715
|
+
const provider = getProvider(ctx, state);
|
|
716
|
+
// Publish the replacement epoch before waiting on or closing any
|
|
717
|
+
// request-local transport. A hung connect therefore cannot delay the
|
|
718
|
+
// fence, and every late OAuth write stays in the older namespace.
|
|
719
|
+
const connecting = state.connecting;
|
|
720
|
+
try {
|
|
721
|
+
await provider.resetAuthorization(operatorDisconnected);
|
|
722
|
+
} finally {
|
|
723
|
+
// Consume the abandoned connect and close whichever half of the
|
|
724
|
+
// client/transport exists. Reset is unconditional because KV may already
|
|
725
|
+
// be fenced behind a newer epoch after a cleanup error.
|
|
726
|
+
void connecting?.catch(() => {});
|
|
727
|
+
try {
|
|
728
|
+
if (state.client) {
|
|
729
|
+
await state.client.close();
|
|
730
|
+
} else {
|
|
731
|
+
await state.transport?.close();
|
|
650
732
|
}
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
state.connecting = null;
|
|
733
|
+
} catch {
|
|
734
|
+
// best-effort; the state is discarded either way
|
|
654
735
|
}
|
|
655
|
-
|
|
656
|
-
|
|
736
|
+
reset(state);
|
|
737
|
+
}
|
|
657
738
|
};
|
|
658
739
|
|
|
659
740
|
const connector: Connector = {
|
|
@@ -760,7 +841,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
760
841
|
// Classify that exactly like connect-time and call-time authorization
|
|
761
842
|
// failures, and latch it for the rest of this request scope.
|
|
762
843
|
if (err instanceof UnauthorizedError) {
|
|
763
|
-
state.authRequired = true;
|
|
844
|
+
if (state.client === client) state.authRequired = true;
|
|
764
845
|
throw authRequiredError(err);
|
|
765
846
|
}
|
|
766
847
|
throw err;
|
|
@@ -788,8 +869,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
788
869
|
async callTool(name, args, ctx) {
|
|
789
870
|
const state = stateFor(ctx);
|
|
790
871
|
await ensureConnected(ctx, state);
|
|
872
|
+
const client = state.client!;
|
|
791
873
|
try {
|
|
792
|
-
return await
|
|
874
|
+
return await client.callTool(
|
|
793
875
|
{
|
|
794
876
|
name,
|
|
795
877
|
arguments: (args ?? {}) as Record<string, unknown>,
|
|
@@ -805,7 +887,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
805
887
|
} catch (err) {
|
|
806
888
|
// A grant revoked after connect surfaces here, not in ensureConnected.
|
|
807
889
|
if (err instanceof UnauthorizedError) {
|
|
808
|
-
state.authRequired = true;
|
|
890
|
+
if (state.client === client) state.authRequired = true;
|
|
809
891
|
throw authRequiredError(err);
|
|
810
892
|
}
|
|
811
893
|
throw err;
|
|
@@ -860,6 +942,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
860
942
|
message: "Authorization required — open the URL to connect.",
|
|
861
943
|
};
|
|
862
944
|
}
|
|
945
|
+
if (err instanceof OperatorDisconnectedError) {
|
|
946
|
+
return { state: "auth_required", message: err.message };
|
|
947
|
+
}
|
|
863
948
|
return { state: "error", message: msg(err) };
|
|
864
949
|
}
|
|
865
950
|
},
|
|
@@ -867,12 +952,11 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
867
952
|
async finishAuth(code, ctx) {
|
|
868
953
|
const state = stateFor(ctx);
|
|
869
954
|
const provider = getProvider(ctx, state);
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
// pre-force callback before this runs. Keep those two facts coupled.
|
|
955
|
+
// verifyState ran on this request-scoped provider first and captured the
|
|
956
|
+
// pending flow's generation. If force reset races the exchange, any late
|
|
957
|
+
// token write remains tagged with that older generation and is unreadable.
|
|
874
958
|
const t = (state.transport ??
|
|
875
|
-
buildTransport(ctx,
|
|
959
|
+
buildTransport(ctx, provider)) as StreamableHTTPClientTransport;
|
|
876
960
|
await t.finishAuth(code);
|
|
877
961
|
await provider.clearPending();
|
|
878
962
|
// Reset so the next use reconnects with the freshly stored tokens.
|
|
@@ -895,31 +979,15 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
895
979
|
return getProvider(ctx, state).verifyState(oauthState);
|
|
896
980
|
};
|
|
897
981
|
|
|
982
|
+
connector.disconnectAuth = async (ctx) => {
|
|
983
|
+
await disconnectAuthorization(ctx, stateFor(ctx), true);
|
|
984
|
+
};
|
|
985
|
+
|
|
898
986
|
connector.startAuth = async (ctx, startOpts) => {
|
|
899
987
|
const state = stateFor(ctx);
|
|
900
988
|
const p = getProvider(ctx, state);
|
|
901
|
-
if (startOpts?.force) {
|
|
902
|
-
|
|
903
|
-
// connect attempt runs the flow from scratch (DCR + PKCE + consent).
|
|
904
|
-
// Fence the in-flight connect first: a late-completing attempt must not
|
|
905
|
-
// resurrect the credentials we're about to wipe, nor leave `client` set
|
|
906
|
-
// (which would make ensureConnected below report already-authorized and
|
|
907
|
-
// silently defeat force).
|
|
908
|
-
await state.connecting?.catch(() => {});
|
|
909
|
-
try {
|
|
910
|
-
await state.client?.close();
|
|
911
|
-
} catch {
|
|
912
|
-
// best-effort; the connection is being discarded either way
|
|
913
|
-
}
|
|
914
|
-
// Bump the shared generation FIRST so any other isolate — one mid-
|
|
915
|
-
// connect, or on its next tool call — sees the advance and drops its
|
|
916
|
-
// client instead of keeping the token we're about to revoke.
|
|
917
|
-
await p.bumpGeneration();
|
|
918
|
-
// Wipe KV before dropping in-memory state so nothing racing back in can
|
|
919
|
-
// write tokens over a half-cleared slot.
|
|
920
|
-
await p.invalidateCredentials("all");
|
|
921
|
-
await p.clearPending();
|
|
922
|
-
reset(state);
|
|
989
|
+
if (startOpts?.force || (await p.operatorDisconnected())) {
|
|
990
|
+
await disconnectAuthorization(ctx, state);
|
|
923
991
|
} else {
|
|
924
992
|
// A consent URL already outstanding? Re-issue it rather than re-running
|
|
925
993
|
// the SDK flow, which would overwrite the PKCE verifier and invalidate
|
package/src/index.ts
CHANGED
|
@@ -719,8 +719,11 @@ export type {
|
|
|
719
719
|
ActivityCallSource,
|
|
720
720
|
ActivityOutcome,
|
|
721
721
|
ActivityPage,
|
|
722
|
+
ActivityReadActor,
|
|
723
|
+
ActivityReadEvent,
|
|
722
724
|
ActivityReader,
|
|
723
725
|
ActivityReadGate,
|
|
726
|
+
ActivityReadPage,
|
|
724
727
|
ActivitySink,
|
|
725
728
|
ActivityStore,
|
|
726
729
|
ToolCallActivityEvent,
|
package/src/registry.ts
CHANGED
|
@@ -235,6 +235,10 @@ export class Registry implements RegistryView {
|
|
|
235
235
|
private readonly connectors = new Map<string, Connector>();
|
|
236
236
|
private readonly cache = new Map<string, CacheEntry>();
|
|
237
237
|
private readonly invalidated = new Set<string>();
|
|
238
|
+
/** Per-connector epoch preventing a pre-invalidation refresh from publishing. */
|
|
239
|
+
private readonly catalogGenerations = new Map<string, number>();
|
|
240
|
+
/** Serialize persisted catalog set/delete operations within this isolate. */
|
|
241
|
+
private readonly catalogMutations = new Map<string, Promise<void>>();
|
|
238
242
|
/** Deployment-wide observations — every call, whatever view made it. */
|
|
239
243
|
private readonly health = new HealthLog();
|
|
240
244
|
private readonly ttlMs: number;
|
|
@@ -459,6 +463,36 @@ export class Registry implements RegistryView {
|
|
|
459
463
|
});
|
|
460
464
|
}
|
|
461
465
|
|
|
466
|
+
private catalogGeneration(id: string): number {
|
|
467
|
+
return this.catalogGenerations.get(id) ?? 0;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
private advanceCatalogGeneration(id: string): void {
|
|
471
|
+
this.catalogGenerations.set(id, this.catalogGeneration(id) + 1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Keep this isolate's writes and invalidations ordered. Without the queue, an
|
|
476
|
+
* old refresh can finish its storage.set after a credential change deletes
|
|
477
|
+
* the catalog and resurrect the pre-change listing.
|
|
478
|
+
*/
|
|
479
|
+
private enqueueCatalogMutation(
|
|
480
|
+
id: string,
|
|
481
|
+
operation: () => Promise<void>,
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
const previous = this.catalogMutations.get(id) ?? Promise.resolve();
|
|
484
|
+
const next = previous.catch(() => {}).then(operation);
|
|
485
|
+
this.catalogMutations.set(id, next);
|
|
486
|
+
void next
|
|
487
|
+
.finally(() => {
|
|
488
|
+
if (this.catalogMutations.get(id) === next) {
|
|
489
|
+
this.catalogMutations.delete(id);
|
|
490
|
+
}
|
|
491
|
+
})
|
|
492
|
+
.catch(() => {});
|
|
493
|
+
return next;
|
|
494
|
+
}
|
|
495
|
+
|
|
462
496
|
/** Force a live listTools refresh and replace both catalog cache layers. */
|
|
463
497
|
async refreshTools(
|
|
464
498
|
id: string,
|
|
@@ -468,11 +502,16 @@ export class Registry implements RegistryView {
|
|
|
468
502
|
): Promise<ToolDef[]> {
|
|
469
503
|
const connector = this.connectors.get(id);
|
|
470
504
|
if (!connector) throw new Error(`Unknown connector "${id}"`);
|
|
505
|
+
const generation = this.catalogGeneration(id);
|
|
471
506
|
const tools = connector.staticTools
|
|
472
507
|
? connector.staticTools
|
|
473
508
|
: await connector.listTools(
|
|
474
509
|
this.contextFor(id, baseUrl, requestScope, callOptions),
|
|
475
510
|
);
|
|
511
|
+
// The caller that began this refresh may still use its result, but a
|
|
512
|
+
// credential/OAuth change that landed while listTools was in flight means
|
|
513
|
+
// the listing must not enter either shared cache layer.
|
|
514
|
+
if (generation !== this.catalogGeneration(id)) return tools;
|
|
476
515
|
const now = Date.now();
|
|
477
516
|
const previous = this.cache.get(id);
|
|
478
517
|
const catalogChanged =
|
|
@@ -487,13 +526,16 @@ export class Registry implements RegistryView {
|
|
|
487
526
|
});
|
|
488
527
|
this.invalidated.delete(id);
|
|
489
528
|
if (shouldPersist) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
529
|
+
await this.enqueueCatalogMutation(id, async () => {
|
|
530
|
+
if (generation !== this.catalogGeneration(id)) return;
|
|
531
|
+
try {
|
|
532
|
+
await this.storeCatalog(id, tools);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
this.opts.logger.warn(
|
|
535
|
+
`[connecta] connector "${id}" catalog persistence failed: ${msg(err)}`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
497
539
|
}
|
|
498
540
|
return tools;
|
|
499
541
|
}
|
|
@@ -510,11 +552,13 @@ export class Registry implements RegistryView {
|
|
|
510
552
|
if (connector.staticTools) return connector.staticTools;
|
|
511
553
|
|
|
512
554
|
const now = Date.now();
|
|
555
|
+
const requestGeneration = this.catalogGeneration(id);
|
|
513
556
|
const hit = this.cache.get(id);
|
|
514
557
|
if (hit && hit.exp > now) return hit.tools;
|
|
515
558
|
|
|
516
559
|
let stale = hit && hit.staleUntil > now ? hit.tools : undefined;
|
|
517
560
|
if (this.persistToolCatalog && !this.invalidated.has(id)) {
|
|
561
|
+
const generation = this.catalogGeneration(id);
|
|
518
562
|
let persisted: PersistedCatalog | null = null;
|
|
519
563
|
try {
|
|
520
564
|
persisted = this.validCatalog(
|
|
@@ -525,6 +569,10 @@ export class Registry implements RegistryView {
|
|
|
525
569
|
`[connecta] connector "${id}" catalog read failed: ${msg(err)}`,
|
|
526
570
|
);
|
|
527
571
|
}
|
|
572
|
+
if (generation !== this.catalogGeneration(id)) {
|
|
573
|
+
persisted = null;
|
|
574
|
+
stale = undefined;
|
|
575
|
+
}
|
|
528
576
|
if (persisted && persisted.staleUntil > now) {
|
|
529
577
|
this.cache.set(id, {
|
|
530
578
|
tools: persisted.tools,
|
|
@@ -539,7 +587,11 @@ export class Registry implements RegistryView {
|
|
|
539
587
|
try {
|
|
540
588
|
return await this.refreshTools(id, baseUrl, requestScope, callOptions);
|
|
541
589
|
} catch (err) {
|
|
542
|
-
if (
|
|
590
|
+
if (
|
|
591
|
+
stale &&
|
|
592
|
+
requestGeneration === this.catalogGeneration(id) &&
|
|
593
|
+
!this.invalidated.has(id)
|
|
594
|
+
) {
|
|
543
595
|
this.opts.logger.warn(
|
|
544
596
|
`[connecta] connector "${id}" catalog refresh failed; serving stale catalog: ${msg(err)}`,
|
|
545
597
|
);
|
|
@@ -675,29 +727,37 @@ export class Registry implements RegistryView {
|
|
|
675
727
|
|
|
676
728
|
/** Drop a connector's cached tool list (e.g. after auth completes). */
|
|
677
729
|
invalidate(id: string): void {
|
|
730
|
+
this.advanceCatalogGeneration(id);
|
|
678
731
|
this.cache.delete(id);
|
|
679
732
|
this.invalidated.add(id);
|
|
680
733
|
if (this.persistToolCatalog) {
|
|
681
|
-
void this.
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
)
|
|
734
|
+
void this.enqueueCatalogMutation(id, async () => {
|
|
735
|
+
try {
|
|
736
|
+
await this.opts.storage.delete(this.catalogKey(id));
|
|
737
|
+
} catch (err) {
|
|
738
|
+
this.opts.logger.warn(
|
|
739
|
+
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
740
|
+
);
|
|
741
|
+
}
|
|
685
742
|
});
|
|
686
743
|
}
|
|
687
744
|
}
|
|
688
745
|
|
|
689
746
|
/** Drop both in-memory and persisted tool catalogs. */
|
|
690
747
|
async invalidateStored(id: string): Promise<void> {
|
|
748
|
+
this.advanceCatalogGeneration(id);
|
|
691
749
|
this.cache.delete(id);
|
|
692
750
|
this.invalidated.add(id);
|
|
693
751
|
if (this.persistToolCatalog) {
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
752
|
+
await this.enqueueCatalogMutation(id, async () => {
|
|
753
|
+
try {
|
|
754
|
+
await this.opts.storage.delete(this.catalogKey(id));
|
|
755
|
+
} catch (err) {
|
|
756
|
+
this.opts.logger.warn(
|
|
757
|
+
`[connecta] connector "${id}" catalog invalidation failed: ${msg(err)}`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
701
761
|
}
|
|
702
762
|
}
|
|
703
763
|
}
|