@zackbart/connecta 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +6 -5
- package/CHANGELOG.md +387 -0
- package/README.md +32 -15
- package/SECURITY.md +6 -5
- package/bin/connecta.mjs +5 -1
- package/dist/activity.d.ts +10 -3
- package/dist/activity.js +10 -1
- package/dist/artifacts/connector.d.ts +55 -0
- package/dist/artifacts/connector.js +724 -0
- package/dist/artifacts/document.js +80 -0
- package/dist/artifacts/guide.js +140 -0
- package/dist/artifacts/html-scan.js +329 -0
- package/dist/artifacts/json.js +68 -0
- package/dist/artifacts/kv-store.d.ts +20 -0
- package/dist/artifacts/kv-store.js +140 -0
- package/dist/artifacts/markdown.js +478 -0
- package/dist/artifacts/module.d.ts +37 -0
- package/dist/artifacts/module.js +60 -0
- package/dist/artifacts/operations.d.ts +195 -0
- package/dist/artifacts/operations.js +944 -0
- package/dist/artifacts/refresh.d.ts +53 -0
- package/dist/artifacts/refresh.js +235 -0
- package/dist/artifacts/routes.js +217 -0
- package/dist/artifacts/types.d.ts +214 -0
- package/dist/artifacts/types.js +22 -0
- package/dist/artifacts/validate.d.ts +62 -0
- package/dist/artifacts/validate.js +653 -0
- package/dist/artifacts.d.ts +6 -0
- package/dist/artifacts.js +9 -0
- package/dist/auth/downstream-oauth.js +397 -48
- package/dist/auth/oauth-start-reset.js +20 -0
- package/dist/branding.d.ts +87 -0
- package/dist/branding.js +170 -1
- package/dist/catalog-service.js +39 -29
- package/dist/catalog.js +42 -8
- package/dist/connector-access.d.ts +18 -4
- package/dist/connector-access.js +65 -20
- package/dist/connector-caller.d.ts +8 -0
- package/dist/connector-caller.js +19 -0
- package/dist/connectors/remote-mcp.js +48 -9
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +28 -0
- package/dist/execute.js +431 -75
- package/dist/executor-admission.d.ts +11 -1
- package/dist/executor-admission.js +79 -14
- package/dist/executors/quickjs.js +4 -2
- package/dist/index.d.ts +62 -6
- package/dist/index.js +230 -6
- package/dist/invocation.js +56 -10
- package/dist/meta-tools.js +7 -2
- package/dist/module-contracts.d.ts +21 -1
- package/dist/oauth-callback-outcome.js +43 -18
- package/dist/operator-ui/generated.js +2 -2
- package/dist/operator-ui/model.d.ts +62 -5
- package/dist/operator-ui/view.js +372 -28
- package/dist/page-styles.js +7 -0
- package/dist/program-source.js +116 -0
- package/dist/registry.d.ts +42 -6
- package/dist/registry.js +164 -60
- package/dist/resumable.js +1291 -0
- package/dist/routes/activity.js +6 -69
- package/dist/routes/actor-labels.js +114 -0
- package/dist/routes/credentials.js +27 -5
- package/dist/routes/mcp.js +120 -59
- package/dist/routes/oauth-management.js +87 -20
- package/dist/routes/oauth.js +54 -87
- package/dist/routes/operator.js +1 -0
- package/dist/routes/shared.d.ts +23 -1
- package/dist/routes/shared.js +20 -2
- package/dist/routes/ui-data.js +2 -1
- package/dist/routes/ui.js +19 -4
- package/dist/run-journal.js +347 -0
- package/dist/runtime/shared-read.js +106 -0
- package/dist/server.js +30 -2
- package/dist/skills.js +30 -11
- package/dist/timeout.js +26 -0
- package/dist/tool-safety.d.ts +35 -0
- package/dist/tool-safety.js +31 -0
- package/dist/types.d.ts +23 -0
- package/dist/ui.d.ts +17 -5
- package/dist/ui.js +51 -68
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/documentation/architecture.md +214 -27
- package/documentation/auth.md +186 -2
- package/documentation/code-mode.md +236 -63
- package/documentation/meta-tools.md +55 -29
- package/ethos.md +12 -10
- package/examples/worker/AGENTS.md +6 -0
- package/examples/worker/README.md +174 -10
- package/examples/worker/src/d1-activity-row.ts +14 -2
- package/examples/worker/src/d1-activity.ts +4 -2
- package/examples/worker/src/index.ts +36 -1
- package/examples/worker/src/r2-artifact-blobs.ts +29 -0
- package/examples/worker/wrangler.jsonc +5 -1
- package/package.json +6 -2
- package/templates/node/AGENTS.md +9 -2
- package/templates/node/README.md +31 -3
- package/templates/node/package.json +1 -1
- package/templates/node/src/index.ts +27 -0
package/dist/connector-access.js
CHANGED
|
@@ -12,38 +12,69 @@ const hasControlCharacter = (value) => [...value].some((ch) => {
|
|
|
12
12
|
* Normalize a grant list. A bare connector id grants every tool on that
|
|
13
13
|
* connector; a `connector.tool` address grants one tool. Grants are additive,
|
|
14
14
|
* so a bare id beside addresses for the same connector means the whole
|
|
15
|
-
* connector.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
* connector. Identity resolution can opt into `{ tool, requireReadOnly: true }`
|
|
16
|
+
* for a fixed address that also needs a current explicit read-only annotation;
|
|
17
|
+
* pool declarations remain strings. Anything else throws, and the caller
|
|
18
|
+
* decides whether that is a construction failure or a 403: a grant that
|
|
19
|
+
* cannot be parsed must never fail open.
|
|
18
20
|
*/
|
|
19
|
-
export function parseConnectorAccess(value) {
|
|
21
|
+
export function parseConnectorAccess(value, options = {}) {
|
|
20
22
|
if (value === "all")
|
|
21
23
|
return { connectorIds: "all" };
|
|
22
24
|
if (!Array.isArray(value))
|
|
23
25
|
throw new Error("invalid connector permission");
|
|
24
26
|
const whole = new Set();
|
|
25
|
-
const
|
|
27
|
+
const plain = new Map();
|
|
28
|
+
const guarded = new Map();
|
|
26
29
|
for (const entry of value) {
|
|
27
|
-
if (typeof entry
|
|
28
|
-
throw new Error("invalid connector permission");
|
|
29
|
-
if (CONNECTOR_ID_RE.test(entry)) {
|
|
30
|
+
if (typeof entry === "string" && CONNECTOR_ID_RE.test(entry)) {
|
|
30
31
|
whole.add(entry);
|
|
31
32
|
continue;
|
|
32
33
|
}
|
|
33
|
-
|
|
34
|
+
const isGuarded = typeof entry !== "string";
|
|
35
|
+
if (isGuarded && (!options.allowReadOnly || entry === null ||
|
|
36
|
+
typeof entry !== "object" || Array.isArray(entry) ||
|
|
37
|
+
!Object.hasOwn(entry, "tool") || !Object.hasOwn(entry, "requireReadOnly") ||
|
|
38
|
+
Object.keys(entry).length !== 2 || entry.requireReadOnly !== true)) {
|
|
39
|
+
throw new Error("invalid connector permission");
|
|
40
|
+
}
|
|
41
|
+
const address = isGuarded ? entry.tool : entry;
|
|
42
|
+
if (typeof address !== "string" || !TOOL_ADDRESS_RE.test(address) || hasControlCharacter(address)) {
|
|
34
43
|
throw new Error("invalid connector permission");
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
}
|
|
45
|
+
const dot = address.indexOf(".");
|
|
46
|
+
const connectorId = address.slice(0, dot);
|
|
47
|
+
const target = isGuarded ? guarded : plain;
|
|
48
|
+
const tools = target.get(connectorId) ?? new Set();
|
|
49
|
+
tools.add(address.slice(dot + 1));
|
|
50
|
+
target.set(connectorId, tools);
|
|
51
|
+
}
|
|
52
|
+
for (const id of whole) {
|
|
53
|
+
plain.delete(id);
|
|
54
|
+
guarded.delete(id);
|
|
55
|
+
}
|
|
56
|
+
for (const [id, names] of plain) {
|
|
57
|
+
const restricted = guarded.get(id);
|
|
58
|
+
if (!restricted)
|
|
59
|
+
continue;
|
|
60
|
+
for (const name of names)
|
|
61
|
+
restricted.delete(name);
|
|
62
|
+
if (restricted.size === 0)
|
|
63
|
+
guarded.delete(id);
|
|
64
|
+
}
|
|
65
|
+
const partial = new Map(plain);
|
|
66
|
+
for (const [id, names] of guarded) {
|
|
67
|
+
const combined = partial.get(id) ?? new Set();
|
|
68
|
+
for (const name of names)
|
|
69
|
+
combined.add(name);
|
|
70
|
+
partial.set(id, combined);
|
|
40
71
|
}
|
|
41
|
-
for (const id of whole)
|
|
42
|
-
partial.delete(id);
|
|
43
72
|
const connectorIds = [...new Set([...whole, ...partial.keys()])];
|
|
44
|
-
return
|
|
45
|
-
|
|
46
|
-
|
|
73
|
+
return {
|
|
74
|
+
connectorIds,
|
|
75
|
+
...(partial.size > 0 ? { toolAccess: partial } : {}),
|
|
76
|
+
...(guarded.size > 0 ? { guardedToolAccess: guarded } : {}),
|
|
77
|
+
};
|
|
47
78
|
}
|
|
48
79
|
/**
|
|
49
80
|
* The view a pool endpoint serves: the pool's grants, never wider than the
|
|
@@ -59,6 +90,7 @@ export function intersectAccess(ceiling, pool) {
|
|
|
59
90
|
: new Set(ceiling.connectorIds);
|
|
60
91
|
const connectorIds = [];
|
|
61
92
|
const toolAccess = new Map();
|
|
93
|
+
const guardedToolAccess = new Map();
|
|
62
94
|
for (const id of pool.connectorIds) {
|
|
63
95
|
if (allowedIds && !allowedIds.has(id))
|
|
64
96
|
continue;
|
|
@@ -73,7 +105,20 @@ export function intersectAccess(ceiling, pool) {
|
|
|
73
105
|
else if (fromPool ?? fromCeiling) {
|
|
74
106
|
toolAccess.set(id, (fromPool ?? fromCeiling));
|
|
75
107
|
}
|
|
108
|
+
const guarded = ceiling.guardedToolAccess?.get(id);
|
|
109
|
+
if (guarded) {
|
|
110
|
+
const allowed = toolAccess.get(id);
|
|
111
|
+
const retained = allowed
|
|
112
|
+
? new Set([...guarded].filter((name) => allowed.has(name)))
|
|
113
|
+
: guarded;
|
|
114
|
+
if (retained.size > 0)
|
|
115
|
+
guardedToolAccess.set(id, retained);
|
|
116
|
+
}
|
|
76
117
|
connectorIds.push(id);
|
|
77
118
|
}
|
|
78
|
-
return
|
|
119
|
+
return {
|
|
120
|
+
connectorIds,
|
|
121
|
+
...(toolAccess.size > 0 ? { toolAccess } : {}),
|
|
122
|
+
...(guardedToolAccess.size > 0 ? { guardedToolAccess } : {}),
|
|
123
|
+
};
|
|
79
124
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AuthenticatedIdentity, ConnectorContext } from "./types.js";
|
|
2
|
+
export interface ConnectorCaller {
|
|
3
|
+
identity: AuthenticatedIdentity;
|
|
4
|
+
/** The `/mcp/<pool>` the call arrived on, when it was a pool. */
|
|
5
|
+
pool?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function attachCaller(ctx: ConnectorContext, caller: ConnectorCaller | undefined): ConnectorContext;
|
|
8
|
+
export declare function callerOf(ctx: ConnectorContext): ConnectorCaller | undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Who is calling, for the connectors connecta ships and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// `ConnectorContext` deliberately carries no identity: a deployment's own
|
|
4
|
+
// connector acts for the deployment, and one that could read its caller would
|
|
5
|
+
// invite per-user behavior the access rules never saw. The built-in artifacts
|
|
6
|
+
// connector is the exception — every version it writes records who wrote it —
|
|
7
|
+
// so the identity rides beside the context, the way the OAuth sealer does,
|
|
8
|
+
// where only in-repo code can read it. Core sets it from the authorization
|
|
9
|
+
// that admitted the request, on the scoped registry view that request
|
|
10
|
+
// receives; no argument, header, or program can name it.
|
|
11
|
+
const callers = new WeakMap();
|
|
12
|
+
export function attachCaller(ctx, caller) {
|
|
13
|
+
if (caller)
|
|
14
|
+
callers.set(ctx, caller);
|
|
15
|
+
return ctx;
|
|
16
|
+
}
|
|
17
|
+
export function callerOf(ctx) {
|
|
18
|
+
return callers.get(ctx);
|
|
19
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Client, ProtocolError, SdkHttpError, isInputRequiredResult, specTypeSchemas, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
|
|
2
2
|
import { Deferred, Duration, Effect, Exit, Scope } from "effect";
|
|
3
3
|
import { KvOAuthProvider, OAuthRefreshCoordinator, } from "../auth/downstream-oauth.js";
|
|
4
|
+
import { trackOAuthStartReset } from "../auth/oauth-start-reset.js";
|
|
4
5
|
import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
|
|
5
6
|
import { boundedEchoText, ConnectorCallError, msg, unavailableCallError, } from "../errors.js";
|
|
6
7
|
import { CONNECTA_VERSION } from "../version.js";
|
|
@@ -587,7 +588,20 @@ export function remoteMcp(id, opts) {
|
|
|
587
588
|
const newProvider = (ctx, state) => {
|
|
588
589
|
if (state?.provider)
|
|
589
590
|
return state.provider;
|
|
590
|
-
const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator, ctx.allowAuthorization === true, oauthSealerFor(ctx)
|
|
591
|
+
const provider = new KvOAuthProvider(id, ctx.storage, `${ctx.baseUrl}/oauth/callback/${id}`, refreshCoordinator, ctx.allowAuthorization === true, oauthSealerFor(ctx), ctx.signal, JSON.stringify({
|
|
592
|
+
url: new URL(opts.url).href,
|
|
593
|
+
redirectUri: `${ctx.baseUrl}/oauth/callback/${id}`,
|
|
594
|
+
clientMetadata: {
|
|
595
|
+
redirect_uris: [`${ctx.baseUrl}/oauth/callback/${id}`],
|
|
596
|
+
client_name: "connecta",
|
|
597
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
598
|
+
response_types: ["code"],
|
|
599
|
+
token_endpoint_auth_method: "none",
|
|
600
|
+
},
|
|
601
|
+
authScope: opts.authScope ?? "shared",
|
|
602
|
+
versionNegotiation: opts.versionNegotiation ?? "auto",
|
|
603
|
+
redirects: opts.redirects ?? "none",
|
|
604
|
+
}), (reset) => trackOAuthStartReset(ctx.requestScope ?? ctx, reset));
|
|
591
605
|
if (state)
|
|
592
606
|
state.provider = provider;
|
|
593
607
|
return provider;
|
|
@@ -775,6 +789,7 @@ export function remoteMcp(id, opts) {
|
|
|
775
789
|
// SDK v2 selects its validator by runtime export condition: AJV on
|
|
776
790
|
// Node and @cfworker/json-schema under workerd. The Workers-safe path
|
|
777
791
|
// no longer needs Connecta-specific wiring.
|
|
792
|
+
const handshakeAbort = new AbortController();
|
|
778
793
|
const c = new Client({ name: "connecta", version: CONNECTA_VERSION }, {
|
|
779
794
|
versionNegotiation: {
|
|
780
795
|
mode: opts.versionNegotiation ?? "auto",
|
|
@@ -783,6 +798,19 @@ export function remoteMcp(id, opts) {
|
|
|
783
798
|
// below as one structured, non-retryable connector failure.
|
|
784
799
|
inputRequired: { autoFulfill: false },
|
|
785
800
|
});
|
|
801
|
+
// @modelcontextprotocol/client 2.0.0 can attempt the legacy initialized
|
|
802
|
+
// notification after we close its transport. Its rejected Promise is
|
|
803
|
+
// unhandled under workerd even though connect() has a caller. This
|
|
804
|
+
// connection is already abandoned, so skip only that final handshake
|
|
805
|
+
// notification after our abort. Remove when the SDK owns this race:
|
|
806
|
+
// https://github.com/modelcontextprotocol/typescript-sdk/issues/2864
|
|
807
|
+
const notify = c.notification.bind(c);
|
|
808
|
+
c.notification = (message, options) => {
|
|
809
|
+
if (handshakeAbort.signal.aborted && message.method === "notifications/initialized") {
|
|
810
|
+
return Promise.resolve();
|
|
811
|
+
}
|
|
812
|
+
return notify(message, options);
|
|
813
|
+
};
|
|
786
814
|
const t = buildTransport(ctx, provider, credentialFramed);
|
|
787
815
|
if (!owned()) {
|
|
788
816
|
detach(closeConnection(null, t, ctx.logger));
|
|
@@ -794,11 +822,11 @@ export function remoteMcp(id, opts) {
|
|
|
794
822
|
// what aborts a connect still in flight.
|
|
795
823
|
const lease = Scope.forkUnsafe(state.scope);
|
|
796
824
|
const held = { client: null };
|
|
797
|
-
yield* Scope.addFinalizer(lease, Effect.suspend(() => closeConnection(held.client, t, ctx.logger)));
|
|
825
|
+
yield* Scope.addFinalizer(lease, Effect.sync(() => handshakeAbort.abort()).pipe(Effect.andThen(Effect.suspend(() => closeConnection(held.client, t, ctx.logger)))));
|
|
798
826
|
state.lease = lease;
|
|
799
827
|
state.transport = t;
|
|
800
828
|
return yield* Effect.gen(function* () {
|
|
801
|
-
yield* promised(() => c.connect(t));
|
|
829
|
+
yield* promised(() => c.connect(t, { signal: handshakeAbort.signal }));
|
|
802
830
|
// A probe deadline can end its scope while connect is still in flight.
|
|
803
831
|
// The transport is closed immediately by closeScope; if connect wins
|
|
804
832
|
// that race anyway, close the resulting client rather than resurrecting
|
|
@@ -857,13 +885,14 @@ export function remoteMcp(id, opts) {
|
|
|
857
885
|
}));
|
|
858
886
|
});
|
|
859
887
|
};
|
|
860
|
-
const disconnectAuthorization = async (ctx, state, operatorDisconnected = false) => {
|
|
888
|
+
const disconnectAuthorization = async (ctx, state, operatorDisconnected = false, preserveClient = false) => {
|
|
861
889
|
const provider = newProvider(ctx, state);
|
|
862
890
|
// Publish the replacement epoch before waiting on or closing any
|
|
863
891
|
// request-local transport. A hung connect therefore cannot delay the
|
|
864
892
|
// fence, and every late OAuth write stays in the older namespace.
|
|
893
|
+
const reset = provider.resetAuthorization(operatorDisconnected, preserveClient);
|
|
865
894
|
try {
|
|
866
|
-
await
|
|
895
|
+
await reset;
|
|
867
896
|
}
|
|
868
897
|
finally {
|
|
869
898
|
// Abandon any connect in flight and close whichever half of the
|
|
@@ -1181,22 +1210,32 @@ export function remoteMcp(id, opts) {
|
|
|
1181
1210
|
const state = stateFor(ctx);
|
|
1182
1211
|
state.provider = null;
|
|
1183
1212
|
const p = newProvider(ctx, state);
|
|
1184
|
-
if (
|
|
1185
|
-
|
|
1213
|
+
if (ctx.signal?.aborted)
|
|
1214
|
+
throw ctx.signal.reason;
|
|
1215
|
+
const disconnected = startOpts?.force ? false : await p.operatorDisconnected();
|
|
1216
|
+
if (ctx.signal?.aborted)
|
|
1217
|
+
throw ctx.signal.reason;
|
|
1218
|
+
if (startOpts?.force || disconnected) {
|
|
1219
|
+
await disconnectAuthorization(ctx, state, false, startOpts?.force === true);
|
|
1186
1220
|
}
|
|
1187
1221
|
else {
|
|
1188
1222
|
// A consent URL already outstanding? Re-issue it rather than re-running
|
|
1189
1223
|
// the SDK flow, which would overwrite the PKCE verifier and invalidate
|
|
1190
|
-
// the URL the operator may be mid-consent on.
|
|
1191
|
-
|
|
1224
|
+
// the URL the operator may be mid-consent on. Only a recent one: an
|
|
1225
|
+
// old or untimed URL falls through to a fresh flow in this same
|
|
1226
|
+
// epoch, which keeps the stored client registration and discovery.
|
|
1227
|
+
const pending = await p.reusablePendingAuthorizationUrl();
|
|
1192
1228
|
if (pending) {
|
|
1193
1229
|
return {
|
|
1194
1230
|
state: "auth_required",
|
|
1195
1231
|
authorizationUrl: pending,
|
|
1232
|
+
authorizationReused: true,
|
|
1196
1233
|
message: "Authorization required — open the URL to connect.",
|
|
1197
1234
|
};
|
|
1198
1235
|
}
|
|
1199
1236
|
}
|
|
1237
|
+
if (ctx.signal?.aborted)
|
|
1238
|
+
throw ctx.signal.reason;
|
|
1200
1239
|
try {
|
|
1201
1240
|
await ensureConnected(ctx, state);
|
|
1202
1241
|
return {
|
package/dist/errors.d.ts
CHANGED
|
@@ -15,7 +15,14 @@ export type ConnectorCallErrorCode = "timeout" | "auth_required" | "rate_limited
|
|
|
15
15
|
* code stays `connector_call_failed` or `auth_required` with a message that
|
|
16
16
|
* states the ambiguity (H11).
|
|
17
17
|
*/
|
|
18
|
-
| "not_found"
|
|
18
|
+
| "not_found"
|
|
19
|
+
/**
|
|
20
|
+
* The write named a base version or revision that is no longer current:
|
|
21
|
+
* someone else changed the thing first. The caller re-reads, reapplies its
|
|
22
|
+
* change, and retries with the base `current` reports — repeating the same
|
|
23
|
+
* call can never succeed, and waiting will not help either.
|
|
24
|
+
*/
|
|
25
|
+
| "conflict" | "input_required_unsupported" | "connector_call_failed";
|
|
19
26
|
/** One bounded, payload-free explanation of an input-schema mismatch. */
|
|
20
27
|
export interface ArgumentValidationIssue {
|
|
21
28
|
/** JSON Pointer into the submitted arguments; "/" means the root value. */
|
|
@@ -88,12 +95,18 @@ export declare class ConnectorCallError extends Error {
|
|
|
88
95
|
readonly validation: ArgumentValidationDetails | undefined;
|
|
89
96
|
/** Sanitized transport diagnostics for `unavailable` only. */
|
|
90
97
|
readonly details: UnavailableDetails | undefined;
|
|
98
|
+
/**
|
|
99
|
+
* For `conflict` only: where each thing the write touched stands now, e.g.
|
|
100
|
+
* `{ revision: 12, view: 7 }`. Bounded to 20 whole-number entries.
|
|
101
|
+
*/
|
|
102
|
+
readonly current: Readonly<Record<string, number>> | undefined;
|
|
91
103
|
constructor(code: ConnectorCallErrorCode, message: string, opts?: {
|
|
92
104
|
retryable?: boolean;
|
|
93
105
|
retryAfterMs?: number;
|
|
94
106
|
cause?: unknown;
|
|
95
107
|
validation?: ArgumentValidationDetails;
|
|
96
108
|
details?: UnavailableDetails;
|
|
109
|
+
current?: Readonly<Record<string, number>>;
|
|
97
110
|
});
|
|
98
111
|
}
|
|
99
112
|
/** The `error` object surfaced in value-mode call results and rejected promises. */
|
|
@@ -107,6 +120,8 @@ export interface CallErrorDetails {
|
|
|
107
120
|
retryAfterMs?: number;
|
|
108
121
|
/** Bounded input-schema findings; paths and expectations, never values. */
|
|
109
122
|
validation?: ArgumentValidationDetails;
|
|
123
|
+
/** On a `conflict`: where each thing the write touched stands now. */
|
|
124
|
+
current?: Readonly<Record<string, number>>;
|
|
110
125
|
/** Connector whose failed operation needs recovery. */
|
|
111
126
|
connector?: string;
|
|
112
127
|
/** Canonical downstream address the agent may retry after recovery. */
|
package/dist/errors.js
CHANGED
|
@@ -162,9 +162,30 @@ const RETRYABLE_BY_CODE = {
|
|
|
162
162
|
auth_required: false,
|
|
163
163
|
invalid_args: false,
|
|
164
164
|
not_found: false,
|
|
165
|
+
conflict: false,
|
|
165
166
|
input_required_unsupported: false,
|
|
166
167
|
connector_call_failed: false,
|
|
167
168
|
};
|
|
169
|
+
/** Entries a conflict's `current` map may carry. */
|
|
170
|
+
const MAX_CURRENT_ENTRIES = 20;
|
|
171
|
+
const MAX_CURRENT_KEY_CHARS = 128;
|
|
172
|
+
/**
|
|
173
|
+
* Where each thing a conflicting write touched stands now: short names to
|
|
174
|
+
* whole non-negative numbers, at most 20 of them. Anything else is dropped
|
|
175
|
+
* whole rather than clipped, because a truncated key names a different thing.
|
|
176
|
+
*/
|
|
177
|
+
function boundedCurrent(current) {
|
|
178
|
+
if (!current || typeof current !== "object")
|
|
179
|
+
return undefined;
|
|
180
|
+
const entries = Object.entries(current).filter(([key, value]) => key.length > 0 &&
|
|
181
|
+
key.length <= MAX_CURRENT_KEY_CHARS &&
|
|
182
|
+
key !== "__proto__" &&
|
|
183
|
+
Number.isSafeInteger(value) &&
|
|
184
|
+
value >= 0);
|
|
185
|
+
if (entries.length === 0)
|
|
186
|
+
return undefined;
|
|
187
|
+
return Object.freeze(Object.fromEntries(entries.slice(0, MAX_CURRENT_ENTRIES)));
|
|
188
|
+
}
|
|
168
189
|
/** Non-negative integer milliseconds, or undefined for anything else. */
|
|
169
190
|
function normalizeRetryAfterMs(value) {
|
|
170
191
|
if (value === undefined)
|
|
@@ -201,6 +222,11 @@ export class ConnectorCallError extends Error {
|
|
|
201
222
|
validation;
|
|
202
223
|
/** Sanitized transport diagnostics for `unavailable` only. */
|
|
203
224
|
details;
|
|
225
|
+
/**
|
|
226
|
+
* For `conflict` only: where each thing the write touched stands now, e.g.
|
|
227
|
+
* `{ revision: 12, view: 7 }`. Bounded to 20 whole-number entries.
|
|
228
|
+
*/
|
|
229
|
+
current;
|
|
204
230
|
constructor(code, message, opts = {}) {
|
|
205
231
|
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
206
232
|
this.name = "ConnectorCallError";
|
|
@@ -211,6 +237,7 @@ export class ConnectorCallError extends Error {
|
|
|
211
237
|
code === "unavailable" ? sanitizedUnavailableDetails(opts.details) : undefined;
|
|
212
238
|
this.validation =
|
|
213
239
|
code === "invalid_args" ? boundedValidation(opts.validation) : undefined;
|
|
240
|
+
this.current = code === "conflict" ? boundedCurrent(opts.current) : undefined;
|
|
214
241
|
}
|
|
215
242
|
}
|
|
216
243
|
/**
|
|
@@ -262,6 +289,7 @@ export function classifyCallError(err, fallbackCode = "connector_call_failed") {
|
|
|
262
289
|
: {}),
|
|
263
290
|
...(err.validation ? { validation: err.validation } : {}),
|
|
264
291
|
...(err.details ? { details: err.details } : {}),
|
|
292
|
+
...(err.current ? { current: err.current } : {}),
|
|
265
293
|
};
|
|
266
294
|
}
|
|
267
295
|
// An aborted fetch rejects with a DOMException named "AbortError" whose
|