@stigg/agent-sdk-js 0.0.0-stage → 0.2.0
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 +1411 -2
- package/dist/bindings/client/core/registry.d.ts +12 -0
- package/dist/bindings/client/core/registry.js +22 -0
- package/dist/bindings/client/core/types.d.ts +126 -0
- package/dist/bindings/client/core/types.js +13 -0
- package/dist/bindings/client/core/wrap-client.d.ts +6 -0
- package/dist/bindings/client/core/wrap-client.js +261 -0
- package/dist/bindings/client/gateways/bedrock/bedrock-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/bedrock/bedrock-recipe.js +134 -0
- package/dist/bindings/client/gateways/bedrock/types.d.ts +13 -0
- package/dist/bindings/client/gateways/bedrock/types.js +2 -0
- package/dist/bindings/client/gateways/cloudflare/cloudflare-recipe.d.ts +10 -0
- package/dist/bindings/client/gateways/cloudflare/cloudflare-recipe.js +254 -0
- package/dist/bindings/client/gateways/cloudflare/types.d.ts +35 -0
- package/dist/bindings/client/gateways/cloudflare/types.js +2 -0
- package/dist/bindings/client/gateways/databricks/databricks-recipe.d.ts +32 -0
- package/dist/bindings/client/gateways/databricks/databricks-recipe.js +324 -0
- package/dist/bindings/client/gateways/databricks/types.d.ts +30 -0
- package/dist/bindings/client/gateways/databricks/types.js +2 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.d.ts +17 -0
- package/dist/bindings/client/gateways/litellm/litellm-recipe.js +158 -0
- package/dist/bindings/client/gateways/litellm/types.d.ts +30 -0
- package/dist/bindings/client/gateways/litellm/types.js +2 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.d.ts +20 -0
- package/dist/bindings/client/gateways/openrouter/openrouter-recipe.js +233 -0
- package/dist/bindings/client/gateways/openrouter/sdk-usage.d.ts +37 -0
- package/dist/bindings/client/gateways/openrouter/sdk-usage.js +63 -0
- package/dist/bindings/client/gateways/openrouter/types.d.ts +78 -0
- package/dist/bindings/client/gateways/openrouter/types.js +2 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.d.ts +34 -0
- package/dist/bindings/client/providers/anthropic/anthropic-recipe.js +169 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.d.ts +54 -0
- package/dist/bindings/client/providers/gemini/gemini-recipe.js +243 -0
- package/dist/bindings/client/providers/groq/groq-recipe.d.ts +20 -0
- package/dist/bindings/client/providers/groq/groq-recipe.js +84 -0
- package/dist/bindings/client/providers/mistral/mistral-recipe.d.ts +22 -0
- package/dist/bindings/client/providers/mistral/mistral-recipe.js +114 -0
- package/dist/bindings/client/providers/mistral/types.d.ts +38 -0
- package/dist/bindings/client/providers/mistral/types.js +2 -0
- package/dist/bindings/client/providers/ollama/ollama-recipe.d.ts +31 -0
- package/dist/bindings/client/providers/ollama/ollama-recipe.js +107 -0
- package/dist/bindings/client/providers/ollama/types.d.ts +24 -0
- package/dist/bindings/client/providers/ollama/types.js +2 -0
- package/dist/bindings/client/providers/openai/openai-recipe.d.ts +71 -0
- package/dist/bindings/client/providers/openai/openai-recipe.js +122 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.d.ts +44 -0
- package/dist/bindings/client/providers/typesafe/typesafe-recipe.js +83 -0
- package/dist/bindings/proxy/headers.d.ts +6 -0
- package/dist/bindings/proxy/headers.js +12 -0
- package/dist/bindings/proxy/index.d.ts +21 -0
- package/dist/bindings/proxy/index.js +18 -0
- package/dist/bindings/proxy/types.d.ts +8 -0
- package/dist/bindings/proxy/types.js +2 -0
- package/dist/bindings/shared/base-url.d.ts +44 -0
- package/dist/bindings/shared/base-url.js +90 -0
- package/dist/bindings/shared/estimate.d.ts +27 -0
- package/dist/bindings/shared/estimate.js +38 -0
- package/dist/bindings/shared/meter-stream.d.ts +18 -0
- package/dist/bindings/shared/meter-stream.js +151 -0
- package/dist/bindings/shared/token-usage.d.ts +345 -0
- package/dist/bindings/shared/token-usage.js +735 -0
- package/dist/bindings/shared/types.d.ts +170 -0
- package/dist/bindings/shared/types.js +2 -0
- package/dist/bindings/shared/weighted-metrics.d.ts +37 -0
- package/dist/bindings/shared/weighted-metrics.js +47 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +99 -0
- package/dist/core/context.d.ts +160 -0
- package/dist/core/context.js +221 -0
- package/dist/core/dimensions.d.ts +22 -0
- package/dist/core/dimensions.js +47 -0
- package/dist/core/hooks.d.ts +21 -0
- package/dist/core/hooks.js +47 -0
- package/dist/core/session.d.ts +190 -0
- package/dist/core/session.js +448 -0
- package/dist/decision-text.d.ts +15 -0
- package/dist/decision-text.js +54 -0
- package/dist/engine/composite-engine.d.ts +72 -0
- package/dist/engine/composite-engine.js +135 -0
- package/dist/engine/create-engine.d.ts +16 -0
- package/dist/engine/create-engine.js +93 -0
- package/dist/engine/decision.d.ts +42 -0
- package/dist/engine/decision.js +135 -0
- package/dist/engine/engine.d.ts +166 -0
- package/dist/engine/engine.js +11 -0
- package/dist/engine/node-sdk-engine.d.ts +125 -0
- package/dist/engine/node-sdk-engine.js +262 -0
- package/dist/engine/report.d.ts +106 -0
- package/dist/engine/report.js +188 -0
- package/dist/engine/rest-governance-engine.d.ts +47 -0
- package/dist/engine/rest-governance-engine.js +220 -0
- package/dist/errors.d.ts +86 -0
- package/dist/errors.js +161 -0
- package/dist/headers.d.ts +40 -0
- package/dist/headers.js +82 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.js +124 -0
- package/dist/internal.d.ts +33 -0
- package/dist/internal.js +91 -0
- package/dist/logger.d.ts +15 -0
- package/dist/logger.js +20 -0
- package/dist/sdk-identity.d.ts +36 -0
- package/dist/sdk-identity.js +43 -0
- package/dist/stigg-agent-sdk.d.ts +300 -0
- package/dist/stigg-agent-sdk.js +669 -0
- package/dist/types.d.ts +676 -0
- package/dist/types.js +147 -0
- package/package.json +94 -3
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StiggAgentSDK = exports.DEFAULT_RECIPES = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The SDK's entry point, and the run lifecycle it exposes:
|
|
6
|
+
*
|
|
7
|
+
* register -> check -> execute -> ingest -> (deregister)
|
|
8
|
+
*
|
|
9
|
+
* `register` is one call because it is one idea - "this agent is about to do something" - but it
|
|
10
|
+
* is two operations underneath, and they have very different costs. Giving the agent an identity
|
|
11
|
+
* is a write, so it happens once per agent and is remembered; opening the run is not a write at
|
|
12
|
+
* all, so it happens every time and is what warms the budget. Calling `register` in a loop is
|
|
13
|
+
* therefore safe: the write does not repeat.
|
|
14
|
+
*
|
|
15
|
+
* This file is the composition root - the one place that knows both the core and the bindings.
|
|
16
|
+
* Nothing under `core/` or `engine/` imports a binding, and no binding imports another.
|
|
17
|
+
*/
|
|
18
|
+
const node_crypto_1 = require("node:crypto");
|
|
19
|
+
const config_1 = require("./config");
|
|
20
|
+
const create_engine_1 = require("./engine/create-engine");
|
|
21
|
+
const engine_1 = require("./engine/engine");
|
|
22
|
+
const errors_1 = require("./errors");
|
|
23
|
+
const headers_1 = require("./headers");
|
|
24
|
+
const dimensions_1 = require("./core/dimensions");
|
|
25
|
+
const anthropic_recipe_1 = require("./bindings/client/providers/anthropic/anthropic-recipe");
|
|
26
|
+
const gemini_recipe_1 = require("./bindings/client/providers/gemini/gemini-recipe");
|
|
27
|
+
const litellm_recipe_1 = require("./bindings/client/gateways/litellm/litellm-recipe");
|
|
28
|
+
const databricks_recipe_1 = require("./bindings/client/gateways/databricks/databricks-recipe");
|
|
29
|
+
const cloudflare_recipe_1 = require("./bindings/client/gateways/cloudflare/cloudflare-recipe");
|
|
30
|
+
const groq_recipe_1 = require("./bindings/client/providers/groq/groq-recipe");
|
|
31
|
+
const mistral_recipe_1 = require("./bindings/client/providers/mistral/mistral-recipe");
|
|
32
|
+
const ollama_recipe_1 = require("./bindings/client/providers/ollama/ollama-recipe");
|
|
33
|
+
const bedrock_recipe_1 = require("./bindings/client/gateways/bedrock/bedrock-recipe");
|
|
34
|
+
const openai_recipe_1 = require("./bindings/client/providers/openai/openai-recipe");
|
|
35
|
+
const openrouter_recipe_1 = require("./bindings/client/gateways/openrouter/openrouter-recipe");
|
|
36
|
+
const typesafe_recipe_1 = require("./bindings/client/providers/typesafe/typesafe-recipe");
|
|
37
|
+
const registry_1 = require("./bindings/client/core/registry");
|
|
38
|
+
const wrap_client_1 = require("./bindings/client/core/wrap-client");
|
|
39
|
+
const token_usage_1 = require("./bindings/shared/token-usage");
|
|
40
|
+
const context_1 = require("./core/context");
|
|
41
|
+
const session_1 = require("./core/session");
|
|
42
|
+
const hooks_1 = require("./core/hooks");
|
|
43
|
+
const types_1 = require("./types");
|
|
44
|
+
/**
|
|
45
|
+
* Recipes are data - method paths and usage extractors - so wiring them here costs nothing at
|
|
46
|
+
* runtime and pulls in no vendor package. An orchestration adapter is different: it needs its
|
|
47
|
+
* framework loaded, so it stays an explicit subpath import.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Order matters: the gateways are matched before the plain OpenAI adapter, because every one of
|
|
51
|
+
* them *is* an OpenAI client by shape and only the base URL says which. The bare adapter would
|
|
52
|
+
* otherwise claim them and lose what only a gateway knows - the model that actually served after
|
|
53
|
+
* routing, and the gateway's own request id.
|
|
54
|
+
*
|
|
55
|
+
* Matching them costs nothing in units: they report what the gateway stated and leave what it is
|
|
56
|
+
* worth to the run's mapper, like every other vendor. Billing the `cost` they add rather than the
|
|
57
|
+
* tokens is that mapper's business, and stays the caller's to decide.
|
|
58
|
+
*/
|
|
59
|
+
exports.DEFAULT_RECIPES = [
|
|
60
|
+
// First, and that position is load-bearing. Cloudflare AI Gateway forwards the upstream body
|
|
61
|
+
// untouched, so an `Anthropic` client pointed at it is an Anthropic client by shape and only the
|
|
62
|
+
// hostname says otherwise - and `anthropicRecipe` has no base-URL guard, so asked first it would
|
|
63
|
+
// claim that client and meter gateway traffic as direct Anthropic traffic. The same argument puts
|
|
64
|
+
// it ahead of `openAIRecipe` for its OpenAI-shaped routes and for Workers AI's compatible one.
|
|
65
|
+
(0, cloudflare_recipe_1.cloudflareRecipe)(),
|
|
66
|
+
anthropic_recipe_1.anthropicRecipe,
|
|
67
|
+
(0, openrouter_recipe_1.openrouterRecipe)(),
|
|
68
|
+
(0, litellm_recipe_1.litellmRecipe)(),
|
|
69
|
+
// The Databricks workspace route, matched on host *and* serving path - the host families alone
|
|
70
|
+
// are every Databricks API there is, most of which are not inference. Only the hosted recipe is
|
|
71
|
+
// here: `databricksExternalRecipe` covers the same wire reaching an endpoint billed by somebody
|
|
72
|
+
// else, which no matcher can tell apart, so it is named at `wrap` rather than guessed at.
|
|
73
|
+
(0, databricks_recipe_1.databricksRecipe)(),
|
|
74
|
+
// Ahead of `openAIRecipe`, like the gateways above and for the same reason: shape cannot tell a
|
|
75
|
+
// Groq client from any other OpenAI-compatible one, so the hostname decides, and the recipe that
|
|
76
|
+
// reads the hostname has to be asked first or the generic one claims the client.
|
|
77
|
+
groq_recipe_1.groqRecipe,
|
|
78
|
+
// Matched on its own client's configuration, so it sits with the gateways rather than ahead of
|
|
79
|
+
// or behind them: an AWS command bus is nothing any other guard here can mistake for its own.
|
|
80
|
+
(0, bedrock_recipe_1.bedrockRecipe)(),
|
|
81
|
+
openai_recipe_1.openAIRecipe,
|
|
82
|
+
// Matched on `chat.complete`, which no OpenAI-compatible client has - so position is not load
|
|
83
|
+
// bearing here, unlike the gateways above.
|
|
84
|
+
mistral_recipe_1.mistralRecipe,
|
|
85
|
+
// Ahead of `typesafeRecipe` on purpose. That recipe's whole guard is a single method name,
|
|
86
|
+
// `systemOne`, and an Ollama client carries a `systemone` whose request and response are the
|
|
87
|
+
// same shape - casing is the entire margin between them. `find` returns the first match, so the
|
|
88
|
+
// three-signal guard gets to answer first rather than relying on that margin holding.
|
|
89
|
+
ollama_recipe_1.ollamaRecipe,
|
|
90
|
+
typesafe_recipe_1.typesafeRecipe,
|
|
91
|
+
// Gemini's two surfaces are matched separately - a client and one of its chats are different
|
|
92
|
+
// objects with no method in common, so neither guard can see the other's traffic.
|
|
93
|
+
gemini_recipe_1.geminiRecipe,
|
|
94
|
+
gemini_recipe_1.geminiChatRecipe,
|
|
95
|
+
];
|
|
96
|
+
/**
|
|
97
|
+
* What a run's events are called when nothing names them.
|
|
98
|
+
*
|
|
99
|
+
* A client binding overrides this with the vendor call it wrapped - `anthropic.messages.create` -
|
|
100
|
+
* which is the name a Stigg meter should filter on. This is only the floor, for a run opened with
|
|
101
|
+
* no recipe behind it at all.
|
|
102
|
+
*/
|
|
103
|
+
const DEFAULT_EVENT_NAME = 'agent.usage';
|
|
104
|
+
/**
|
|
105
|
+
* Which metric a gateway's report draws the wallet with when the caller names no entitlement.
|
|
106
|
+
*
|
|
107
|
+
* A routing default, not a gate - which is why the run paths have none and this one does. A run
|
|
108
|
+
* that names no entitlement is asking not to be gated, and substituting one there would gate it;
|
|
109
|
+
* `ingestFromHeaders` never checks anything, so nothing here can be made to look enforced. All this
|
|
110
|
+
* decides is whether a metric literally called `credits` draws the credit wallet or is reported as
|
|
111
|
+
* a feature by that name - and dropping it would silently re-route exactly those reports at the
|
|
112
|
+
* next deploy, on the one path with no check to notice and an `'async'` mode that answers nothing.
|
|
113
|
+
*/
|
|
114
|
+
const DEFAULT_WALLET = { currencyId: 'credits' };
|
|
115
|
+
/**
|
|
116
|
+
* How long a failed engine probe is remembered before the next call builds a new one.
|
|
117
|
+
*
|
|
118
|
+
* Long enough that an outage is probed on a timer rather than once per request, short enough that
|
|
119
|
+
* a transient failure costs seconds instead of a restart. See {@link StiggAgentSDK.ensureStarted}
|
|
120
|
+
* for why remembering it at all is the point.
|
|
121
|
+
*/
|
|
122
|
+
const RETRY_START_AFTER_MS = 2_000;
|
|
123
|
+
/** The key shorthand and the options object, folded into the one object the config takes. */
|
|
124
|
+
function toOptions(first, second) {
|
|
125
|
+
return typeof first === 'string' ? { ...second, apiKey: first } : { ...first, ...second };
|
|
126
|
+
}
|
|
127
|
+
/** The assignment `register` is being asked to write, if it is being asked to write one. */
|
|
128
|
+
function toPlacement(input) {
|
|
129
|
+
if (!input.budget)
|
|
130
|
+
return undefined;
|
|
131
|
+
const { entitlement, cadence, parentId } = input.budget;
|
|
132
|
+
// A placement writes an assignment against the entitlement, so an empty one persists a budget
|
|
133
|
+
// attached to nothing rather than merely routing badly.
|
|
134
|
+
const invalid = (0, types_1.entitlementError)(entitlement);
|
|
135
|
+
if (invalid)
|
|
136
|
+
throw invalid;
|
|
137
|
+
// `'on-create'` seeds and leaves an admin's change alone; `'always'`, the default, writes every
|
|
138
|
+
// time. One field, so the two cannot both be given.
|
|
139
|
+
const onCreate = input.budget.apply === 'on-create';
|
|
140
|
+
// The type says a seed carries a number, and a JS caller is not bound by it. Seeding `undefined`
|
|
141
|
+
// sends a cadence and no cap, so the agent is created with the budget it asked for missing -
|
|
142
|
+
// enforcement then falls through to whatever ancestor has one, silently. Refused instead.
|
|
143
|
+
if (onCreate && typeof input.budget.limit !== 'number') {
|
|
144
|
+
throw new Error(`Agent ${input.agentId} asked to seed a budget with no limit to seed. ` +
|
|
145
|
+
"`budget.apply: 'on-create'` writes a cap the first time and never again, so there is " +
|
|
146
|
+
'nothing to write without `budget.limit`.');
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
parentId,
|
|
150
|
+
entitlement,
|
|
151
|
+
cadence,
|
|
152
|
+
usageLimit: onCreate ? undefined : input.budget.limit,
|
|
153
|
+
usageLimitOnCreate: onCreate ? (input.budget.limit ?? undefined) : undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
class StiggAgentSDK {
|
|
157
|
+
config;
|
|
158
|
+
clients;
|
|
159
|
+
/** Every session this SDK handed out, so `flush()` can drain all of them at once. */
|
|
160
|
+
sessions = new Set();
|
|
161
|
+
/** The session the no-client paths share. Built on first use. */
|
|
162
|
+
loose;
|
|
163
|
+
/**
|
|
164
|
+
* `customer:agent` -> what was last said about that agent, so a repeat of the same description
|
|
165
|
+
* is not written again per run. The value is a fingerprint of the describable attributes, not
|
|
166
|
+
* an id: the id is in the key.
|
|
167
|
+
*/
|
|
168
|
+
registered = new Map();
|
|
169
|
+
/**
|
|
170
|
+
* Bare registrations still on the wire, so the burst of runs that opens a process writes once
|
|
171
|
+
* rather than once each. Only the memoized path is shared - see `register`.
|
|
172
|
+
*/
|
|
173
|
+
registering = new Map();
|
|
174
|
+
started;
|
|
175
|
+
/**
|
|
176
|
+
* When the attempt currently in `started` rejected, or undefined while it is pending or has
|
|
177
|
+
* resolved. What makes a failed probe expire rather than persist - see `ensureStarted`.
|
|
178
|
+
*/
|
|
179
|
+
startFailedAt;
|
|
180
|
+
engineState;
|
|
181
|
+
constructor(first, second) {
|
|
182
|
+
this.config = (0, config_1.resolveConfig)(toOptions(first, second));
|
|
183
|
+
this.clients = (0, registry_1.createClientRegistry)(exports.DEFAULT_RECIPES);
|
|
184
|
+
}
|
|
185
|
+
/** Releases what this SDK's engine holds open. */
|
|
186
|
+
async close() {
|
|
187
|
+
// A startup still in flight hands back an engine after this returns, and with the client
|
|
188
|
+
// already out of the registry there is nothing left holding its cache and its sockets. So
|
|
189
|
+
// wait for it: a close that reports success while a poller is still running is not one.
|
|
190
|
+
const started = await this.started?.catch(() => undefined);
|
|
191
|
+
await (started ?? this.engineState)?.engine.close?.();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Forces the engine to resolve now and reports which client serves what. Optional - every
|
|
195
|
+
* async call below does it anyway - but useful when you want the answer before the first run.
|
|
196
|
+
*/
|
|
197
|
+
async init() {
|
|
198
|
+
return (await this.ensureStarted()).resolution;
|
|
199
|
+
}
|
|
200
|
+
/** Undefined until the engine has resolved. `init()` returns it directly. */
|
|
201
|
+
get resolution() {
|
|
202
|
+
return this.engineState?.resolution;
|
|
203
|
+
}
|
|
204
|
+
/** Available after the engine has resolved; the bindings that need it only run after that. */
|
|
205
|
+
get engine() {
|
|
206
|
+
if (!this.engineState)
|
|
207
|
+
throw new Error('The Stigg engine is not ready yet - await init() or any async call first.');
|
|
208
|
+
return this.engineState.engine;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Gives the agent an identity, a budget, and a place in the tree, and returns the agent id to
|
|
212
|
+
* open a run against.
|
|
213
|
+
*
|
|
214
|
+
* The id is the one you passed: nothing about the run depends on this call having happened, so
|
|
215
|
+
* returning it is a convenience rather than a handle. Skip `register` entirely and the agent
|
|
216
|
+
* has no entity - the run still works, and checks and usage apply to the customer instead.
|
|
217
|
+
*
|
|
218
|
+
* Idempotent, and remembered per process: an upsert is cheap, but it is still a write, so
|
|
219
|
+
* calling this on a hot path does not repeat it. A call carrying a budget or a parent always
|
|
220
|
+
* writes, because that is a deliberate change rather than a formality.
|
|
221
|
+
*
|
|
222
|
+
* `run.start(runOptions, agentOptions)` calls this for you with the run's own scope, which is
|
|
223
|
+
* the usual way in; reach for it directly to provision an agent no run is about to use.
|
|
224
|
+
*/
|
|
225
|
+
async register(input) {
|
|
226
|
+
const { customerId, agentId } = input;
|
|
227
|
+
// An id reaches the entity payload and the memo key alike, and an empty one is a public
|
|
228
|
+
// identifier for nothing - provisioned, unfindable, and billed against by name. The type is
|
|
229
|
+
// checked rather than assumed: `.trim()` on the number a JS caller passed throws a bare
|
|
230
|
+
// TypeError from inside the SDK, which reads as a bug in it rather than in the call.
|
|
231
|
+
if (typeof agentId !== 'string' || !agentId.trim()) {
|
|
232
|
+
throw new Error('register needs an agentId - an empty one provisions an entity nobody can name.');
|
|
233
|
+
}
|
|
234
|
+
const placement = toPlacement(input);
|
|
235
|
+
const key = `${customerId}:${agentId}`;
|
|
236
|
+
// Memoized on the description, not just the pair: a second call describing the agent
|
|
237
|
+
// differently is asking for that description to be written, and skipping it leaves the
|
|
238
|
+
// account holding a name the application has already stopped using. The agent's identity is
|
|
239
|
+
// `key`; this is only what is being said about it.
|
|
240
|
+
//
|
|
241
|
+
// Metadata is compared by its entries, sorted: `JSON.stringify` follows insertion order, so
|
|
242
|
+
// the same pairs built by a spread or an `Object.fromEntries` would fingerprint differently
|
|
243
|
+
// and re-upsert every run - which is the one case this memo exists to prevent.
|
|
244
|
+
const metadata = describableMetadata(agentId, input.metadata);
|
|
245
|
+
const describedAs = JSON.stringify([
|
|
246
|
+
input.entityTypeId,
|
|
247
|
+
input.displayName ?? null,
|
|
248
|
+
metadata ? Object.entries(metadata).sort(([a], [b]) => a.localeCompare(b)) : null,
|
|
249
|
+
]);
|
|
250
|
+
if (!placement && this.registered.get(key) === describedAs)
|
|
251
|
+
return agentId;
|
|
252
|
+
// Everything above is a property of the arguments, so it is answered before the engine is
|
|
253
|
+
// built: `run()` already validates in this order, and a malformed call that reports itself as
|
|
254
|
+
// a probe timeout sends the reader looking at the network for a typo they can see.
|
|
255
|
+
await this.ensureStarted();
|
|
256
|
+
if (!this.engine.capabilities.provisioning) {
|
|
257
|
+
// Only a call that asked for something governance-only is an error. A budget or a parent
|
|
258
|
+
// cannot be honoured, and carrying on would leave an agent that looks governed with a
|
|
259
|
+
// budget that was never written - so that fails. A bare registration asked for neither.
|
|
260
|
+
if (placement) {
|
|
261
|
+
throw new errors_1.GovernanceNotEnabledError(`Cannot give agent ${agentId} a budget or a parent: governance is not enabled for this ` +
|
|
262
|
+
'account, so there is nowhere to write the assignment. Contact Stigg to enable it, or ' +
|
|
263
|
+
'register without them - checks and usage reporting work at the customer level.');
|
|
264
|
+
}
|
|
265
|
+
this.config.logger.warn(`[stigg] governance is not enabled: agent ${agentId} has no entity, so checks and usage ` +
|
|
266
|
+
'apply to the customer rather than to the agent.');
|
|
267
|
+
return agentId;
|
|
268
|
+
}
|
|
269
|
+
const write = async () => {
|
|
270
|
+
await this.engine.upsertEntity(customerId, {
|
|
271
|
+
id: agentId,
|
|
272
|
+
entityTypeId: input.entityTypeId,
|
|
273
|
+
displayName: input.displayName,
|
|
274
|
+
metadata,
|
|
275
|
+
}, placement);
|
|
276
|
+
this.registered.set(key, describedAs);
|
|
277
|
+
this.config.logger.info(`[stigg] agent ${agentId} registered under customer ${customerId}`);
|
|
278
|
+
};
|
|
279
|
+
// Only the bare registration is shared. A call carrying a budget or a parent is a deliberate
|
|
280
|
+
// change that always writes, and two of those are two different instructions - collapsing
|
|
281
|
+
// them into one would silently drop whichever lost the race.
|
|
282
|
+
if (placement) {
|
|
283
|
+
await write();
|
|
284
|
+
return agentId;
|
|
285
|
+
}
|
|
286
|
+
// The memo above cannot help until the first write lands, which is exactly when a process
|
|
287
|
+
// opening its runs in parallel asks. Cleared on settle either way, so a failure is retryable
|
|
288
|
+
// and the callers sharing it all hear about it.
|
|
289
|
+
//
|
|
290
|
+
// Keyed on the description as well as the agent, for the same reason the settled memo is: two
|
|
291
|
+
// concurrent calls describing one agent differently are two different instructions, and
|
|
292
|
+
// sharing a promise between them would return success to the second having written only the
|
|
293
|
+
// first's `displayName`, metadata and entity type.
|
|
294
|
+
const shared = `${key}:${describedAs}`;
|
|
295
|
+
const inFlight = this.registering.get(shared) ?? write().finally(() => this.registering.delete(shared));
|
|
296
|
+
this.registering.set(shared, inFlight);
|
|
297
|
+
await inFlight;
|
|
298
|
+
return agentId;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Removes the agent's entity. Optional, mirrors `register`, and never relied on: it may never
|
|
302
|
+
* fire - a crash, an abandoned stream, an interrupt that is never resumed - which is why warm
|
|
303
|
+
* state has a TTL rather than waiting to be released.
|
|
304
|
+
*/
|
|
305
|
+
async deregister(input) {
|
|
306
|
+
await this.ensureStarted();
|
|
307
|
+
const { customerId, agentId } = input;
|
|
308
|
+
if (!this.engine.capabilities.provisioning) {
|
|
309
|
+
throw new errors_1.GovernanceNotEnabledError(`Cannot deregister agent ${agentId}: governance is not enabled for this account.`);
|
|
310
|
+
}
|
|
311
|
+
await this.engine.archiveEntity(customerId, agentId);
|
|
312
|
+
this.registered.delete(`${customerId}:${agentId}`);
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Governs a vendor client and hands back a copy whose calls are checked and metered.
|
|
316
|
+
*
|
|
317
|
+
* The whole lifecycle, in one step. `stiggContext` here is the default for every call through
|
|
318
|
+
* this client; any single call overrides it by putting its own `stiggContext` in the vendor's
|
|
319
|
+
* parameters. The client you passed is never mutated, so it keeps working ungoverned.
|
|
320
|
+
*
|
|
321
|
+
* The vendor is recognised from the client's own shape and base URL rather than named; pass
|
|
322
|
+
* `recipe` only for a vendor the built-ins cannot identify.
|
|
323
|
+
*
|
|
324
|
+
* Warms the entitlement cache before returning when the context names both a customer and an
|
|
325
|
+
* entitlement, so the calls that follow read local state. Nothing is cached in this package -
|
|
326
|
+
* see {@link GovernanceSession.warmUp} for which cache that is and when there is one at all.
|
|
327
|
+
*/
|
|
328
|
+
wrap(client, options = {}) {
|
|
329
|
+
// Started, not awaited. Resolving the engine is a probe over the network, and this is a setup
|
|
330
|
+
// call that reads as instant - so the work begins here and the first call that needs an
|
|
331
|
+
// engine waits on it. `init()` is there for anyone who wants that settled up front.
|
|
332
|
+
//
|
|
333
|
+
// The rejection has to be caught here, though, and that is not tidiness: nobody is holding
|
|
334
|
+
// this promise, so a failed probe became an unhandled rejection - which Node treats as fatal
|
|
335
|
+
// by default. A Stigg outage would have taken the application down at `wrap()`, before a
|
|
336
|
+
// single call was made, which is the exact opposite of what a soft limit promises. Reported
|
|
337
|
+
// through the application's own error channel instead; the first call that needs an engine
|
|
338
|
+
// awaits the same promise and surfaces it again somewhere the caller can see it.
|
|
339
|
+
void this.ensureStarted().catch((err) => this.reportStartupFailure(err));
|
|
340
|
+
const recipe = options.recipe ?? this.clients.find(client);
|
|
341
|
+
if (!recipe) {
|
|
342
|
+
// Silently returning an unwrapped client would mean one that quietly meters nothing.
|
|
343
|
+
throw new Error(`No client recipe matches this client. Known vendors: ${this.clients.vendors().join(', ') || 'none'}. ` +
|
|
344
|
+
'Pass `recipe` to name an adapter for it.');
|
|
345
|
+
}
|
|
346
|
+
const session = this.newSession(options.hooks);
|
|
347
|
+
// Resolved and validated once, here, so a call that overrides nothing has nothing to do. A
|
|
348
|
+
// client wrapped with no customer is legal - its calls each name their own, and the types say
|
|
349
|
+
// so - so that one case resolves per call instead.
|
|
350
|
+
const context = (0, context_1.resolveContext)(options.stiggContext ?? {}, (0, node_crypto_1.randomUUID)(), {
|
|
351
|
+
requireCustomer: false,
|
|
352
|
+
defaults: this.contextDefaults,
|
|
353
|
+
});
|
|
354
|
+
// Said once, at the boundary that owns the decision. A client that gates nothing looks exactly
|
|
355
|
+
// like one that gates, right up to the call that should have been refused and was not - so
|
|
356
|
+
// this is the only place the difference exists before it costs money. Only for a client that
|
|
357
|
+
// is otherwise fully configured: one whose calls each name their own customer may well name
|
|
358
|
+
// an entitlement too, and warning there would cry wolf on every wrap.
|
|
359
|
+
if (context.customerId && !(0, context_1.isEnforced)(context)) {
|
|
360
|
+
this.config.logger.warn('[stigg] this client named no entitlement, so nothing is checked and no call can be ' +
|
|
361
|
+
"refused - it meters only. Pass `entitlement: { currencyId: 'credits' }` in " +
|
|
362
|
+
'`stiggContext`, at the wrap or on a call, to gate it.');
|
|
363
|
+
}
|
|
364
|
+
const governed = (0, wrap_client_1.wrapClient)(client, recipe, session, context);
|
|
365
|
+
this.attachGovernance(governed, session, context);
|
|
366
|
+
// Not awaited: warming is an optimization, and it logs rather than throws.
|
|
367
|
+
if (context.customerId)
|
|
368
|
+
void session.warmUp(context);
|
|
369
|
+
return governed;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Hangs the governance handle off the wrapped client. Refuses to overwrite a `stigg` the vendor
|
|
373
|
+
* already has: silently replacing one of its own properties would be a worse surprise.
|
|
374
|
+
*/
|
|
375
|
+
attachGovernance(client, session, base) {
|
|
376
|
+
if (client.stigg !== undefined) {
|
|
377
|
+
throw new Error('This client already has a `stigg` property, so the governance handle has nowhere to go. ' +
|
|
378
|
+
'Governance would be invisible on it, so this is refused rather than overwritten.');
|
|
379
|
+
}
|
|
380
|
+
const resolve = (override) => {
|
|
381
|
+
const merged = override ? (0, context_1.overrideContext)(base, override) : base;
|
|
382
|
+
if (!merged.customerId)
|
|
383
|
+
throw (0, context_1.noCustomer)();
|
|
384
|
+
return merged;
|
|
385
|
+
};
|
|
386
|
+
const governance = {
|
|
387
|
+
flush: () => session.drain(),
|
|
388
|
+
close: async () => {
|
|
389
|
+
await session.drain();
|
|
390
|
+
this.sessions.delete(session);
|
|
391
|
+
},
|
|
392
|
+
summary: () => session.summary(),
|
|
393
|
+
get lastDecision() {
|
|
394
|
+
return session.lastDecision;
|
|
395
|
+
},
|
|
396
|
+
headers: (override) => session.headers(resolve(override)),
|
|
397
|
+
check: (override, requestedUsage) => session.check(resolve(override), requestedUsage),
|
|
398
|
+
report: (metrics, override) => session.ingestMetrics(resolve(override), metrics),
|
|
399
|
+
};
|
|
400
|
+
Object.defineProperty(client, 'stigg', { value: governance, enumerable: false });
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The gate, with no client to wrap - what a framework binding reaches for, and what the gateway
|
|
404
|
+
* example checks with before it proxies.
|
|
405
|
+
*/
|
|
406
|
+
async check(context, requestedUsage = 1) {
|
|
407
|
+
await this.ensureStarted();
|
|
408
|
+
return this.standalone().check((0, context_1.resolveContext)(context, (0, node_crypto_1.randomUUID)(), { defaults: this.contextDefaults }), requestedUsage);
|
|
409
|
+
}
|
|
410
|
+
/** The meter, with no client to wrap. Metrics already in Stigg's own vocabulary. */
|
|
411
|
+
async report(context, metrics) {
|
|
412
|
+
await this.ensureStarted();
|
|
413
|
+
return this.standalone().ingestMetrics((0, context_1.resolveContext)(context, (0, node_crypto_1.randomUUID)(), { defaults: this.contextDefaults }), metrics);
|
|
414
|
+
}
|
|
415
|
+
/** The `x-stigg-*` attribution for work continuing in another process, with no client involved. */
|
|
416
|
+
governanceHeaders(context) {
|
|
417
|
+
return this.standalone().headers((0, context_1.resolveContext)(context, (0, node_crypto_1.randomUUID)(), { defaults: this.contextDefaults }));
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The policy a context falls back to when neither the call nor `wrap` named one.
|
|
421
|
+
*
|
|
422
|
+
* Both fields are always set - the config schema defaults them - so this is the one place a
|
|
423
|
+
* constructor option becomes the behaviour of a call. Before it existed the options were parsed,
|
|
424
|
+
* stored, and never read: every context fell through to the context module's own defaults, and a
|
|
425
|
+
* client constructed with `checkErrorAction: 'deny'` failed open on every call it gated.
|
|
426
|
+
*/
|
|
427
|
+
get contextDefaults() {
|
|
428
|
+
return { checkErrorAction: this.config.checkErrorAction, ingestMode: this.config.ingest };
|
|
429
|
+
}
|
|
430
|
+
newSession(hooks) {
|
|
431
|
+
const session = new session_1.GovernanceSession({
|
|
432
|
+
engine: () => this.ensureStarted().then((state) => state.engine),
|
|
433
|
+
logger: this.config.logger,
|
|
434
|
+
onError: this.config.onError,
|
|
435
|
+
hooks: hooks ?? {},
|
|
436
|
+
});
|
|
437
|
+
this.sessions.add(session);
|
|
438
|
+
return session;
|
|
439
|
+
}
|
|
440
|
+
/** The session the no-client paths share, built on first use so an unused SDK builds none. */
|
|
441
|
+
standalone() {
|
|
442
|
+
this.loose ??= this.newSession();
|
|
443
|
+
return this.loose;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Returns the decision when `err` is a governance refusal, so a catch block is one line and the
|
|
447
|
+
* caller needs no import to tell a denial from a real failure.
|
|
448
|
+
*/
|
|
449
|
+
denial(err) {
|
|
450
|
+
return (0, errors_1.isAccessDenied)(err);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* The gateway side of the split lifecycle: ingest usage a proxy observed, attributed from the
|
|
454
|
+
* headers the application sent with the request. Ingest only - a gateway sees requests, not
|
|
455
|
+
* runs, so there is nothing here to check against.
|
|
456
|
+
*/
|
|
457
|
+
async ingestFromHeaders(headers, input) {
|
|
458
|
+
const { idempotencyKey } = input;
|
|
459
|
+
const mode = input.ingest ?? this.config.ingest;
|
|
460
|
+
// Filled in once the headers are read, and reported with every failure after that. Declared
|
|
461
|
+
// out here rather than parsed earlier because the checks below are ordered deliberately - an
|
|
462
|
+
// empty idempotency key is the first thing rejected - so a failure that happens before the
|
|
463
|
+
// headers are read truthfully carries no attribution, because at that point there is none.
|
|
464
|
+
let attribution;
|
|
465
|
+
// The hook is the application's own code and a gateway has no run to fall back on, so it is
|
|
466
|
+
// run the way `RunHandle` runs its own: a throw from it is reported, never returned.
|
|
467
|
+
const notify = (result) => (0, hooks_1.safely)(() => input.onIngest?.(result), (err) => this.reportIngestFailure(asIngestError(err), idempotencyKey, attribution));
|
|
468
|
+
const failed = (error) => {
|
|
469
|
+
this.reportIngestFailure(error, idempotencyKey, attribution);
|
|
470
|
+
const result = {
|
|
471
|
+
ok: false,
|
|
472
|
+
// What the error carried, not an assumption of nothing. An ingest is up to two calls -
|
|
473
|
+
// the features, then the wallet - so a rejection on the second leaves the first one's
|
|
474
|
+
// spend at Stigg. `asEngineError` preserves that; hardcoding `{}` here threw it away, and
|
|
475
|
+
// a gateway reading this result would retry work that was already billed.
|
|
476
|
+
reported: error.reported ?? {},
|
|
477
|
+
idempotencyKey,
|
|
478
|
+
mode,
|
|
479
|
+
replayed: error.replayed ?? false,
|
|
480
|
+
error,
|
|
481
|
+
};
|
|
482
|
+
notify(result);
|
|
483
|
+
return result;
|
|
484
|
+
};
|
|
485
|
+
// Everything is inside the failure path, the mapper and the engine's own resolution included.
|
|
486
|
+
// This call is documented to answer rather than throw, and "the usage mapper threw" is not a
|
|
487
|
+
// different kind of event from "the report did" to whoever is holding the request.
|
|
488
|
+
try {
|
|
489
|
+
if (!idempotencyKey) {
|
|
490
|
+
// The gateway's request id is what deduplicates its retries. An empty one is not a key,
|
|
491
|
+
// and read as absent it bills every retry again.
|
|
492
|
+
throw new errors_1.EngineError('ingest', 'idempotencyKey must not be empty - use the gateway request id');
|
|
493
|
+
}
|
|
494
|
+
await this.ensureStarted();
|
|
495
|
+
// The proxy observed someone else's response, so the vendor has to be named rather than
|
|
496
|
+
// guessed: raw names read off the wrong shape would report to the wrong features entirely.
|
|
497
|
+
const measurement = (0, token_usage_1.observedMeasurement)(input.usage, input.vendor);
|
|
498
|
+
const metrics = (input.metrics ?? types_1.vendorMetrics)(measurement);
|
|
499
|
+
attribution = (0, headers_1.parseGovernanceHeaders)(headers);
|
|
500
|
+
if (!attribution) {
|
|
501
|
+
throw new errors_1.EngineError('ingest', 'request carries no governance headers - nothing to attribute usage to');
|
|
502
|
+
}
|
|
503
|
+
// The same split `reportMetrics` makes: a metric named after the currency this ingest
|
|
504
|
+
// draws on goes to the wallet, which takes fractions, and everything else to the usage
|
|
505
|
+
// report, which does not.
|
|
506
|
+
const entitlement = input.entitlement ?? DEFAULT_WALLET;
|
|
507
|
+
const creditCurrencyId = (0, types_1.entitlementKind)(entitlement) === 'credit' ? (0, types_1.entitlementId)(entitlement) : undefined;
|
|
508
|
+
// Whether this proxied call names an agent, read from the header the sending side sets
|
|
509
|
+
// rather than guessed from the dimensions. The map arrives flat, so `{ region: 'eu' }`
|
|
510
|
+
// looks exactly like an attribution key from here - and rounding on that would overcharge a
|
|
511
|
+
// wallet draw with no entity behind it. See `wholeUnitsForGovernance`.
|
|
512
|
+
const attributed = !!attribution.attributionKey;
|
|
513
|
+
const sendable = {};
|
|
514
|
+
for (const [featureId, rawValue] of Object.entries(metrics)) {
|
|
515
|
+
const drawsWallet = featureId === creditCurrencyId;
|
|
516
|
+
const value = attributed && drawsWallet ? (0, types_1.wholeUnitsForGovernance)(rawValue) : rawValue;
|
|
517
|
+
// A gateway maps someone else's usage object with someone else's mapper, so it is the
|
|
518
|
+
// path most likely to produce a value that is not a measurement at all.
|
|
519
|
+
const invalid = (0, types_1.usageValueError)(value, mode, drawsWallet);
|
|
520
|
+
if (invalid)
|
|
521
|
+
throw invalid;
|
|
522
|
+
if (value > 0 || mode === 'async')
|
|
523
|
+
sendable[featureId] = value;
|
|
524
|
+
}
|
|
525
|
+
// Zero-token turns are normal (a cached refusal, an empty stream); they are not an error,
|
|
526
|
+
// and they are still an ingest an observer asked to be told about.
|
|
527
|
+
if (!Object.keys(sendable).length) {
|
|
528
|
+
const noop = { ok: true, reported: {}, idempotencyKey, mode, replayed: false };
|
|
529
|
+
notify(noop);
|
|
530
|
+
return noop;
|
|
531
|
+
}
|
|
532
|
+
const reported = await this.engine.report(attribution.customerId, {
|
|
533
|
+
metrics: sendable,
|
|
534
|
+
entitlement,
|
|
535
|
+
mode,
|
|
536
|
+
// What the proxy observed, as the proxy received it. There is no recipe out here, so the
|
|
537
|
+
// block is whatever `observedMeasurement` could make of the usage object it was handed.
|
|
538
|
+
raw: mode === 'async' ? (0, dimensions_1.flattenRaw)(measurement.raw) : undefined,
|
|
539
|
+
dimensions: attribution.dimensions,
|
|
540
|
+
idempotencyKey,
|
|
541
|
+
// No wrapped call to take a name from out of process, so the caller names it - or the
|
|
542
|
+
// vendor it declared does, which is the closest thing to the call that actually happened.
|
|
543
|
+
event: input.event ?? { name: input.vendor ? `${input.vendor}.proxied` : DEFAULT_EVENT_NAME },
|
|
544
|
+
});
|
|
545
|
+
// Delivered. The hook runs on the settled result, so instrumentation cannot turn a report
|
|
546
|
+
// that landed into one that reads as failed.
|
|
547
|
+
const result = {
|
|
548
|
+
ok: true,
|
|
549
|
+
reported: reported.reported,
|
|
550
|
+
idempotencyKey,
|
|
551
|
+
mode: reported.mode,
|
|
552
|
+
replayed: reported.replayed,
|
|
553
|
+
creditsConsumed: reported.creditsConsumed,
|
|
554
|
+
};
|
|
555
|
+
notify(result);
|
|
556
|
+
return result;
|
|
557
|
+
}
|
|
558
|
+
catch (err) {
|
|
559
|
+
return failed(asIngestError(err));
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* A probe that failed with nobody waiting on it.
|
|
564
|
+
*
|
|
565
|
+
* `wrap()` returns before the engine resolves, so this is the one path where a startup failure
|
|
566
|
+
* has no caller to reject towards. Swallowing it silently would leave a client that looks
|
|
567
|
+
* governed and cannot check; letting it reject unobserved ends the process.
|
|
568
|
+
*/
|
|
569
|
+
reportStartupFailure(err) {
|
|
570
|
+
const error = (0, errors_1.asEngineError)('start', err);
|
|
571
|
+
(0, hooks_1.safely)(() => this.config.onError(error, { operation: 'start' }), (reportErr) => this.config.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(reportErr)}`));
|
|
572
|
+
}
|
|
573
|
+
/** `onError` is the application's too, and there is nowhere left to report its failure to. */
|
|
574
|
+
reportIngestFailure(error, idempotencyKey, attribution) {
|
|
575
|
+
(0, hooks_1.safely)(() => this.config.onError(error, {
|
|
576
|
+
operation: 'ingest',
|
|
577
|
+
idempotencyKey,
|
|
578
|
+
// The request said which run and which customer it was for. An ingest that fails out
|
|
579
|
+
// here is the one case where nobody is holding a `RunHandle` to ask, so dropping them
|
|
580
|
+
// leaves the failure attached to a request id and nothing else.
|
|
581
|
+
runId: attribution?.runId,
|
|
582
|
+
customerId: attribution?.customerId,
|
|
583
|
+
}), (err) => this.config.logger.warn(`[stigg] onError threw while reporting ${error.message}: ${String(err)}`));
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Drains every wrapped client's in-flight ingests. Explicit because an exit hook does not fire
|
|
587
|
+
* in a request-scoped or serverless runtime.
|
|
588
|
+
*
|
|
589
|
+
* A single client drains on its own with `client.stigg.flush()`; this is the process-wide one,
|
|
590
|
+
* for a shutdown that does not know which clients exist.
|
|
591
|
+
*/
|
|
592
|
+
async flush(options = {}) {
|
|
593
|
+
const summaries = await Promise.all([...this.sessions].map((session) => session.drain()));
|
|
594
|
+
if (options.close)
|
|
595
|
+
await this.close();
|
|
596
|
+
return summaries;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Builds the engine once, on first need. Concurrent callers share the same attempt.
|
|
600
|
+
*
|
|
601
|
+
* A *failed* attempt is shared too, but only for {@link RETRY_START_AFTER_MS}. A rejected
|
|
602
|
+
* promise is still a promise, so memoizing one unconditionally outlives the failure that caused
|
|
603
|
+
* it: under the default `checkErrorAction: 'allow'` the process stops enforcing any budget at
|
|
604
|
+
* all, and under `'deny'` it refuses every call - neither saying anything a log would show.
|
|
605
|
+
*
|
|
606
|
+
* Holding the failure briefly rather than discarding it on sight is the other half: callers
|
|
607
|
+
* inside the window get the same rejection immediately and their own `checkErrorAction` decides
|
|
608
|
+
* the outcome, so an outage is re-probed on a timer instead of once per request.
|
|
609
|
+
*/
|
|
610
|
+
ensureStarted() {
|
|
611
|
+
if (this.startFailedAt !== undefined && Date.now() - this.startFailedAt >= RETRY_START_AFTER_MS) {
|
|
612
|
+
this.started = undefined;
|
|
613
|
+
this.startFailedAt = undefined;
|
|
614
|
+
}
|
|
615
|
+
if (!this.started) {
|
|
616
|
+
const attempt = (0, create_engine_1.createGovernanceEngine)({
|
|
617
|
+
apiKey: this.config.apiKey,
|
|
618
|
+
baseUrl: this.config.baseUrl,
|
|
619
|
+
timeoutMs: this.config.timeoutMs,
|
|
620
|
+
cache: this.config.cache,
|
|
621
|
+
client: this.config.client,
|
|
622
|
+
logger: this.config.logger,
|
|
623
|
+
}).then((state) => {
|
|
624
|
+
this.engineState = state;
|
|
625
|
+
this.config.logger.info(`[stigg] ${(0, engine_1.describeResolution)(state.resolution)}`);
|
|
626
|
+
return state;
|
|
627
|
+
});
|
|
628
|
+
this.started = attempt;
|
|
629
|
+
// Checked against the attempt that failed, not just set: a late rejection from an attempt
|
|
630
|
+
// already replaced would otherwise expire the newer one that succeeded.
|
|
631
|
+
attempt.catch(() => {
|
|
632
|
+
if (this.started === attempt)
|
|
633
|
+
this.startFailedAt = Date.now();
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
// `wrap` starts this and does not await it, so between a failing probe and the first call
|
|
637
|
+
// there is a rejected promise nobody is holding - which Node treats as an unhandled rejection
|
|
638
|
+
// and ends the process over. Marked handled here; it changes nothing for an awaiter, because
|
|
639
|
+
// attaching a handler does not consume the rejection. Same reason `wrap-client` does it to
|
|
640
|
+
// the promise behind a gated call.
|
|
641
|
+
this.started.catch(() => undefined);
|
|
642
|
+
return this.started;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
exports.StiggAgentSDK = StiggAgentSDK;
|
|
646
|
+
/** Whatever went wrong on the way to a report, as the seam's own error. */
|
|
647
|
+
function asIngestError(err) {
|
|
648
|
+
return err instanceof errors_1.EngineError ? err : new errors_1.EngineError('ingest', String(err), { cause: err });
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* The metadata an entity is described by, refused rather than carried when it is not that.
|
|
652
|
+
*
|
|
653
|
+
* Two readers need it to be a flat record of strings and neither says so when it is not: the memo
|
|
654
|
+
* fingerprints it with `JSON.stringify`, which throws a bare `TypeError` on anything cyclic, and
|
|
655
|
+
* the upsert sends it on to Stigg, which rejects the write one network hop later. A value the
|
|
656
|
+
* caller can see in their own argument is worth more than either.
|
|
657
|
+
*/
|
|
658
|
+
function describableMetadata(agentId, metadata) {
|
|
659
|
+
if (metadata === undefined || metadata === null)
|
|
660
|
+
return undefined;
|
|
661
|
+
const entries = typeof metadata === 'object' && !Array.isArray(metadata) ? Object.entries(metadata) : undefined;
|
|
662
|
+
const offender = entries?.find(([, value]) => typeof value !== 'string');
|
|
663
|
+
if (!entries || offender) {
|
|
664
|
+
throw new Error(`Agent ${agentId} was given metadata that is not a record of strings` +
|
|
665
|
+
`${offender ? ` - \`${offender[0]}\` is a ${typeof offender[1]}` : ''}. ` +
|
|
666
|
+
'Stigg stores entity metadata as strings; convert the values before passing them.');
|
|
667
|
+
}
|
|
668
|
+
return Object.fromEntries(entries);
|
|
669
|
+
}
|