@kindgi/api 0.1.4-rc.3 → 0.1.4-rc.5
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/dist/app.d.ts +7 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +12 -1
- package/dist/app.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +7 -0
- package/dist/errors.js.map +1 -1
- package/dist/gate-policy-binding.d.ts +7 -5
- package/dist/gate-policy-binding.d.ts.map +1 -1
- package/dist/handler-binding.d.ts +9 -0
- package/dist/handler-binding.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/live-version-binding.d.ts +40 -8
- package/dist/live-version-binding.d.ts.map +1 -1
- package/dist/middleware/error-mapper.d.ts.map +1 -1
- package/dist/middleware/error-mapper.js +2 -0
- package/dist/middleware/error-mapper.js.map +1 -1
- package/dist/middleware/request-log.d.ts +31 -0
- package/dist/middleware/request-log.d.ts.map +1 -0
- package/dist/middleware/request-log.js +80 -0
- package/dist/middleware/request-log.js.map +1 -0
- package/dist/openapi/operations.d.ts.map +1 -1
- package/dist/openapi/operations.js +8 -5
- package/dist/openapi/operations.js.map +1 -1
- package/dist/openapi/schemas.d.ts.map +1 -1
- package/dist/openapi/schemas.js +5 -0
- package/dist/openapi/schemas.js.map +1 -1
- package/dist/routes/agent-releases.d.ts.map +1 -1
- package/dist/routes/agent-releases.js +42 -1
- package/dist/routes/agent-releases.js.map +1 -1
- package/dist/routes/runs.d.ts.map +1 -1
- package/dist/routes/runs.js +5 -2
- package/dist/routes/runs.js.map +1 -1
- package/dist/secrets-binding.d.ts +8 -0
- package/dist/secrets-binding.d.ts.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/openapi.json +40 -6
- package/package.json +22 -21
- package/src/app.ts +17 -1
- package/src/errors.ts +7 -0
- package/src/gate-policy-binding.ts +7 -5
- package/src/handler-binding.ts +10 -0
- package/src/index.ts +1 -0
- package/src/live-version-binding.ts +39 -7
- package/src/middleware/error-mapper.ts +3 -0
- package/src/middleware/request-log.ts +89 -0
- package/src/openapi/operations.ts +16 -5
- package/src/openapi/schemas.ts +6 -0
- package/src/routes/agent-releases.ts +46 -2
- package/src/routes/runs.ts +11 -1
- package/src/secrets-binding.ts +5 -0
- package/src/types.ts +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kindgi/api",
|
|
3
|
-
"version": "0.1.4-rc.
|
|
3
|
+
"version": "0.1.4-rc.5",
|
|
4
4
|
"description": "REST + SSE HTTP surface for Kindgi™. createApp assembles a Hono app serving the /v1/* REST API (bearer or session-token auth; OpenAPI 3.1 document at /v1/openapi.json) and an optional S3-compatible /s3/* surface (SigV4), over caller-plugged bindings for the runtime (runs, agents, flows, HITL, supervisor) and for storage. Route conventions: docs/API-ROUTE-CONVENTIONS.md.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,30 +32,31 @@
|
|
|
32
32
|
"openapi.json"
|
|
33
33
|
],
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@kindgi/agents": "0.1.4-rc.
|
|
36
|
-
"@kindgi/audit-events": "0.1.4-rc.
|
|
37
|
-
"@kindgi/compliance": "0.1.4-rc.
|
|
38
|
-
"@kindgi/authz": "0.1.4-rc.
|
|
39
|
-
"@kindgi/blob-binding": "0.1.4-rc.
|
|
40
|
-
"@kindgi/capabilities": "0.1.4-rc.
|
|
41
|
-
"@kindgi/crypto": "0.1.4-rc.
|
|
42
|
-
"@kindgi/flow": "0.1.4-rc.
|
|
43
|
-
"@kindgi/guardrails": "0.1.4-rc.
|
|
44
|
-
"@kindgi/
|
|
45
|
-
"@kindgi/
|
|
46
|
-
"@kindgi/
|
|
47
|
-
"@kindgi/
|
|
48
|
-
"@kindgi/
|
|
49
|
-
"@kindgi/
|
|
50
|
-
"@kindgi/
|
|
51
|
-
"@kindgi/
|
|
35
|
+
"@kindgi/agents": "0.1.4-rc.5",
|
|
36
|
+
"@kindgi/audit-events": "0.1.4-rc.5",
|
|
37
|
+
"@kindgi/compliance": "0.1.4-rc.5",
|
|
38
|
+
"@kindgi/authz": "0.1.4-rc.5",
|
|
39
|
+
"@kindgi/blob-binding": "0.1.4-rc.5",
|
|
40
|
+
"@kindgi/capabilities": "0.1.4-rc.5",
|
|
41
|
+
"@kindgi/crypto": "0.1.4-rc.5",
|
|
42
|
+
"@kindgi/flow": "0.1.4-rc.5",
|
|
43
|
+
"@kindgi/guardrails": "0.1.4-rc.5",
|
|
44
|
+
"@kindgi/log": "0.1.4-rc.5",
|
|
45
|
+
"@kindgi/memory": "0.1.4-rc.5",
|
|
46
|
+
"@kindgi/platform": "0.1.4-rc.5",
|
|
47
|
+
"@kindgi/provenance": "0.1.4-rc.5",
|
|
48
|
+
"@kindgi/runtime": "0.1.4-rc.5",
|
|
49
|
+
"@kindgi/policy-contract": "0.1.4-rc.5",
|
|
50
|
+
"@kindgi/schema": "0.1.4-rc.5",
|
|
51
|
+
"@kindgi/tools": "0.1.4-rc.5",
|
|
52
|
+
"@kindgi/types": "0.1.4-rc.5",
|
|
52
53
|
"@scalar/hono-api-reference": "^0.12.2",
|
|
53
54
|
"hono": "^4.6.14"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
|
-
"@kindgi/audit-events-inmemory": "0.1.4-rc.
|
|
57
|
-
"@kindgi/specs": "0.1.4-rc.
|
|
58
|
-
"@kindgi/testing": "0.1.4-rc.
|
|
57
|
+
"@kindgi/audit-events-inmemory": "0.1.4-rc.5",
|
|
58
|
+
"@kindgi/specs": "0.1.4-rc.5",
|
|
59
|
+
"@kindgi/testing": "0.1.4-rc.5",
|
|
59
60
|
"@types/aws4": "^1.11.6",
|
|
60
61
|
"@types/node": "^22.10.5",
|
|
61
62
|
"aws4": "^1.13.2",
|
package/src/app.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) 2026 Kindgi Inc.
|
|
3
3
|
|
|
4
|
+
import { type Logger, noopLogger } from '@kindgi/log';
|
|
4
5
|
import { Scalar } from '@scalar/hono-api-reference';
|
|
5
6
|
import { Hono } from 'hono';
|
|
6
7
|
|
|
@@ -63,6 +64,7 @@ import { principalMiddleware } from './middleware/principal.js';
|
|
|
63
64
|
import { PROJECT_REF_ROUTES, refuseBadProjectId } from './middleware/project-ref.js';
|
|
64
65
|
import { publicRunCorsMiddleware, publicRunRouteMatcher } from './middleware/public-run-routes.js';
|
|
65
66
|
import { requestIdMiddleware } from './middleware/request-id.js';
|
|
67
|
+
import { requestLogMiddleware } from './middleware/request-log.js';
|
|
66
68
|
import { sigv4Middleware } from './middleware/sigv4.js';
|
|
67
69
|
import { type GenerateOptions, generateOpenApiDocument } from './openapi/generate.js';
|
|
68
70
|
import type { ProvenanceBinding } from './provenance-binding.js';
|
|
@@ -150,6 +152,12 @@ import type { WebhookEndpointBinding } from './webhook-endpoint-binding.js';
|
|
|
150
152
|
* for tests.
|
|
151
153
|
*/
|
|
152
154
|
export interface CreateAppInput {
|
|
155
|
+
/**
|
|
156
|
+
* Where the app's records go (`@kindgi/log`): the access line, logged
|
|
157
|
+
* 500s, and what routes log, each with the request's ids. Default:
|
|
158
|
+
* `noopLogger`, so an embedding app stays quiet unless it passes one.
|
|
159
|
+
*/
|
|
160
|
+
readonly logger?: Logger;
|
|
153
161
|
readonly resolveToken: TokenResolver;
|
|
154
162
|
readonly runHandler: RunHandlerBinding;
|
|
155
163
|
/**
|
|
@@ -893,7 +901,9 @@ export function createApp(input: CreateAppInput): Hono<AppEnv> {
|
|
|
893
901
|
|
|
894
902
|
// ---------- global middleware ----------
|
|
895
903
|
app.use('*', requestIdMiddleware());
|
|
896
|
-
//
|
|
904
|
+
// The request's trace context and logger, and its access line.
|
|
905
|
+
app.use('*', requestLogMiddleware(input.logger ?? noopLogger));
|
|
906
|
+
// A thrown exception: a 500 wire error with its message and request id, logged.
|
|
897
907
|
app.onError(mapThrownError);
|
|
898
908
|
|
|
899
909
|
// Public run tokens: checked once at startup; CORS for the two routes
|
|
@@ -968,6 +978,12 @@ export function createApp(input: CreateAppInput): Hono<AppEnv> {
|
|
|
968
978
|
// still populate the principal (cheap, and lets `can`/`check` work
|
|
969
979
|
// as inspection helpers even when authorize() enforcement is off).
|
|
970
980
|
v1.use('*', principalMiddleware());
|
|
981
|
+
// From here on, the request's records carry its tenant.
|
|
982
|
+
v1.use('*', async (c, next) => {
|
|
983
|
+
const tenantId = c.get('tenantId');
|
|
984
|
+
if (tenantId !== undefined) c.set('log', c.get('log').child({ tenantId }));
|
|
985
|
+
await next();
|
|
986
|
+
});
|
|
971
987
|
const authorizer: Authorizer | undefined =
|
|
972
988
|
input.authz !== undefined ? createAuthorizer(input.authz.authzCheckBinding) : undefined;
|
|
973
989
|
|
package/src/errors.ts
CHANGED
|
@@ -88,6 +88,10 @@ export const ERROR_CODE_TO_STATUS: Readonly<Record<string, number>> = {
|
|
|
88
88
|
'budget-exceeded': 422,
|
|
89
89
|
'output-schema-violation': 422,
|
|
90
90
|
'model-invocation-failed': 422,
|
|
91
|
+
/** No registered provider satisfies the agent's capability declaration (`needs`). */
|
|
92
|
+
'capability-unsatisfiable': 422,
|
|
93
|
+
/** A tool the agent names has no version satisfying its range (or the pinned one is gone). */
|
|
94
|
+
'tool-version-unresolvable': 422,
|
|
91
95
|
'tool-invocation-failed': 422,
|
|
92
96
|
'capability-routing-failed': 422,
|
|
93
97
|
'runtime-not-configured': 422,
|
|
@@ -195,6 +199,7 @@ export const ERROR_CODE_TO_STATUS: Readonly<Record<string, number>> = {
|
|
|
195
199
|
'gate-policy-scope-changed': 409,
|
|
196
200
|
'gate-policy-scope-unpinned': 409,
|
|
197
201
|
'gate-policy-needs-pin': 409,
|
|
202
|
+
'gate-policy-descendant-unpinned': 409,
|
|
198
203
|
/** Unregister: the version is live in a scope; move that pin first. */
|
|
199
204
|
'agent-version-live': 409,
|
|
200
205
|
'run-not-finished': 409,
|
|
@@ -286,6 +291,8 @@ export const ERROR_CODE_TO_STATUS: Readonly<Record<string, number>> = {
|
|
|
286
291
|
'secret-provider-unauthorized': 502,
|
|
287
292
|
'secret-provider-rate-limited': 429,
|
|
288
293
|
'secret-store-error': 500,
|
|
294
|
+
/** The secret store doesn't do this by design (the dev store's rotate and revoke). */
|
|
295
|
+
'secret-operation-unsupported': 501,
|
|
289
296
|
'env-store-error': 500,
|
|
290
297
|
// Trigger HTTP surface.
|
|
291
298
|
'trigger-not-found': 404,
|
|
@@ -102,9 +102,10 @@ export type GatePolicyErrorCode =
|
|
|
102
102
|
/** A new version names another agent or scope than the policy's. */
|
|
103
103
|
| 'gate-policy-scope-changed'
|
|
104
104
|
/**
|
|
105
|
-
* The scope
|
|
106
|
-
*
|
|
107
|
-
*
|
|
105
|
+
* The scope has no live version of its own. A gated scope holds its own
|
|
106
|
+
* pin: following the latest version, a publish would go live there
|
|
107
|
+
* ungated; following a pin above it, a promotion there would. Pin a
|
|
108
|
+
* version for the scope first.
|
|
108
109
|
*/
|
|
109
110
|
| 'gate-policy-scope-unpinned'
|
|
110
111
|
| 'gate-policy-not-found'
|
|
@@ -137,8 +138,9 @@ export interface GatePolicyVersionInput {
|
|
|
137
138
|
export interface GatePolicyBinding {
|
|
138
139
|
/**
|
|
139
140
|
* Register `id` at `version`: a new policy, or a new version of one
|
|
140
|
-
* (same agent and scope). A gated scope
|
|
141
|
-
* `gate-policy-scope-unpinned` when
|
|
141
|
+
* (same agent and scope). A gated scope holds its own pin:
|
|
142
|
+
* `gate-policy-scope-unpinned` when the scope has none (a pin above it
|
|
143
|
+
* isn't enough). Reinstating a version checks the same.
|
|
142
144
|
*/
|
|
143
145
|
publish(input: GatePolicyPublishInput): Promise<Result<GatePolicy, GatePolicyError>>;
|
|
144
146
|
/** The policy's latest active version, or `null`. */
|
package/src/handler-binding.ts
CHANGED
|
@@ -58,6 +58,12 @@ export interface RunHandlerBinding {
|
|
|
58
58
|
resumeRun(input: ResumeRunBindingInput): Promise<RunHandlerOutcome>;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** The request's W3C trace context, for the run it starts (`traceId` is kept on the run). */
|
|
62
|
+
export interface RunTrace {
|
|
63
|
+
readonly traceId: string;
|
|
64
|
+
readonly spanId: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
export interface ResumeRunBindingInput {
|
|
62
68
|
readonly tenantId: TenantId;
|
|
63
69
|
readonly runId: RunId;
|
|
@@ -101,6 +107,8 @@ export interface InvokeAgentBindingInput {
|
|
|
101
107
|
* can't run in the background may treat `false` like `true`.
|
|
102
108
|
*/
|
|
103
109
|
readonly wait?: boolean;
|
|
110
|
+
/** The trace context of the request starting the run: the binding records its `traceId` on the run. */
|
|
111
|
+
readonly trace?: RunTrace;
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
export interface InvokeFlowBindingInput {
|
|
@@ -122,6 +130,8 @@ export interface InvokeFlowBindingInput {
|
|
|
122
130
|
readonly wait?: boolean;
|
|
123
131
|
/** Agents and tools to run at other exact versions than the flow version's pins (`RunFlowInput.versions`). */
|
|
124
132
|
readonly versions?: FlowVersionOverrides;
|
|
133
|
+
/** The trace context of the request starting the run, as for an agent run. */
|
|
134
|
+
readonly trace?: RunTrace;
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
export type RunHandlerOutcome =
|
package/src/index.ts
CHANGED
|
@@ -133,10 +133,19 @@ export type PromotionErrorCode =
|
|
|
133
133
|
/** The scope's live version changed while the gate ran: check again. */
|
|
134
134
|
| 'promotion-superseded'
|
|
135
135
|
/**
|
|
136
|
-
* Unpin
|
|
137
|
-
*
|
|
136
|
+
* Unpin: the scope has a gate policy of its own, and a gated scope keeps
|
|
137
|
+
* its own pin (else a change above it, or a publish, would reach it
|
|
138
|
+
* ungated). Unregister the gate policy first, or roll back instead.
|
|
138
139
|
*/
|
|
139
140
|
| 'gate-policy-needs-pin'
|
|
141
|
+
/**
|
|
142
|
+
* Promote, rollback or unpin: the change would also move a narrower
|
|
143
|
+
* scope a gate policy applies to, which has no pin of its own and so
|
|
144
|
+
* follows this scope, without its gate. The message names each such
|
|
145
|
+
* scope: pin it at its current version first. (A gated scope must hold
|
|
146
|
+
* its own pin; this guards scopes gated before that rule.)
|
|
147
|
+
*/
|
|
148
|
+
| 'gate-policy-descendant-unpinned'
|
|
140
149
|
| 'persistence-error';
|
|
141
150
|
|
|
142
151
|
export interface PromotionError {
|
|
@@ -172,6 +181,18 @@ export interface PromotionRequestInput extends PromoteInput {
|
|
|
172
181
|
* promotion whose scope moved on becomes `superseded`.
|
|
173
182
|
*/
|
|
174
183
|
readonly servingVersion: Semver;
|
|
184
|
+
/**
|
|
185
|
+
* A pin in place: the scope has no pin of its own and serves exactly
|
|
186
|
+
* the promoted version (`servingVersion`), so pinning it there changes
|
|
187
|
+
* nothing any run gets. The gate's checks and approval don't apply:
|
|
188
|
+
* `checks` holds one passing `pinInPlace` line and there's no
|
|
189
|
+
* `approval`. The binding re-checks both conditions in the write's
|
|
190
|
+
* transaction, under the scope's lock, and refuses with
|
|
191
|
+
* `promotion-superseded` when either no longer holds; it records the
|
|
192
|
+
* promotion `promoted`, with the policy, its reason starting
|
|
193
|
+
* `pin-in-place`.
|
|
194
|
+
*/
|
|
195
|
+
readonly pinInPlace?: boolean;
|
|
175
196
|
};
|
|
176
197
|
}
|
|
177
198
|
|
|
@@ -203,22 +224,33 @@ export interface ListPromotionsInput {
|
|
|
203
224
|
}
|
|
204
225
|
|
|
205
226
|
export interface PromotionBinding {
|
|
206
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Make `version` live for `scope`. The version must be registered and
|
|
229
|
+
* active. `gate-policy-descendant-unpinned` when it would also move a
|
|
230
|
+
* narrower gated scope with no pin of its own.
|
|
231
|
+
*/
|
|
207
232
|
promote(input: PromoteInput): Promise<Result<Promotion, PromotionError>>;
|
|
208
233
|
/**
|
|
209
234
|
* Record a gated promotion request (evals step 4b): `refused` when the
|
|
210
235
|
* gate failed, `pending-approval` (opening a HITL approval, subject
|
|
211
236
|
* `agent-promotion`) when it passed and the policy wants an approval,
|
|
212
237
|
* else `promoted`. Optional: without it, the route refuses a promotion
|
|
213
|
-
* a gate policy applies to (`501`), rather than promoting ungated.
|
|
238
|
+
* a gate policy applies to (`501`), rather than promoting ungated. A
|
|
239
|
+
* passing request that would also move a narrower gated scope with no
|
|
240
|
+
* pin of its own is `gate-policy-descendant-unpinned`, and so is its
|
|
241
|
+
* approval's apply (the promotion is then `superseded`).
|
|
214
242
|
*/
|
|
215
243
|
request?(input: PromotionRequestInput): Promise<Result<Promotion, PromotionError>>;
|
|
216
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Back to the scope's previous live version, or a named earlier one,
|
|
246
|
+
* at once (no gate). `gate-policy-descendant-unpinned` as for `promote`.
|
|
247
|
+
*/
|
|
217
248
|
rollback(input: RollbackInput): Promise<Result<Promotion, PromotionError>>;
|
|
218
249
|
/**
|
|
219
250
|
* Remove the scope's own pin: it falls back to the next scope up. With
|
|
220
|
-
* gate policies, `gate-policy-needs-pin` when
|
|
221
|
-
*
|
|
251
|
+
* gate policies, `gate-policy-needs-pin` when the scope has a gate
|
|
252
|
+
* policy of its own, and `gate-policy-descendant-unpinned` when a
|
|
253
|
+
* narrower gated scope with no pin of its own follows it.
|
|
222
254
|
*/
|
|
223
255
|
unpin(input: UnpinInput): Promise<Result<Promotion, PromotionError>>;
|
|
224
256
|
list(input: ListPromotionsInput): Promise<{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright (C) 2026 Kindgi Inc.
|
|
3
3
|
|
|
4
|
+
import type { Logger } from '@kindgi/log';
|
|
4
5
|
import type { Context } from 'hono';
|
|
5
6
|
|
|
6
7
|
import { statusFor, toWireError } from '../errors.js';
|
|
@@ -28,6 +29,8 @@ export function mapThrownError(cause: Error, c: Context): Response {
|
|
|
28
29
|
return c.newResponse(res.body, res);
|
|
29
30
|
}
|
|
30
31
|
const requestId = (c.get('requestId') as string | undefined) ?? 'req-unknown';
|
|
32
|
+
// A 500 is something an operator should look at: logged, with the error.
|
|
33
|
+
(c.get('log') as Logger | undefined)?.error('unhandled error', { err: cause });
|
|
31
34
|
const body = toWireError(
|
|
32
35
|
{
|
|
33
36
|
code: 'internal-server-error',
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import { type LogLevel, type Logger, formatTraceparent, traceFromHeader } from '@kindgi/log';
|
|
5
|
+
import type { Context, MiddlewareHandler } from 'hono';
|
|
6
|
+
import { routePath } from 'hono/route';
|
|
7
|
+
|
|
8
|
+
import type { AppEnv } from '../types.js';
|
|
9
|
+
|
|
10
|
+
/** Paths a client or a probe reads all the time: their lines are `debug` even when they fail to read. */
|
|
11
|
+
const QUIET_PATHS: ReadonlySet<string> = new Set(['/health', '/ready', '/v1/openapi.json']);
|
|
12
|
+
|
|
13
|
+
const WRITES: ReadonlySet<string> = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The access line's level, so `info` stays readable with a console open
|
|
17
|
+
* (it polls every few seconds): a write, and every 4xx, at `info`; every
|
|
18
|
+
* 5xx at `error`; a successful read (`GET`, `HEAD`: the polling), the
|
|
19
|
+
* health and readiness probes, the spec and the console's files, and a
|
|
20
|
+
* stream's opening, at `debug`.
|
|
21
|
+
*/
|
|
22
|
+
export function accessLevel(c: Context<AppEnv>): LogLevel {
|
|
23
|
+
const status = c.res.status;
|
|
24
|
+
if (status >= 500) return 'error';
|
|
25
|
+
if (QUIET_PATHS.has(c.req.path) || !c.req.path.startsWith('/v1/')) return 'debug';
|
|
26
|
+
if (WRITES.has(c.req.method) || status >= 400) return 'info';
|
|
27
|
+
return 'debug';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The request's trace context and logger, then its access line.
|
|
32
|
+
*
|
|
33
|
+
* - **Trace:** an incoming `traceparent` (a load balancer's, an API
|
|
34
|
+
* gateway's, the caller's own app) is honoured: its trace id is kept
|
|
35
|
+
* and the request gets its own span, a child of the caller's. A
|
|
36
|
+
* missing or malformed one starts a fresh trace. `c.var.trace` holds
|
|
37
|
+
* it; the response answers `traceresponse` (W3C Trace Context Level
|
|
38
|
+
* 2) next to `X-Request-Id`.
|
|
39
|
+
* - **Logger:** `c.var.log` is a child of the app's logger with the
|
|
40
|
+
* request's `requestId`, `traceId` and `spanId` (subsystem `http`);
|
|
41
|
+
* routes log through it, and authentication adds `tenantId`.
|
|
42
|
+
* - **Access line:** `METHOD route status ms`, with the route's
|
|
43
|
+
* pattern (`/v1/runs/:runId`, never the raw path, so ids don't
|
|
44
|
+
* multiply lines), at the level `accessLevel` gives.
|
|
45
|
+
*
|
|
46
|
+
* Mounted right after the request-id middleware.
|
|
47
|
+
*/
|
|
48
|
+
export function requestLogMiddleware(logger: Logger): MiddlewareHandler<AppEnv> {
|
|
49
|
+
return async (c, next) => {
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
const trace = traceFromHeader(c.req.header('traceparent'));
|
|
52
|
+
c.set('trace', trace);
|
|
53
|
+
c.set(
|
|
54
|
+
'log',
|
|
55
|
+
logger.child({
|
|
56
|
+
subsystem: 'http',
|
|
57
|
+
requestId: c.get('requestId'),
|
|
58
|
+
traceId: trace.traceId,
|
|
59
|
+
spanId: trace.spanId,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
try {
|
|
63
|
+
await next();
|
|
64
|
+
} finally {
|
|
65
|
+
c.header('traceresponse', formatTraceparent(trace));
|
|
66
|
+
const log = c.get('log');
|
|
67
|
+
const level = accessLevel(c);
|
|
68
|
+
if (log.isLevelEnabled(level)) {
|
|
69
|
+
// The responding route's pattern; a request no route matched ends on a wildcard.
|
|
70
|
+
const pattern = routePath(c, -1);
|
|
71
|
+
const route = pattern.endsWith('*') ? c.req.path : pattern;
|
|
72
|
+
const durationMs = Date.now() - started;
|
|
73
|
+
const stream = (c.res.headers.get('content-type') ?? '').startsWith('text/event-stream');
|
|
74
|
+
log[stream ? 'debug' : level](
|
|
75
|
+
`${c.req.method} ${route} ${c.res.status} ${durationMs}ms${stream ? ' (stream opened)' : ''}`,
|
|
76
|
+
{
|
|
77
|
+
method: c.req.method,
|
|
78
|
+
route,
|
|
79
|
+
status: c.res.status,
|
|
80
|
+
durationMs,
|
|
81
|
+
...(c.get('tenantId') !== undefined && { tenantId: c.get('tenantId') }),
|
|
82
|
+
},
|
|
83
|
+
// The message says them: a terminal line needn't again.
|
|
84
|
+
{ inMessage: ['method', 'route', 'status', 'durationMs'] },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -1972,7 +1972,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
1972
1972
|
operationId: 'agents.promotions.create',
|
|
1973
1973
|
summary: 'Make a version live for a scope',
|
|
1974
1974
|
description:
|
|
1975
|
-
"Pins `version` live for `scope`: runs in that scope that don't name a version use it, from the next run. Open conversations keep their version. The version must be registered and active. Every promotion is recorded, with who asked and why. Needs `promote` on the agent.\n\nWith a gate policy for the scope (`GET …/gate-policy`), the promotion is checked first against the comparison named by `evalRunId`: `201` promoted; `202` the gate passed and the policy wants a reviewer's approval (a HITL approval, subject `agent-promotion`; the live version moves once it's approved, if nothing changed meanwhile); `422 gate-failed` with `details.promotionId`, `details.policy` and every check in `details.checks`. A refused promotion is recorded too.",
|
|
1975
|
+
"Pins `version` live for `scope`: runs in that scope that don't name a version use it, from the next run. Open conversations keep their version. The version must be registered and active. Every promotion is recorded, with who asked and why. Needs `promote` on the agent.\n\nWith a gate policy for the scope (`GET …/gate-policy`), the promotion is checked first against the comparison named by `evalRunId`: `201` promoted; `202` the gate passed and the policy wants a reviewer's approval (a HITL approval, subject `agent-promotion`; the live version moves once it's approved, if nothing changed meanwhile); `422 gate-failed` with `details.promotionId`, `details.policy` and every check in `details.checks`. A refused promotion is recorded too.\n\nA pin in place skips the gate: when the scope has no live version of its own and already serves exactly `version` (from a scope above, or as the latest), pinning it there changes nothing any run gets, so it is promoted (`201`) with one passing `pinInPlace` check, no comparison and no approval, its reason starting `pin-in-place`. It is re-checked as it is written; if the scope moved meanwhile, `409 promotion-superseded`: check again.",
|
|
1976
1976
|
tags: ['agents'],
|
|
1977
1977
|
security: 'bearer',
|
|
1978
1978
|
parameters: [AgentIdPathParam, IdempotencyKeyParam],
|
|
@@ -1988,7 +1988,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
1988
1988
|
'The version is not registered, or was unregistered; or the eval run is not found.',
|
|
1989
1989
|
),
|
|
1990
1990
|
'409': ErrorResponse(
|
|
1991
|
-
"`promotion-superseded`: the scope's live version changed while the gate ran; check again.",
|
|
1991
|
+
"`promotion-superseded`: the scope's live version changed while the gate ran; check again. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.",
|
|
1992
1992
|
),
|
|
1993
1993
|
'422': ErrorResponse(
|
|
1994
1994
|
'`gate-failed`: the gate refused it. `details.checks` has every check; the refusal is recorded (`details.promotionId`).',
|
|
@@ -2086,7 +2086,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
2086
2086
|
...CommonMutationErrors,
|
|
2087
2087
|
'400': ErrorResponse('Validation failed (see `details.issues`).'),
|
|
2088
2088
|
'409': ErrorResponse(
|
|
2089
|
-
'`gate-policy-already-registered`: that (id, version) exists. `gate-policy-scope-taken`: another policy gates the agent for the scope (`details.heldBy`). `gate-policy-scope-changed`: the version would change the agent or scope. `gate-policy-scope-unpinned`:
|
|
2089
|
+
'`gate-policy-already-registered`: that (id, version) exists. `gate-policy-scope-taken`: another policy gates the agent for the scope (`details.heldBy`). `gate-policy-scope-changed`: the version would change the agent or scope. `gate-policy-scope-unpinned`: the scope has no live version of its own; a gated scope holds its own pin (a pin above it is not enough, since a promotion there would change this scope ungated), so pin a version for the scope first.',
|
|
2090
2090
|
),
|
|
2091
2091
|
},
|
|
2092
2092
|
},
|
|
@@ -2168,6 +2168,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
2168
2168
|
'200': { description: 'The reinstated version.', schema: ref('GatePolicy') },
|
|
2169
2169
|
...CommonMutationErrors,
|
|
2170
2170
|
'404': ErrorResponse('No such version.'),
|
|
2171
|
+
'409': ErrorResponse(
|
|
2172
|
+
'`gate-policy-scope-taken`: another policy gates the agent for the scope now (`details.heldBy`). `gate-policy-scope-unpinned`: the scope has no live version of its own; pin one first.',
|
|
2173
|
+
),
|
|
2171
2174
|
},
|
|
2172
2175
|
},
|
|
2173
2176
|
{
|
|
@@ -2225,7 +2228,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
2225
2228
|
'200': { description: 'Rolled back.', schema: ref('Promotion') },
|
|
2226
2229
|
...CommonMutationErrors,
|
|
2227
2230
|
'404': ErrorResponse('`toVersion` is not registered, or was unregistered.'),
|
|
2228
|
-
'409': ErrorResponse(
|
|
2231
|
+
'409': ErrorResponse(
|
|
2232
|
+
'`not-pinned`: the scope has no pin of its own. `nothing-to-roll-back`: no earlier version to go back to. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.',
|
|
2233
|
+
),
|
|
2229
2234
|
},
|
|
2230
2235
|
},
|
|
2231
2236
|
{
|
|
@@ -2244,7 +2249,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
2244
2249
|
'200': { description: 'Unpinned.', schema: ref('Promotion') },
|
|
2245
2250
|
...CommonMutationErrors,
|
|
2246
2251
|
'409': ErrorResponse(
|
|
2247
|
-
'`not-pinned`: the scope has no pin of its own. `gate-policy-needs-pin`:
|
|
2252
|
+
'`not-pinned`: the scope has no pin of its own. `gate-policy-needs-pin`: the scope has a gate policy of its own, and a gated scope keeps its own pin; unregister the policy first, or roll back instead. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.',
|
|
2248
2253
|
),
|
|
2249
2254
|
},
|
|
2250
2255
|
},
|
|
@@ -6077,6 +6082,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
6077
6082
|
...CommonMutationErrors,
|
|
6078
6083
|
'403': ErrorResponse('Bearer token missing `secrets:rotate` capability.'),
|
|
6079
6084
|
'404': ErrorResponse('Unknown secret.'),
|
|
6085
|
+
'501': ErrorResponse(
|
|
6086
|
+
"The store doesn't rotate (`secret-operation-unsupported`): under `kindgi dev` secrets live in the env files and have no versions; the message says what to do instead.",
|
|
6087
|
+
),
|
|
6080
6088
|
},
|
|
6081
6089
|
},
|
|
6082
6090
|
{
|
|
@@ -6173,6 +6181,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
|
|
|
6173
6181
|
'400': ErrorResponse('Missing or malformed scope / envName.'),
|
|
6174
6182
|
'403': ErrorResponse('Bearer token missing revoke capability.'),
|
|
6175
6183
|
'404': ErrorResponse('Unknown secret.'),
|
|
6184
|
+
'501': ErrorResponse(
|
|
6185
|
+
"The store doesn't revoke (`secret-operation-unsupported`): under `kindgi dev` secrets live in the env files; the message says to remove the name there.",
|
|
6186
|
+
),
|
|
6176
6187
|
},
|
|
6177
6188
|
},
|
|
6178
6189
|
|
package/src/openapi/schemas.ts
CHANGED
|
@@ -637,6 +637,12 @@ export const RunSchema: JsonSchema = {
|
|
|
637
637
|
description:
|
|
638
638
|
"The segment path the run was started with (coarse to fine), which picks live agent versions. A child run has its parent's. Absent when there was none.",
|
|
639
639
|
},
|
|
640
|
+
traceId: {
|
|
641
|
+
type: 'string',
|
|
642
|
+
pattern: '^[0-9a-f]{32}$',
|
|
643
|
+
description:
|
|
644
|
+
"The W3C trace id of the request that started the run: the caller's (from its `traceparent`) or one the API minted. The runtime's records about the run carry it; `GET` responses answer `traceresponse` with each request's own. Absent for a run no request started, and on runs from before runs recorded it.",
|
|
645
|
+
},
|
|
640
646
|
publicAccessToken: {
|
|
641
647
|
type: 'string',
|
|
642
648
|
description:
|
|
@@ -184,6 +184,7 @@ export function mountAgentReleaseRoutes(
|
|
|
184
184
|
passed: gate.result.passed,
|
|
185
185
|
...(gate.result.approval !== undefined && { approval: gate.result.approval }),
|
|
186
186
|
servingVersion: gate.servingVersion as Semver,
|
|
187
|
+
...(gate.pinInPlace && { pinInPlace: true }),
|
|
187
188
|
},
|
|
188
189
|
});
|
|
189
190
|
if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
|
|
@@ -441,10 +442,32 @@ function summaryOf(run: EvalRun): JudgedComparisonSummary | null {
|
|
|
441
442
|
: null;
|
|
442
443
|
}
|
|
443
444
|
|
|
445
|
+
/** Whether two live scopes are the same scope. */
|
|
446
|
+
function sameScope(a: LiveScope, b: LiveScope): boolean {
|
|
447
|
+
switch (a.kind) {
|
|
448
|
+
case 'tenant':
|
|
449
|
+
return b.kind === 'tenant';
|
|
450
|
+
case 'org':
|
|
451
|
+
return b.kind === 'org' && b.orgId === a.orgId;
|
|
452
|
+
case 'project':
|
|
453
|
+
return b.kind === 'project' && b.projectId === a.projectId;
|
|
454
|
+
case 'segment':
|
|
455
|
+
return (
|
|
456
|
+
b.kind === 'segment' &&
|
|
457
|
+
b.projectId === a.projectId &&
|
|
458
|
+
b.path.length === a.path.length &&
|
|
459
|
+
b.path.every((s, i) => s.key === a.path[i]?.key && s.value === a.path[i]?.value)
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
444
464
|
/**
|
|
445
465
|
* The gate for a promotion request: what serves the scope now, the
|
|
446
466
|
* comparison it names, and the policy's checks against them. With no
|
|
447
|
-
* policy there's nothing to check.
|
|
467
|
+
* policy there's nothing to check. A pin in place (the scope has no pin
|
|
468
|
+
* of its own and serves exactly the version already) changes nothing any
|
|
469
|
+
* run gets, so the policy's checks and approval don't apply: one passing
|
|
470
|
+
* `pinInPlace` check, and `pinInPlace` for the binding to re-check.
|
|
448
471
|
*/
|
|
449
472
|
async function runGate(
|
|
450
473
|
registry: AgentRegistryBinding,
|
|
@@ -459,7 +482,7 @@ async function runGate(
|
|
|
459
482
|
},
|
|
460
483
|
policy: GatePolicy | null,
|
|
461
484
|
): Promise<
|
|
462
|
-
| { kind: 'ok'; result: GateResult; servingVersion: string }
|
|
485
|
+
| { kind: 'ok'; result: GateResult; servingVersion: string; pinInPlace?: boolean }
|
|
463
486
|
| { kind: 'err'; error: { code: string; message: string } }
|
|
464
487
|
> {
|
|
465
488
|
const { tenantId, agentId } = req;
|
|
@@ -483,6 +506,27 @@ async function runGate(
|
|
|
483
506
|
servingVersion = latest.version as unknown as string;
|
|
484
507
|
}
|
|
485
508
|
if (policy === null) return { kind: 'ok', result: { checks: [], passed: true }, servingVersion };
|
|
509
|
+
if (servingVersion === (req.version as unknown as string)) {
|
|
510
|
+
const pins = await releases.live.list({ tenantId, agentId });
|
|
511
|
+
if (!pins.some((pin) => sameScope(pin.scope, req.scope))) {
|
|
512
|
+
const from = live === null ? 'as the latest version' : 'from a pin above it';
|
|
513
|
+
return {
|
|
514
|
+
kind: 'ok',
|
|
515
|
+
result: {
|
|
516
|
+
checks: [
|
|
517
|
+
{
|
|
518
|
+
name: 'pinInPlace',
|
|
519
|
+
passed: true,
|
|
520
|
+
message: `${agentId} ${servingVersion} already serves this scope (${from}), so pinning it here changes nothing for runs: the gate's checks and approval don't apply.`,
|
|
521
|
+
},
|
|
522
|
+
],
|
|
523
|
+
passed: true,
|
|
524
|
+
},
|
|
525
|
+
servingVersion,
|
|
526
|
+
pinInPlace: true,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
}
|
|
486
530
|
|
|
487
531
|
let summary: JudgedComparisonSummary | null = null;
|
|
488
532
|
if (req.evalRunId !== undefined) {
|
package/src/routes/runs.ts
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
RunHandlerBinding,
|
|
24
24
|
RunHandlerFailure,
|
|
25
25
|
RunHandlerOutcome,
|
|
26
|
+
RunTrace,
|
|
26
27
|
} from '../handler-binding.js';
|
|
27
28
|
import type { Authorizer } from '../middleware/authorize.js';
|
|
28
29
|
import type { MintPublicRunTokenResult } from '../public-run-token.js';
|
|
@@ -161,7 +162,13 @@ export function runsRouter(
|
|
|
161
162
|
return c.json(toWireError(parsed.error, requestId));
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
const
|
|
165
|
+
const trace = c.get('trace');
|
|
166
|
+
const invocation = await invokeFromBody(
|
|
167
|
+
binding,
|
|
168
|
+
tenantId,
|
|
169
|
+
parsed.value,
|
|
170
|
+
trace !== undefined ? { traceId: trace.traceId, spanId: trace.spanId } : undefined,
|
|
171
|
+
);
|
|
165
172
|
|
|
166
173
|
if (invocation.kind === 'err') {
|
|
167
174
|
c.status(statusFor(invocation.error.code) as never);
|
|
@@ -624,9 +631,11 @@ function invokeFromBody(
|
|
|
624
631
|
binding: RunHandlerBinding,
|
|
625
632
|
tenantId: TenantId,
|
|
626
633
|
body: ParsedStartRunBody,
|
|
634
|
+
trace?: RunTrace,
|
|
627
635
|
): Promise<RunHandlerOutcome> {
|
|
628
636
|
const common = {
|
|
629
637
|
tenantId,
|
|
638
|
+
...(trace !== undefined && { trace }),
|
|
630
639
|
...(body.projectId !== undefined && { projectId: body.projectId }),
|
|
631
640
|
input: body.input,
|
|
632
641
|
...(body.segments !== undefined && { segments: body.segments }),
|
|
@@ -690,6 +699,7 @@ function serializeRun(
|
|
|
690
699
|
row.segments.length > 0 && {
|
|
691
700
|
segments: row.segments.map(({ key, value }) => ({ key, value })),
|
|
692
701
|
}),
|
|
702
|
+
...(row.traceId != null && { traceId: row.traceId }),
|
|
693
703
|
};
|
|
694
704
|
}
|
|
695
705
|
|
package/src/secrets-binding.ts
CHANGED
|
@@ -305,4 +305,9 @@ export type SecretError =
|
|
|
305
305
|
readonly message: string;
|
|
306
306
|
readonly currentVersion: number;
|
|
307
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* The store doesn't do this by design (the dev store keeps no versions to
|
|
310
|
+
* rotate and no revocation): the message says what to do instead.
|
|
311
|
+
*/
|
|
312
|
+
| { readonly code: 'secret-operation-unsupported'; readonly message: string }
|
|
308
313
|
| { readonly code: 'secret-store-error'; readonly message: string; readonly cause?: unknown };
|
package/src/types.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Copyright (C) 2026 Kindgi Inc.
|
|
3
3
|
|
|
4
4
|
import type { Principal, ReviewerRole } from '@kindgi/authz';
|
|
5
|
+
import type { Logger, TraceContext } from '@kindgi/log';
|
|
5
6
|
import type { ApiTokenId, RunId, SessionId, TenantId, UserId } from '@kindgi/types';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -11,6 +12,14 @@ import type { ApiTokenId, RunId, SessionId, TenantId, UserId } from '@kindgi/typ
|
|
|
11
12
|
export interface AppEnv {
|
|
12
13
|
Variables: {
|
|
13
14
|
requestId: string;
|
|
15
|
+
/**
|
|
16
|
+
* The request's logger (`requestLogMiddleware`): the app's, with the
|
|
17
|
+
* request's `requestId`, `traceId` and `spanId`, and its `tenantId`
|
|
18
|
+
* once authenticated. Routes log through it.
|
|
19
|
+
*/
|
|
20
|
+
log: Logger;
|
|
21
|
+
/** The request's W3C trace context: the caller's trace when it sent a valid `traceparent`. */
|
|
22
|
+
trace: TraceContext;
|
|
14
23
|
/** Set by `bearerAuthMiddleware` on authenticated routes. */
|
|
15
24
|
tenantId: TenantId;
|
|
16
25
|
/**
|