@stigmer/server 3.38.0 → 3.38.2
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 +1 -1
- package/dist/boot/compose.js +1 -1
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/config.d.ts +18 -7
- package/dist/boot/config.d.ts.map +1 -1
- package/dist/boot/config.js +1 -0
- package/dist/boot/config.js.map +1 -1
- package/dist/domain/mcpserver/complete-oauth-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/complete-oauth-connect.js +10 -4
- package/dist/domain/mcpserver/complete-oauth-connect.js.map +1 -1
- package/dist/domain/mcpserver/connect.d.ts +2 -1
- package/dist/domain/mcpserver/connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/connect.js +5 -2
- package/dist/domain/mcpserver/connect.js.map +1 -1
- package/dist/domain/mcpserver/controller.d.ts.map +1 -1
- package/dist/domain/mcpserver/controller.js +7 -3
- package/dist/domain/mcpserver/controller.js.map +1 -1
- package/dist/domain/mcpserver/get-oauth-grant-status.js +1 -1
- package/dist/domain/mcpserver/initiate-oauth-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/initiate-oauth-connect.js +8 -4
- package/dist/domain/mcpserver/initiate-oauth-connect.js.map +1 -1
- package/dist/domain/mcpserver/start-connect.d.ts +2 -2
- package/dist/domain/mcpserver/start-connect.d.ts.map +1 -1
- package/dist/domain/mcpserver/start-connect.js +10 -4
- package/dist/domain/mcpserver/start-connect.js.map +1 -1
- package/dist/domain/platform/controller.d.ts +7 -5
- package/dist/domain/platform/controller.d.ts.map +1 -1
- package/dist/domain/platform/controller.js +4 -2
- package/dist/domain/platform/controller.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/pipeline/errors.d.ts +12 -13
- package/dist/pipeline/errors.d.ts.map +1 -1
- package/dist/pipeline/errors.js +29 -3
- package/dist/pipeline/errors.js.map +1 -1
- package/package.json +6 -6
- package/src/boot/__tests__/config.test.ts +31 -0
- package/src/boot/compose.ts +1 -1
- package/src/boot/config.ts +23 -7
- package/src/domain/agentexecution/__tests__/store-faults.test.ts +6 -26
- package/src/domain/mcpserver/__tests__/store-faults.test.ts +333 -0
- package/src/domain/mcpserver/complete-oauth-connect.ts +13 -4
- package/src/domain/mcpserver/connect.ts +7 -3
- package/src/domain/mcpserver/controller.ts +7 -3
- package/src/domain/mcpserver/get-oauth-grant-status.ts +1 -1
- package/src/domain/mcpserver/initiate-oauth-connect.ts +8 -4
- package/src/domain/mcpserver/start-connect.ts +12 -6
- package/src/domain/platform/__tests__/platform.test.ts +55 -3
- package/src/domain/platform/controller.ts +11 -7
- package/src/domain/workflowexecution/__tests__/store-faults.test.ts +6 -26
- package/src/index.ts +2 -0
- package/src/pipeline/__tests__/blind-not-found.test.ts +0 -5
- package/src/pipeline/__tests__/errors.test.ts +72 -0
- package/src/pipeline/__tests__/support.ts +48 -5
- package/src/pipeline/errors.ts +42 -3
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* executioncontext decrypt lane uses, bound to exactly the named
|
|
15
15
|
* execution id;
|
|
16
16
|
* - getRunnerBootstrapConfig echoes the configured Temporal coordinates
|
|
17
|
-
* with the token fields empty (minting a proxy token is cloud-only)
|
|
17
|
+
* with the token fields empty (minting a proxy token is cloud-only),
|
|
18
|
+
* and publishes the runner-bootstrap address rather than the one the
|
|
19
|
+
* server dials when the two differ (stigmer#1357);
|
|
18
20
|
* - getServerInfo reports the build stamp by default and the release a
|
|
19
21
|
* library consumer states through ComposeOptions.version otherwise
|
|
20
22
|
* (stigmer#1168: unbundled, the stamp is always "dev"), and a
|
|
@@ -278,11 +280,61 @@ describe("platform domain (composed with a stated version)", () => {
|
|
|
278
280
|
});
|
|
279
281
|
});
|
|
280
282
|
|
|
283
|
+
/**
|
|
284
|
+
* stigmer#1357 through the real stack: a server that dials Temporal by a
|
|
285
|
+
* name its runners cannot resolve publishes the address they can, from
|
|
286
|
+
* the environment through loadConfig and the composition root to the
|
|
287
|
+
* wire. The composed-server arm above is the default: with no override,
|
|
288
|
+
* the published address is the server's own.
|
|
289
|
+
*/
|
|
290
|
+
describe("platform domain (composed for runners off the server's network)", () => {
|
|
291
|
+
let server: ComposedServer;
|
|
292
|
+
let client: PlatformClient;
|
|
293
|
+
let dir: string;
|
|
294
|
+
|
|
295
|
+
beforeAll(async () => {
|
|
296
|
+
dir = mkdtempSync(
|
|
297
|
+
path.join(tmpdir(), "platform-domain-runner-address-test-"),
|
|
298
|
+
);
|
|
299
|
+
vi.stubEnv("STIGMER_ENCRYPTION_KEY", ENC_KEY.toString("base64"));
|
|
300
|
+
server = await composeServer({
|
|
301
|
+
config: loadConfig({
|
|
302
|
+
STIGMER_MODEL_REGISTRY_REFRESH: "off",
|
|
303
|
+
DB_PATH: path.join(dir, "stigmer.db"),
|
|
304
|
+
ARTIFACT_LOCAL_BASE_PATH: path.join(dir, "artifacts"),
|
|
305
|
+
TEMPORAL_HOST_PORT: "127.0.0.1:7777",
|
|
306
|
+
TEMPORAL_NAMESPACE: "conformance-ns",
|
|
307
|
+
STIGMER_RUNNER_BOOTSTRAP_TEMPORAL_ADDRESS: "temporal.example.test:7233",
|
|
308
|
+
}),
|
|
309
|
+
logger: silentLogger,
|
|
310
|
+
portOverride: 0,
|
|
311
|
+
host: "127.0.0.1",
|
|
312
|
+
});
|
|
313
|
+
const port = await server.start();
|
|
314
|
+
client = createClient(
|
|
315
|
+
PlatformQueryController,
|
|
316
|
+
createGrpcTransport({ baseUrl: `http://127.0.0.1:${port}` }),
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
afterAll(async () => {
|
|
321
|
+
await server.shutdown();
|
|
322
|
+
rmSync(dir, { recursive: true, force: true });
|
|
323
|
+
vi.unstubAllEnvs();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("getRunnerBootstrapConfig publishes the runner-bootstrap address, with the server's namespace", async () => {
|
|
327
|
+
const config = await client.getRunnerBootstrapConfig({});
|
|
328
|
+
expect(config.temporalAddress).toBe("temporal.example.test:7233");
|
|
329
|
+
expect(config.temporalNamespace).toBe("conformance-ns");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
281
333
|
describe("platform domain (keyless runner-token service)", () => {
|
|
282
334
|
it("a keyless service answers not-minted — fail-soft, never an error", async () => {
|
|
283
335
|
const transport = createRouterTransport((router) => {
|
|
284
336
|
registerPlatformServices(router, {
|
|
285
|
-
|
|
337
|
+
runnerBootstrapTemporalAddress: "localhost:7233",
|
|
286
338
|
temporalNamespace: "default",
|
|
287
339
|
runnerAuthService: newExecutionScopedRunnerCredentialProvider(
|
|
288
340
|
RunnerAuthService.create(undefined),
|
|
@@ -318,7 +370,7 @@ describe("platform domain (license status)", () => {
|
|
|
318
370
|
function clientWith(provider: LicenseStatusProvider) {
|
|
319
371
|
const transport = createRouterTransport((router) => {
|
|
320
372
|
registerPlatformServices(router, {
|
|
321
|
-
|
|
373
|
+
runnerBootstrapTemporalAddress: "localhost:7233",
|
|
322
374
|
temporalNamespace: "default",
|
|
323
375
|
runnerAuthService: newExecutionScopedRunnerCredentialProvider(
|
|
324
376
|
RunnerAuthService.create(undefined),
|
|
@@ -78,12 +78,14 @@ import { TOKEN_TYPE_EXECUTION_SCOPED } from "../../runnerauth/runnerauth.js";
|
|
|
78
78
|
|
|
79
79
|
export interface PlatformControllerDeps {
|
|
80
80
|
/**
|
|
81
|
-
* Temporal coordinates
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
81
|
+
* The Temporal coordinates published to embedded runners. The address
|
|
82
|
+
* is the one a runner should dial, which is not always the one this
|
|
83
|
+
* server dials: a clustered server reaches Temporal by an internal name
|
|
84
|
+
* its desktop runners cannot resolve (stigmer#1357). The composition
|
|
85
|
+
* root passes config.runnerBootstrapTemporalAddress, which defaults to
|
|
86
|
+
* the server's own address; the namespace is the server's.
|
|
85
87
|
*/
|
|
86
|
-
readonly
|
|
88
|
+
readonly runnerBootstrapTemporalAddress: string;
|
|
87
89
|
readonly temporalNamespace: string;
|
|
88
90
|
/**
|
|
89
91
|
* Mints the execution-scoped tokens getRunnerScopedToken hands to
|
|
@@ -202,7 +204,9 @@ async function getLicenseStatus(
|
|
|
202
204
|
|
|
203
205
|
/**
|
|
204
206
|
* Go GetRunnerBootstrapConfig: the Temporal coordinates an embedded runner
|
|
205
|
-
* should connect to so it can self-bootstrap from a token alone
|
|
207
|
+
* should connect to so it can self-bootstrap from a token alone: the
|
|
208
|
+
* address deps name for runners, never assumed to be the server's own
|
|
209
|
+
* (the proto's "the one reachable by the caller"). The
|
|
206
210
|
* RunnerAccessToken / TokenType / RunnerAccessTokenExpiresInSeconds fields
|
|
207
211
|
* are intentionally left empty in OSS: minting an iss=stigmer proxy token
|
|
208
212
|
* is a cloud-only capability (OSS has no Cursor BiDi proxy to authenticate
|
|
@@ -221,7 +225,7 @@ async function getRunnerBootstrapConfig(
|
|
|
221
225
|
ctx: HandlerContext,
|
|
222
226
|
): Promise<GetRunnerBootstrapConfigOutput> {
|
|
223
227
|
const output = create(GetRunnerBootstrapConfigOutputSchema, {
|
|
224
|
-
temporalAddress: deps.
|
|
228
|
+
temporalAddress: deps.runnerBootstrapTemporalAddress,
|
|
225
229
|
temporalNamespace: deps.temporalNamespace,
|
|
226
230
|
});
|
|
227
231
|
const bootstrapCredentials =
|
|
@@ -45,7 +45,12 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
|
|
|
45
45
|
|
|
46
46
|
import { createLogger } from "../../../boot/logger.js";
|
|
47
47
|
import { callerIdentityKey } from "../../../pipeline/interceptors/auth.js";
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
errorOf,
|
|
50
|
+
failingStore,
|
|
51
|
+
testCallerIdentity,
|
|
52
|
+
untouchable,
|
|
53
|
+
} from "../../../pipeline/__tests__/support.js";
|
|
49
54
|
import { RequestContext } from "../../../pipeline/request-context.js";
|
|
50
55
|
import { newPermissiveSingleTeamAuthorizer } from "../../../pipeline/steps/authorize.js";
|
|
51
56
|
import { ResourceNotFoundError } from "../../../store/interface.js";
|
|
@@ -80,35 +85,10 @@ const WORKFLOW_ID = "wfl_storefault";
|
|
|
80
85
|
const EXECUTION_FAULT_COPY = "failed to load workflow execution";
|
|
81
86
|
const WORKFLOW_FAULT_COPY = "failed to load workflow";
|
|
82
87
|
|
|
83
|
-
/** A dependency the call must never reach once its load has failed. */
|
|
84
|
-
function untouchable<T extends object>(name: string): T {
|
|
85
|
-
return new Proxy({} as T, {
|
|
86
|
-
get(_target, prop) {
|
|
87
|
-
throw new Error(`${name}.${String(prop)} reached after a failed load`);
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A store whose every read fails with the given error. */
|
|
93
|
-
function failingStore(error: Error): Store {
|
|
94
|
-
return {
|
|
95
|
-
getResource: () => Promise.reject(error),
|
|
96
|
-
} as unknown as Store;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
88
|
const MISSING = (): Error =>
|
|
100
89
|
new ResourceNotFoundError(`workflow_execution/${EXECUTION_ID}`);
|
|
101
90
|
const LOCKED = (): Error => new Error("SQLITE_BUSY: database is locked");
|
|
102
91
|
|
|
103
|
-
async function errorOf(call: () => Promise<unknown>): Promise<ConnectError> {
|
|
104
|
-
const error = await call().then(
|
|
105
|
-
() => undefined,
|
|
106
|
-
(e: unknown) => e,
|
|
107
|
-
);
|
|
108
|
-
expect(error, "the call must fail").toBeInstanceOf(ConnectError);
|
|
109
|
-
return error as ConnectError;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
92
|
function handlerContext(): HandlerContext {
|
|
113
93
|
const values = createContextValues();
|
|
114
94
|
values.set(callerIdentityKey, testCallerIdentity());
|
package/src/index.ts
CHANGED
|
@@ -293,12 +293,14 @@ export { RequestContext } from "./pipeline/request-context.js";
|
|
|
293
293
|
export {
|
|
294
294
|
abortedError,
|
|
295
295
|
alreadyExistsError,
|
|
296
|
+
ERROR_REASON_DOMAIN,
|
|
296
297
|
failedPreconditionError,
|
|
297
298
|
internalError,
|
|
298
299
|
invalidArgumentError,
|
|
299
300
|
notFoundError,
|
|
300
301
|
unavailableError,
|
|
301
302
|
} from "./pipeline/errors.js";
|
|
303
|
+
export type { RefusalReason } from "./pipeline/errors.js";
|
|
302
304
|
// The shared slug derivation (C2 Stage 3): extension-registered resource
|
|
303
305
|
// kinds derive slugs with the SAME generator both editions pin
|
|
304
306
|
// (ApiRequestResourceSlugGenerator parity) — the semantics live exactly
|
|
@@ -44,11 +44,6 @@ const PENDING: ReadonlyMap<string, number> = new Map([
|
|
|
44
44
|
["domain/artifact/controller.ts", 1],
|
|
45
45
|
["domain/environment/controller.ts", 1],
|
|
46
46
|
["domain/environment/steps.ts", 1],
|
|
47
|
-
["domain/mcpserver/complete-oauth-connect.ts", 1],
|
|
48
|
-
["domain/mcpserver/connect.ts", 1],
|
|
49
|
-
["domain/mcpserver/controller.ts", 1],
|
|
50
|
-
["domain/mcpserver/initiate-oauth-connect.ts", 1],
|
|
51
|
-
["domain/mcpserver/start-connect.ts", 2],
|
|
52
47
|
["domain/plugin/controller.ts", 1],
|
|
53
48
|
["domain/skill/controller.ts", 1],
|
|
54
49
|
["domain/workflow/controller.ts", 1],
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The structured-refusal seam on failedPreconditionError: a reason rides
|
|
3
|
+
* as a google.rpc.ErrorInfo detail beside the byte-pinned message, and
|
|
4
|
+
* reaches a client over a real Connect call. The SDK's getErrorReason
|
|
5
|
+
* reads the detail the same way the round trip below does, so a refusal
|
|
6
|
+
* this helper builds is one a console can branch on.
|
|
7
|
+
*/
|
|
8
|
+
import { create } from "@bufbuild/protobuf";
|
|
9
|
+
import {
|
|
10
|
+
Code,
|
|
11
|
+
ConnectError,
|
|
12
|
+
createClient,
|
|
13
|
+
createRouterTransport,
|
|
14
|
+
} from "@connectrpc/connect";
|
|
15
|
+
import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
|
|
16
|
+
import { Health, HealthCheckRequestSchema } from "@stigmer/protos/grpc/health/v1/health_pb";
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
|
|
19
|
+
import { ERROR_REASON_DOMAIN, failedPreconditionError } from "../errors.js";
|
|
20
|
+
|
|
21
|
+
describe("failedPreconditionError", () => {
|
|
22
|
+
it("carries no detail without a reason", () => {
|
|
23
|
+
const error = failedPreconditionError("Add a payment method first.");
|
|
24
|
+
|
|
25
|
+
expect(error.code).toBe(Code.FailedPrecondition);
|
|
26
|
+
expect(error.rawMessage).toBe("Add a payment method first.");
|
|
27
|
+
expect(error.findDetails(ErrorInfoSchema)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("attaches the reason as an ErrorInfo detail and leaves the message as written", () => {
|
|
31
|
+
const error = failedPreconditionError("Teams need the Team plan or above.", {
|
|
32
|
+
reason: "PLAN_UPGRADE_REQUIRED",
|
|
33
|
+
metadata: { feature: "teams", org_id: "org_1" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(error.rawMessage).toBe("Teams need the Team plan or above.");
|
|
37
|
+
const [info] = error.findDetails(ErrorInfoSchema);
|
|
38
|
+
expect(info?.reason).toBe("PLAN_UPGRADE_REQUIRED");
|
|
39
|
+
expect(info?.domain).toBe(ERROR_REASON_DOMAIN);
|
|
40
|
+
expect(info?.metadata).toEqual({ feature: "teams", org_id: "org_1" });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("delivers the detail to a client across a Connect call", async () => {
|
|
44
|
+
const transport = createRouterTransport((router) => {
|
|
45
|
+
router.service(Health, {
|
|
46
|
+
check: () => {
|
|
47
|
+
throw failedPreconditionError("A saved payment method is required.", {
|
|
48
|
+
reason: "PAYMENT_METHOD_REQUIRED",
|
|
49
|
+
metadata: { org_id: "org_2" },
|
|
50
|
+
});
|
|
51
|
+
},
|
|
52
|
+
list: () => ({ statuses: {} }),
|
|
53
|
+
watch: async function* () {},
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
const client = createClient(Health, transport);
|
|
57
|
+
|
|
58
|
+
const caught = await client.check(create(HealthCheckRequestSchema)).then(
|
|
59
|
+
() => undefined,
|
|
60
|
+
(error: unknown) => error,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(caught).toBeInstanceOf(ConnectError);
|
|
64
|
+
const error = caught as ConnectError;
|
|
65
|
+
expect(error.code).toBe(Code.FailedPrecondition);
|
|
66
|
+
expect(error.rawMessage).toBe("A saved payment method is required.");
|
|
67
|
+
const [info] = error.findDetails(ErrorInfoSchema);
|
|
68
|
+
expect(info?.reason).toBe("PAYMENT_METHOD_REQUIRED");
|
|
69
|
+
expect(info?.domain).toBe(ERROR_REASON_DOMAIN);
|
|
70
|
+
expect(info?.metadata).toEqual({ org_id: "org_2" });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared test
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Shared test fixtures for the pipeline and the domains.
|
|
3
|
+
*
|
|
4
|
+
* The caller identity for RequestContext construction: the identity
|
|
5
|
+
* parameter is required, so every test constructs one explicitly — never a
|
|
6
|
+
* hidden default that could mask a missed threading site in production
|
|
7
|
+
* code. Overrides let adversarial tests pin specific caller classes.
|
|
8
|
+
*
|
|
9
|
+
* The run-gate authorizer, which isolates the AuthorizeRunTarget splice from
|
|
10
|
+
* every other authorization site on a chain.
|
|
11
|
+
*
|
|
12
|
+
* The store-fault helpers the per-domain `store-faults` tests share: a store
|
|
13
|
+
* whose reads fail with a chosen error, a dependency that must never be
|
|
14
|
+
* reached once a load has failed, and the ConnectError a failing call
|
|
15
|
+
* rejects with. Together they pin the error contract's store-fault rule
|
|
16
|
+
* (`src/pipeline/errors.ts`): a typed not-found answers NotFound, any other
|
|
17
|
+
* store failure a sanitized Internal.
|
|
7
18
|
*/
|
|
19
|
+
import { ConnectError } from "@connectrpc/connect";
|
|
20
|
+
import { expect } from "vitest";
|
|
21
|
+
|
|
8
22
|
import type {
|
|
9
23
|
Authorizer,
|
|
10
24
|
AuthzCheck,
|
|
11
25
|
AuthzDecision,
|
|
12
26
|
} from "../../extensions/authorizer.js";
|
|
13
27
|
import type { CallerIdentity } from "../../extensions/identity.js";
|
|
28
|
+
import type { Store } from "../../store/interface.js";
|
|
14
29
|
import { isRunGateCheck } from "../steps/authorize-run-target.js";
|
|
15
30
|
|
|
16
31
|
export function testCallerIdentity(
|
|
@@ -52,3 +67,31 @@ export function runGateOnlyAuthorizer(decide: () => AuthzDecision): {
|
|
|
52
67
|
},
|
|
53
68
|
};
|
|
54
69
|
}
|
|
70
|
+
|
|
71
|
+
/** A dependency the call must never reach once its load has failed. */
|
|
72
|
+
export function untouchable<T extends object>(name: string): T {
|
|
73
|
+
return new Proxy({} as T, {
|
|
74
|
+
get(_target, prop) {
|
|
75
|
+
throw new Error(`${name}.${String(prop)} reached after a failed load`);
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A store whose every read fails with the given error. */
|
|
81
|
+
export function failingStore(error: Error): Store {
|
|
82
|
+
return {
|
|
83
|
+
getResource: () => Promise.reject(error),
|
|
84
|
+
} as unknown as Store;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The ConnectError `call` rejects with; fails the test if it resolves. */
|
|
88
|
+
export async function errorOf(
|
|
89
|
+
call: () => Promise<unknown>,
|
|
90
|
+
): Promise<ConnectError> {
|
|
91
|
+
const error = await call().then(
|
|
92
|
+
() => undefined,
|
|
93
|
+
(e: unknown) => e,
|
|
94
|
+
);
|
|
95
|
+
expect(error, "the call must fail").toBeInstanceOf(ConnectError);
|
|
96
|
+
return error as ConnectError;
|
|
97
|
+
}
|
package/src/pipeline/errors.ts
CHANGED
|
@@ -9,8 +9,31 @@
|
|
|
9
9
|
* text can carry storage-engine detail or filesystem paths, and on an
|
|
10
10
|
* anonymous surface that is information disclosure. The cause rides
|
|
11
11
|
* ConnectError.cause for server-side logs; it never crosses the wire.
|
|
12
|
+
*
|
|
13
|
+
* A refusal a client should branch on, rather than parse its copy, also
|
|
14
|
+
* carries a google.rpc.ErrorInfo detail (domain "stigmer.ai"), the
|
|
15
|
+
* platform's structured-refusal contract that the SDK's getErrorReason
|
|
16
|
+
* reads. Its reasons are documented on the refusing RPC's proto comment.
|
|
17
|
+
* The detail rides beside the message and never changes it, so the copy
|
|
18
|
+
* stays byte-pinned. A detail survives the pipeline and the transport
|
|
19
|
+
* boundary; only the visitor sanitizer, which rewrites an anonymous
|
|
20
|
+
* caller's error, drops it (interceptors/error-boundary.ts).
|
|
12
21
|
*/
|
|
22
|
+
import { create } from "@bufbuild/protobuf";
|
|
13
23
|
import { Code, ConnectError } from "@connectrpc/connect";
|
|
24
|
+
import { ErrorInfoSchema } from "@stigmer/protos/google/rpc/error_details_pb";
|
|
25
|
+
|
|
26
|
+
/** The domain every structured refusal reason is issued under. */
|
|
27
|
+
export const ERROR_REASON_DOMAIN = "stigmer.ai";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A machine-readable refusal reason: an UPPER_SNAKE code and the facts a
|
|
31
|
+
* client needs to act on it, both documented on the refusing RPC.
|
|
32
|
+
*/
|
|
33
|
+
export interface RefusalReason {
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
36
|
+
}
|
|
14
37
|
|
|
15
38
|
/** Go NotFoundError: "%s not found: %s". */
|
|
16
39
|
export function notFoundError(resource: string, id: string): ConnectError {
|
|
@@ -32,10 +55,26 @@ export function alreadyExistsError(resource: string, id: string): ConnectError {
|
|
|
32
55
|
|
|
33
56
|
/**
|
|
34
57
|
* Go FailedPreconditionError — the system is not in a state required for
|
|
35
|
-
* the operation (vs AlreadyExists, which tells the caller to stop).
|
|
58
|
+
* the operation (vs AlreadyExists, which tells the caller to stop). With a
|
|
59
|
+
* reason, the refusal also carries it as an ErrorInfo detail.
|
|
36
60
|
*/
|
|
37
|
-
export function failedPreconditionError(
|
|
38
|
-
|
|
61
|
+
export function failedPreconditionError(
|
|
62
|
+
message: string,
|
|
63
|
+
reason?: RefusalReason,
|
|
64
|
+
): ConnectError {
|
|
65
|
+
if (reason === undefined) {
|
|
66
|
+
return new ConnectError(message, Code.FailedPrecondition);
|
|
67
|
+
}
|
|
68
|
+
return new ConnectError(message, Code.FailedPrecondition, undefined, [
|
|
69
|
+
{
|
|
70
|
+
desc: ErrorInfoSchema,
|
|
71
|
+
value: create(ErrorInfoSchema, {
|
|
72
|
+
reason: reason.reason,
|
|
73
|
+
domain: ERROR_REASON_DOMAIN,
|
|
74
|
+
metadata: { ...reason.metadata },
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
]);
|
|
39
78
|
}
|
|
40
79
|
|
|
41
80
|
/** Go AbortedError — retryable conflict (e.g. an in-flight dedupe claim). */
|