@kindgi/api 0.1.4-rc.0 → 0.1.4-rc.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/dist/agent-binding.d.ts +20 -2
- package/dist/agent-binding.d.ts.map +1 -1
- package/dist/agent-pins.d.ts.map +1 -1
- package/dist/agent-pins.js +4 -1
- package/dist/agent-pins.js.map +1 -1
- package/dist/app.d.ts +8 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +30 -5
- package/dist/app.js.map +1 -1
- package/dist/deploy-versions.d.ts +10 -5
- package/dist/deploy-versions.d.ts.map +1 -1
- package/dist/deploy-versions.js +4 -10
- package/dist/deploy-versions.js.map +1 -1
- package/dist/derive-agent-version.d.ts +9 -1
- package/dist/derive-agent-version.d.ts.map +1 -1
- package/dist/derive-agent-version.js +11 -1
- package/dist/derive-agent-version.js.map +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -1
- package/dist/eval-case-binding.d.ts +10 -0
- package/dist/eval-case-binding.d.ts.map +1 -1
- package/dist/eval-run-binding.d.ts +21 -3
- package/dist/eval-run-binding.d.ts.map +1 -1
- package/dist/eval-run-binding.js.map +1 -1
- package/dist/eval-run-dispatcher.d.ts +3 -0
- package/dist/eval-run-dispatcher.d.ts.map +1 -1
- package/dist/eval-run-dispatcher.js.map +1 -1
- package/dist/flow-binding.d.ts +8 -0
- package/dist/flow-binding.d.ts.map +1 -1
- package/dist/flow-pins.d.ts +20 -7
- package/dist/flow-pins.d.ts.map +1 -1
- package/dist/flow-pins.js +36 -11
- package/dist/flow-pins.js.map +1 -1
- package/dist/gate-policy-binding.d.ts +164 -0
- package/dist/gate-policy-binding.d.ts.map +1 -0
- package/dist/gate-policy-binding.js +12 -0
- package/dist/gate-policy-binding.js.map +1 -0
- package/dist/gate.d.ts +56 -0
- package/dist/gate.d.ts.map +1 -0
- package/dist/gate.js +359 -0
- package/dist/gate.js.map +1 -0
- package/dist/guardrail-binding.d.ts +8 -0
- package/dist/guardrail-binding.d.ts.map +1 -1
- package/dist/handler-binding.d.ts +17 -2
- package/dist/handler-binding.d.ts.map +1 -1
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/judged-dispatcher.d.ts +20 -1
- package/dist/judged-dispatcher.d.ts.map +1 -1
- package/dist/judged-dispatcher.js +57 -7
- package/dist/judged-dispatcher.js.map +1 -1
- package/dist/judgment-binding.d.ts +38 -0
- package/dist/judgment-binding.d.ts.map +1 -1
- package/dist/judgment-binding.js +19 -0
- package/dist/judgment-binding.js.map +1 -1
- package/dist/live-version-binding.d.ts +206 -0
- package/dist/live-version-binding.d.ts.map +1 -0
- package/dist/live-version-binding.js +4 -0
- package/dist/live-version-binding.js.map +1 -0
- package/dist/middleware/authorize.d.ts.map +1 -1
- package/dist/middleware/authorize.js +13 -2
- package/dist/middleware/authorize.js.map +1 -1
- package/dist/middleware/project-ref.d.ts +25 -0
- package/dist/middleware/project-ref.d.ts.map +1 -0
- package/dist/middleware/project-ref.js +72 -0
- package/dist/middleware/project-ref.js.map +1 -0
- package/dist/openapi/generate.d.ts.map +1 -1
- package/dist/openapi/generate.js +4 -0
- package/dist/openapi/generate.js.map +1 -1
- package/dist/openapi/operations.d.ts +6 -0
- package/dist/openapi/operations.d.ts.map +1 -1
- package/dist/openapi/operations.js +524 -27
- package/dist/openapi/operations.js.map +1 -1
- package/dist/openapi/schemas.d.ts +43 -0
- package/dist/openapi/schemas.d.ts.map +1 -1
- package/dist/openapi/schemas.js +1050 -7
- package/dist/openapi/schemas.js.map +1 -1
- package/dist/publish-refused.d.ts +18 -0
- package/dist/publish-refused.d.ts.map +1 -0
- package/dist/publish-refused.js +22 -0
- package/dist/publish-refused.js.map +1 -0
- package/dist/registry-read-only.d.ts +32 -0
- package/dist/registry-read-only.d.ts.map +1 -0
- package/dist/registry-read-only.js +22 -0
- package/dist/registry-read-only.js.map +1 -0
- package/dist/retention-binding.d.ts +29 -0
- package/dist/retention-binding.d.ts.map +1 -1
- package/dist/routes/agent-releases.d.ts +40 -0
- package/dist/routes/agent-releases.d.ts.map +1 -0
- package/dist/routes/agent-releases.js +462 -0
- package/dist/routes/agent-releases.js.map +1 -0
- package/dist/routes/agents.d.ts +7 -1
- package/dist/routes/agents.d.ts.map +1 -1
- package/dist/routes/agents.js +38 -4
- package/dist/routes/agents.js.map +1 -1
- package/dist/routes/approvals.d.ts.map +1 -1
- package/dist/routes/approvals.js +27 -9
- package/dist/routes/approvals.js.map +1 -1
- package/dist/routes/audit.d.ts.map +1 -1
- package/dist/routes/audit.js +7 -0
- package/dist/routes/audit.js.map +1 -1
- package/dist/routes/auth.js +1 -1
- package/dist/routes/auth.js.map +1 -1
- package/dist/routes/blocks.d.ts.map +1 -1
- package/dist/routes/blocks.js +37 -12
- package/dist/routes/blocks.js.map +1 -1
- package/dist/routes/conversations.d.ts +0 -7
- package/dist/routes/conversations.d.ts.map +1 -1
- package/dist/routes/conversations.js +19 -3
- package/dist/routes/conversations.js.map +1 -1
- package/dist/routes/deployments.d.ts +6 -0
- package/dist/routes/deployments.d.ts.map +1 -1
- package/dist/routes/deployments.js +49 -5
- package/dist/routes/deployments.js.map +1 -1
- package/dist/routes/env.d.ts.map +1 -1
- package/dist/routes/env.js +1 -0
- package/dist/routes/env.js.map +1 -1
- package/dist/routes/eval-comparison.d.ts +4 -2
- package/dist/routes/eval-comparison.d.ts.map +1 -1
- package/dist/routes/eval-comparison.js +59 -10
- package/dist/routes/eval-comparison.js.map +1 -1
- package/dist/routes/eval-runs.d.ts +7 -1
- package/dist/routes/eval-runs.d.ts.map +1 -1
- package/dist/routes/eval-runs.js +34 -3
- package/dist/routes/eval-runs.js.map +1 -1
- package/dist/routes/eval-versions.d.ts +25 -0
- package/dist/routes/eval-versions.d.ts.map +1 -0
- package/dist/routes/eval-versions.js +66 -0
- package/dist/routes/eval-versions.js.map +1 -0
- package/dist/routes/flows.d.ts +14 -7
- package/dist/routes/flows.d.ts.map +1 -1
- package/dist/routes/flows.js +14 -8
- package/dist/routes/flows.js.map +1 -1
- package/dist/routes/gate-policies.d.ts +19 -0
- package/dist/routes/gate-policies.d.ts.map +1 -0
- package/dist/routes/gate-policies.js +191 -0
- package/dist/routes/gate-policies.js.map +1 -0
- package/dist/routes/gate-policy-spec.d.ts +17 -0
- package/dist/routes/gate-policy-spec.d.ts.map +1 -0
- package/dist/routes/gate-policy-spec.js +192 -0
- package/dist/routes/gate-policy-spec.js.map +1 -0
- package/dist/routes/gate-policy-wire.d.ts +3 -0
- package/dist/routes/gate-policy-wire.d.ts.map +1 -0
- package/dist/routes/gate-policy-wire.js +18 -0
- package/dist/routes/gate-policy-wire.js.map +1 -0
- package/dist/routes/guardrails.d.ts.map +1 -1
- package/dist/routes/guardrails.js +4 -0
- package/dist/routes/guardrails.js.map +1 -1
- package/dist/routes/hierarchy-errors.d.ts +21 -0
- package/dist/routes/hierarchy-errors.d.ts.map +1 -1
- package/dist/routes/hierarchy-errors.js +21 -0
- package/dist/routes/hierarchy-errors.js.map +1 -1
- package/dist/routes/judged-suites.js +7 -0
- package/dist/routes/judged-suites.js.map +1 -1
- package/dist/routes/judgments.d.ts +4 -1
- package/dist/routes/judgments.d.ts.map +1 -1
- package/dist/routes/judgments.js +92 -19
- package/dist/routes/judgments.js.map +1 -1
- package/dist/routes/live-scope-wire.d.ts +32 -0
- package/dist/routes/live-scope-wire.d.ts.map +1 -0
- package/dist/routes/live-scope-wire.js +71 -0
- package/dist/routes/live-scope-wire.js.map +1 -0
- package/dist/routes/policies.d.ts +6 -3
- package/dist/routes/policies.d.ts.map +1 -1
- package/dist/routes/policies.js +55 -3
- package/dist/routes/policies.js.map +1 -1
- package/dist/routes/projects.d.ts.map +1 -1
- package/dist/routes/projects.js +59 -9
- package/dist/routes/projects.js.map +1 -1
- package/dist/routes/retention.d.ts.map +1 -1
- package/dist/routes/retention.js +18 -2
- package/dist/routes/retention.js.map +1 -1
- package/dist/routes/reviewers.d.ts +8 -1
- package/dist/routes/reviewers.d.ts.map +1 -1
- package/dist/routes/reviewers.js +20 -3
- package/dist/routes/reviewers.js.map +1 -1
- package/dist/routes/runs.d.ts.map +1 -1
- package/dist/routes/runs.js +23 -13
- package/dist/routes/runs.js.map +1 -1
- package/dist/routes/secrets.d.ts.map +1 -1
- package/dist/routes/secrets.js +2 -0
- package/dist/routes/secrets.js.map +1 -1
- package/dist/routes/segments.d.ts +17 -0
- package/dist/routes/segments.d.ts.map +1 -0
- package/dist/routes/segments.js +68 -0
- package/dist/routes/segments.js.map +1 -0
- package/dist/routes/teams.d.ts.map +1 -1
- package/dist/routes/teams.js +53 -8
- package/dist/routes/teams.js.map +1 -1
- package/dist/routes/tools.d.ts.map +1 -1
- package/dist/routes/tools.js +4 -0
- package/dist/routes/tools.js.map +1 -1
- package/dist/routes/uuid-param.d.ts +11 -0
- package/dist/routes/uuid-param.d.ts.map +1 -0
- package/dist/routes/uuid-param.js +19 -0
- package/dist/routes/uuid-param.js.map +1 -0
- package/dist/tool-binding.d.ts +8 -0
- package/dist/tool-binding.d.ts.map +1 -1
- package/openapi.json +6484 -2402
- package/package.json +21 -21
- package/src/agent-binding.ts +21 -2
- package/src/agent-pins.ts +3 -1
- package/src/app.ts +47 -4
- package/src/deploy-versions.ts +12 -15
- package/src/derive-agent-version.ts +12 -1
- package/src/errors.ts +26 -0
- package/src/eval-case-binding.ts +7 -0
- package/src/eval-run-binding.ts +31 -3
- package/src/eval-run-dispatcher.ts +3 -0
- package/src/flow-binding.ts +9 -0
- package/src/flow-pins.ts +49 -9
- package/src/gate-policy-binding.ts +171 -0
- package/src/gate.ts +467 -0
- package/src/guardrail-binding.ts +9 -0
- package/src/handler-binding.ts +17 -2
- package/src/index.ts +47 -1
- package/src/judged-dispatcher.ts +100 -9
- package/src/judgment-binding.ts +62 -0
- package/src/live-version-binding.ts +237 -0
- package/src/middleware/authorize.ts +18 -2
- package/src/middleware/project-ref.ts +88 -0
- package/src/openapi/generate.ts +5 -0
- package/src/openapi/operations.ts +664 -27
- package/src/openapi/schemas.ts +1175 -33
- package/src/publish-refused.ts +31 -0
- package/src/registry-read-only.ts +43 -0
- package/src/retention-binding.ts +30 -0
- package/src/routes/agent-releases.ts +581 -0
- package/src/routes/agents.ts +53 -3
- package/src/routes/approvals.ts +36 -8
- package/src/routes/audit.ts +10 -0
- package/src/routes/auth.ts +1 -1
- package/src/routes/blocks.ts +39 -14
- package/src/routes/conversations.ts +31 -3
- package/src/routes/deployments.ts +75 -5
- package/src/routes/env.ts +1 -0
- package/src/routes/eval-comparison.ts +59 -11
- package/src/routes/eval-runs.ts +46 -3
- package/src/routes/eval-versions.ts +110 -0
- package/src/routes/flows.ts +34 -11
- package/src/routes/gate-policies.ts +226 -0
- package/src/routes/gate-policy-spec.ts +229 -0
- package/src/routes/gate-policy-wire.ts +20 -0
- package/src/routes/guardrails.ts +7 -0
- package/src/routes/hierarchy-errors.ts +23 -0
- package/src/routes/judged-suites.ts +5 -0
- package/src/routes/judgments.ts +107 -23
- package/src/routes/live-scope-wire.ts +90 -0
- package/src/routes/policies.ts +61 -3
- package/src/routes/projects.ts +72 -9
- package/src/routes/retention.ts +21 -2
- package/src/routes/reviewers.ts +23 -4
- package/src/routes/runs.ts +34 -20
- package/src/routes/secrets.ts +2 -0
- package/src/routes/segments.ts +75 -0
- package/src/routes/teams.ts +66 -8
- package/src/routes/tools.ts +7 -0
- package/src/routes/uuid-param.ts +28 -0
- package/src/tool-binding.ts +9 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { Context, Hono } from 'hono';
|
|
5
|
+
|
|
6
|
+
import type { AgentId } from '@kindgi/agents';
|
|
7
|
+
import type { Cursor, LiveScope, OrgId, ProjectId, RunId, Semver, TenantId } from '@kindgi/types';
|
|
8
|
+
|
|
9
|
+
import type { ProjectBinding } from '@kindgi/platform';
|
|
10
|
+
|
|
11
|
+
import type { AgentRegistryBinding } from '../agent-binding.js';
|
|
12
|
+
import { statusFor, toWireError } from '../errors.js';
|
|
13
|
+
import type { EvalRun, EvalRunBinding } from '../eval-run-binding.js';
|
|
14
|
+
import type { GatePolicy } from '../gate-policy-binding.js';
|
|
15
|
+
import { type GateResult, evaluateGate } from '../gate.js';
|
|
16
|
+
import type { JudgedComparisonSummary } from '../judged-dispatcher.js';
|
|
17
|
+
import type {
|
|
18
|
+
AgentReleaseBindings,
|
|
19
|
+
LiveResolveInput,
|
|
20
|
+
Promotion,
|
|
21
|
+
PromotionActor,
|
|
22
|
+
} from '../live-version-binding.js';
|
|
23
|
+
import type { AppEnv } from '../types.js';
|
|
24
|
+
import { serializeGatePolicy } from './gate-policy-wire.js';
|
|
25
|
+
import { liveScopeToWire, parseLiveScopeBody } from './live-scope-wire.js';
|
|
26
|
+
import { clampLimit } from './pagination.js';
|
|
27
|
+
import { parseSegmentsQuery } from './segments.js';
|
|
28
|
+
|
|
29
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
30
|
+
|
|
31
|
+
/** The routes that change what's live: authorized as `promote` on the agent, not `admin`. */
|
|
32
|
+
export function isPromotionWrite(method: string, path: string): boolean {
|
|
33
|
+
return method === 'POST' && /\/(promotions|live\/rollback|live\/unpin)$/.test(path);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `POST …/promotions/check` changes nothing: `read` on the agent. */
|
|
37
|
+
export function isPromotionCheck(method: string, path: string): boolean {
|
|
38
|
+
return method === 'POST' && path.endsWith('/promotions/check');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What the gate reads besides the releases: comparisons, and a project's org. */
|
|
42
|
+
export interface AgentReleaseGateDeps {
|
|
43
|
+
readonly evalRuns?: EvalRunBinding;
|
|
44
|
+
readonly projects?: ProjectBinding;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Live versions of an agent per scope, and the promotions that set them
|
|
49
|
+
* (evals step 4):
|
|
50
|
+
* GET /:agentId/live the version a run would use for a project and segment path
|
|
51
|
+
* GET /:agentId/live-versions every pin
|
|
52
|
+
* POST /:agentId/promotions make a version live for a scope, through its gate
|
|
53
|
+
* (201 promoted, 202 waiting for approval, 422 gate-failed)
|
|
54
|
+
* POST /:agentId/promotions/check what the gate would say, writing nothing
|
|
55
|
+
* GET /:agentId/gate-policy the gate policy that applies to a scope
|
|
56
|
+
* GET /:agentId/promotions[/:id] the history
|
|
57
|
+
* POST /:agentId/live/rollback back to the scope's previous live version
|
|
58
|
+
* POST /:agentId/live/unpin remove the scope's pin (it falls back to the scope above)
|
|
59
|
+
*/
|
|
60
|
+
export function mountAgentReleaseRoutes(
|
|
61
|
+
r: Hono<AppEnv>,
|
|
62
|
+
registry: AgentRegistryBinding,
|
|
63
|
+
releases: AgentReleaseBindings,
|
|
64
|
+
deps: AgentReleaseGateDeps = {},
|
|
65
|
+
): void {
|
|
66
|
+
r.get('/:agentId/live', async (c) => {
|
|
67
|
+
const requestId = c.get('requestId');
|
|
68
|
+
const tenantId = c.get('tenantId') as TenantId;
|
|
69
|
+
const agentId = c.req.param('agentId');
|
|
70
|
+
const projectId = c.req.query('projectId');
|
|
71
|
+
if (projectId !== undefined && !UUID_RE.test(projectId)) {
|
|
72
|
+
c.status(statusFor('bad-input') as never);
|
|
73
|
+
return c.json(
|
|
74
|
+
toWireError(
|
|
75
|
+
{ code: 'bad-input', message: '`projectId` must be a project id (a UUID)' },
|
|
76
|
+
requestId,
|
|
77
|
+
),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const segments = parseSegmentsQuery(c.req.queries('segment') ?? []);
|
|
81
|
+
if (segments.kind === 'err') {
|
|
82
|
+
c.status(statusFor('bad-input') as never);
|
|
83
|
+
return c.json(toWireError({ code: 'bad-input', message: segments.message }, requestId));
|
|
84
|
+
}
|
|
85
|
+
if (segments.segments !== undefined && projectId === undefined) {
|
|
86
|
+
c.status(statusFor('bad-input') as never);
|
|
87
|
+
return c.json(
|
|
88
|
+
toWireError(
|
|
89
|
+
{ code: 'bad-input', message: 'A `segment` path needs a `projectId`' },
|
|
90
|
+
requestId,
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const resolved = await releases.live.resolve({
|
|
95
|
+
tenantId,
|
|
96
|
+
agentId,
|
|
97
|
+
...(projectId !== undefined && { projectId: projectId as ProjectId }),
|
|
98
|
+
...(segments.segments !== undefined && { segments: segments.segments }),
|
|
99
|
+
});
|
|
100
|
+
if (resolved !== null) {
|
|
101
|
+
return c.json({
|
|
102
|
+
agentId,
|
|
103
|
+
version: resolved.version as unknown as string,
|
|
104
|
+
via: 'live',
|
|
105
|
+
liveScope: liveScopeToWire(resolved.scope),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
// Nothing pinned on the way up: what a run gets is the latest registered version.
|
|
109
|
+
const latest = await registry.get({ tenantId, agentId: agentId as AgentId });
|
|
110
|
+
if (latest === null) {
|
|
111
|
+
c.status(statusFor('agent-not-found') as never);
|
|
112
|
+
return c.json(
|
|
113
|
+
toWireError(
|
|
114
|
+
{ code: 'agent-not-found', message: `No agent "${agentId}" is registered` },
|
|
115
|
+
requestId,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return c.json({ agentId, version: latest.version as unknown as string, via: 'latest' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
r.get('/:agentId/live-versions', async (c) => {
|
|
123
|
+
const tenantId = c.get('tenantId') as TenantId;
|
|
124
|
+
const pins = await releases.live.list({ tenantId, agentId: c.req.param('agentId') });
|
|
125
|
+
return c.json({
|
|
126
|
+
data: pins.map((p) => ({
|
|
127
|
+
agentId: p.agentId,
|
|
128
|
+
scope: liveScopeToWire(p.scope),
|
|
129
|
+
version: p.version as unknown as string,
|
|
130
|
+
promotionId: p.promotionId,
|
|
131
|
+
setAt: p.setAt as unknown as string,
|
|
132
|
+
})),
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
r.post('/:agentId/promotions', async (c) => {
|
|
137
|
+
const requestId = c.get('requestId');
|
|
138
|
+
const parsed = await readPromotionBody(c);
|
|
139
|
+
if (parsed.kind === 'err') return badInput(c, requestId, parsed.message);
|
|
140
|
+
const tenantId = c.get('tenantId') as TenantId;
|
|
141
|
+
const agentId = c.req.param('agentId');
|
|
142
|
+
const input = {
|
|
143
|
+
tenantId,
|
|
144
|
+
agentId,
|
|
145
|
+
version: parsed.value.version,
|
|
146
|
+
scope: parsed.value.scope,
|
|
147
|
+
requestedBy: actorOf(c),
|
|
148
|
+
...(parsed.value.reason !== undefined && { reason: parsed.value.reason }),
|
|
149
|
+
...(parsed.value.evalRunId !== undefined && { evalRunId: parsed.value.evalRunId }),
|
|
150
|
+
};
|
|
151
|
+
const policy = await policyFor(releases, tenantId, agentId, parsed.value.scope);
|
|
152
|
+
if (releases.promotions.request === undefined) {
|
|
153
|
+
if (policy !== null) {
|
|
154
|
+
c.status(statusFor('promotion-gate-unsupported') as never);
|
|
155
|
+
return c.json(
|
|
156
|
+
toWireError(
|
|
157
|
+
{
|
|
158
|
+
code: 'promotion-gate-unsupported',
|
|
159
|
+
message: `Gate policy ${policy.id} ${policy.version} applies to this promotion, and this deployment can't record a gated promotion yet.`,
|
|
160
|
+
},
|
|
161
|
+
requestId,
|
|
162
|
+
),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
// No gate, and a binding from before gates: promote as before.
|
|
166
|
+
const outcome = await releases.promotions.promote(input);
|
|
167
|
+
if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
|
|
168
|
+
c.status(201);
|
|
169
|
+
return c.json(serializePromotion(outcome.value));
|
|
170
|
+
}
|
|
171
|
+
const gate = await runGate(
|
|
172
|
+
registry,
|
|
173
|
+
releases,
|
|
174
|
+
deps,
|
|
175
|
+
{ ...parsed.value, tenantId, agentId },
|
|
176
|
+
policy,
|
|
177
|
+
);
|
|
178
|
+
if (gate.kind === 'err') return failed(c, requestId, gate.error);
|
|
179
|
+
const outcome = await releases.promotions.request({
|
|
180
|
+
...input,
|
|
181
|
+
gate: {
|
|
182
|
+
policy: policy === null ? null : { id: policy.id, version: policy.version },
|
|
183
|
+
checks: gate.result.checks,
|
|
184
|
+
passed: gate.result.passed,
|
|
185
|
+
...(gate.result.approval !== undefined && { approval: gate.result.approval }),
|
|
186
|
+
servingVersion: gate.servingVersion as Semver,
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
|
|
190
|
+
const promotion = outcome.value;
|
|
191
|
+
if (promotion.status === 'refused') {
|
|
192
|
+
const checks = promotion.checks ?? gate.result.checks;
|
|
193
|
+
c.status(statusFor('gate-failed') as never);
|
|
194
|
+
return c.json(
|
|
195
|
+
toWireError(
|
|
196
|
+
{
|
|
197
|
+
code: 'gate-failed',
|
|
198
|
+
message: `The gate refused ${agentId} ${parsed.value.version}: ${checks
|
|
199
|
+
.filter((check) => !check.passed)
|
|
200
|
+
.map((check) => check.message)
|
|
201
|
+
.join(' ')}`,
|
|
202
|
+
promotionId: promotion.id,
|
|
203
|
+
policy: promotion.policy ?? null,
|
|
204
|
+
checks,
|
|
205
|
+
},
|
|
206
|
+
requestId,
|
|
207
|
+
),
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
c.status(promotion.status === 'pending-approval' ? 202 : 201);
|
|
211
|
+
return c.json(serializePromotion(promotion));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
r.post('/:agentId/promotions/check', async (c) => {
|
|
215
|
+
const requestId = c.get('requestId');
|
|
216
|
+
const parsed = await readPromotionBody(c);
|
|
217
|
+
if (parsed.kind === 'err') return badInput(c, requestId, parsed.message);
|
|
218
|
+
const tenantId = c.get('tenantId') as TenantId;
|
|
219
|
+
const agentId = c.req.param('agentId');
|
|
220
|
+
const policy = await policyFor(releases, tenantId, agentId, parsed.value.scope);
|
|
221
|
+
const gate = await runGate(
|
|
222
|
+
registry,
|
|
223
|
+
releases,
|
|
224
|
+
deps,
|
|
225
|
+
{ ...parsed.value, tenantId, agentId },
|
|
226
|
+
policy,
|
|
227
|
+
);
|
|
228
|
+
if (gate.kind === 'err') return failed(c, requestId, gate.error);
|
|
229
|
+
const { result } = gate;
|
|
230
|
+
return c.json({
|
|
231
|
+
outcome: !result.passed
|
|
232
|
+
? 'gate-failed'
|
|
233
|
+
: result.approval !== undefined
|
|
234
|
+
? 'needs-approval'
|
|
235
|
+
: 'would-promote',
|
|
236
|
+
policy: policy === null ? null : { id: policy.id, version: policy.version },
|
|
237
|
+
checks: result.checks,
|
|
238
|
+
...(result.approval !== undefined && { approval: result.approval }),
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
r.get('/:agentId/gate-policy', async (c) => {
|
|
243
|
+
const requestId = c.get('requestId');
|
|
244
|
+
const scope = scopeFromQuery((n) => c.req.query(n), c.req.queries('segment') ?? []);
|
|
245
|
+
if (scope.kind === 'err') return badInput(c, requestId, scope.message);
|
|
246
|
+
if (scope.scope === undefined) {
|
|
247
|
+
return badInput(c, requestId, '`scopeKind` is required: the scope a promotion would be for');
|
|
248
|
+
}
|
|
249
|
+
const policy = await policyFor(
|
|
250
|
+
releases,
|
|
251
|
+
c.get('tenantId') as TenantId,
|
|
252
|
+
c.req.param('agentId'),
|
|
253
|
+
scope.scope,
|
|
254
|
+
);
|
|
255
|
+
return c.json({ policy: policy === null ? null : serializeGatePolicy(policy) });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
r.get('/:agentId/promotions', async (c) => {
|
|
259
|
+
const requestId = c.get('requestId');
|
|
260
|
+
const scope = scopeFromQuery((n) => c.req.query(n), c.req.queries('segment') ?? []);
|
|
261
|
+
if (scope.kind === 'err') return badInput(c, requestId, scope.message);
|
|
262
|
+
const cursor = c.req.query('cursor');
|
|
263
|
+
const page = await releases.promotions.list({
|
|
264
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
265
|
+
agentId: c.req.param('agentId'),
|
|
266
|
+
limit: clampLimit(c.req.query('limit')),
|
|
267
|
+
...(scope.scope !== undefined && { scope: scope.scope }),
|
|
268
|
+
...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
|
|
269
|
+
});
|
|
270
|
+
return c.json({
|
|
271
|
+
data: page.data.map(serializePromotion),
|
|
272
|
+
hasMore: page.nextCursor !== undefined,
|
|
273
|
+
...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
r.get('/:agentId/promotions/:promotionId', async (c) => {
|
|
278
|
+
const requestId = c.get('requestId');
|
|
279
|
+
const found = await releases.promotions.get(
|
|
280
|
+
c.get('tenantId') as TenantId,
|
|
281
|
+
c.req.param('promotionId'),
|
|
282
|
+
);
|
|
283
|
+
if (found === null || found.agentId !== c.req.param('agentId')) {
|
|
284
|
+
c.status(statusFor('promotion-not-found') as never);
|
|
285
|
+
return c.json(
|
|
286
|
+
toWireError(
|
|
287
|
+
{ code: 'promotion-not-found', message: 'No such promotion for this agent' },
|
|
288
|
+
requestId,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return c.json(serializePromotion(found));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
r.post('/:agentId/live/rollback', async (c) => {
|
|
296
|
+
const requestId = c.get('requestId');
|
|
297
|
+
const body = await readBody(c);
|
|
298
|
+
if (body === undefined) return badJson(c, requestId);
|
|
299
|
+
const scope = parseLiveScopeBody(body.scope);
|
|
300
|
+
if (scope.kind === 'err') return badInput(c, requestId, scope.message);
|
|
301
|
+
if (
|
|
302
|
+
body.toVersion !== undefined &&
|
|
303
|
+
(typeof body.toVersion !== 'string' || body.toVersion === '')
|
|
304
|
+
) {
|
|
305
|
+
return badInput(c, requestId, '`toVersion` must be a version when supplied');
|
|
306
|
+
}
|
|
307
|
+
const text = optionalText(body, ['reason']);
|
|
308
|
+
if (text.kind === 'err') return badInput(c, requestId, text.message);
|
|
309
|
+
const outcome = await releases.promotions.rollback({
|
|
310
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
311
|
+
agentId: c.req.param('agentId'),
|
|
312
|
+
scope: scope.scope,
|
|
313
|
+
requestedBy: actorOf(c),
|
|
314
|
+
...(typeof body.toVersion === 'string' && { toVersion: body.toVersion as Semver }),
|
|
315
|
+
...text.value,
|
|
316
|
+
});
|
|
317
|
+
if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
|
|
318
|
+
return c.json(serializePromotion(outcome.value));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
r.post('/:agentId/live/unpin', async (c) => {
|
|
322
|
+
const requestId = c.get('requestId');
|
|
323
|
+
const body = await readBody(c);
|
|
324
|
+
if (body === undefined) return badJson(c, requestId);
|
|
325
|
+
const scope = parseLiveScopeBody(body.scope);
|
|
326
|
+
if (scope.kind === 'err') return badInput(c, requestId, scope.message);
|
|
327
|
+
const text = optionalText(body, ['reason']);
|
|
328
|
+
if (text.kind === 'err') return badInput(c, requestId, text.message);
|
|
329
|
+
const outcome = await releases.promotions.unpin({
|
|
330
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
331
|
+
agentId: c.req.param('agentId'),
|
|
332
|
+
scope: scope.scope,
|
|
333
|
+
requestedBy: actorOf(c),
|
|
334
|
+
...text.value,
|
|
335
|
+
});
|
|
336
|
+
if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
|
|
337
|
+
return c.json(serializePromotion(outcome.value));
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
type Ctx = Context<AppEnv>;
|
|
342
|
+
|
|
343
|
+
async function readBody(c: Ctx): Promise<Record<string, unknown> | undefined> {
|
|
344
|
+
try {
|
|
345
|
+
const body: unknown = await c.req.json();
|
|
346
|
+
return body !== null && typeof body === 'object' && !Array.isArray(body)
|
|
347
|
+
? (body as Record<string, unknown>)
|
|
348
|
+
: undefined;
|
|
349
|
+
} catch {
|
|
350
|
+
return undefined;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function badJson(c: Ctx, requestId: string) {
|
|
355
|
+
return badInput(c, requestId, 'Request body must be a JSON object');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function badInput(c: Ctx, requestId: string, message: string) {
|
|
359
|
+
c.status(statusFor('bad-input') as never);
|
|
360
|
+
return c.json(toWireError({ code: 'bad-input', message }, requestId));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function failed(c: Ctx, requestId: string, error: { code: string; message: string }) {
|
|
364
|
+
c.status(statusFor(error.code) as never);
|
|
365
|
+
return c.json(toWireError(error, requestId));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Optional short strings (`reason`, `evalRunId`): absent, or a non-empty string. */
|
|
369
|
+
function optionalText(
|
|
370
|
+
body: Readonly<Record<string, unknown>>,
|
|
371
|
+
fields: readonly ('reason' | 'evalRunId')[],
|
|
372
|
+
):
|
|
373
|
+
| { kind: 'ok'; value: { reason?: string; evalRunId?: string } }
|
|
374
|
+
| { kind: 'err'; message: string } {
|
|
375
|
+
const value: { reason?: string; evalRunId?: string } = {};
|
|
376
|
+
for (const field of fields) {
|
|
377
|
+
const v = body[field];
|
|
378
|
+
if (v === undefined) continue;
|
|
379
|
+
if (typeof v !== 'string' || v.trim() === '' || v.length > 2000) {
|
|
380
|
+
return { kind: 'err', message: `\`${field}\` must be a non-empty string when supplied` };
|
|
381
|
+
}
|
|
382
|
+
value[field] = v;
|
|
383
|
+
}
|
|
384
|
+
return { kind: 'ok', value };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The promotion body: `{version, scope, evalRunId?, reason?}`. */
|
|
388
|
+
async function readPromotionBody(c: Ctx): Promise<
|
|
389
|
+
| {
|
|
390
|
+
kind: 'ok';
|
|
391
|
+
value: { version: Semver; scope: LiveScope; evalRunId?: string; reason?: string };
|
|
392
|
+
}
|
|
393
|
+
| { kind: 'err'; message: string }
|
|
394
|
+
> {
|
|
395
|
+
const body = await readBody(c);
|
|
396
|
+
if (body === undefined) return { kind: 'err', message: 'Request body must be a JSON object' };
|
|
397
|
+
const scope = parseLiveScopeBody(body.scope);
|
|
398
|
+
if (scope.kind === 'err') return scope;
|
|
399
|
+
if (typeof body.version !== 'string' || body.version.length === 0) {
|
|
400
|
+
return { kind: 'err', message: '`version` is required: the agent version to make live' };
|
|
401
|
+
}
|
|
402
|
+
const text = optionalText(body, ['reason', 'evalRunId']);
|
|
403
|
+
if (text.kind === 'err') return text;
|
|
404
|
+
return {
|
|
405
|
+
kind: 'ok',
|
|
406
|
+
value: { version: body.version as Semver, scope: scope.scope, ...text.value },
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The gate policy for a promotion of `agentId` for `scope`; `null` when none applies. */
|
|
411
|
+
async function policyFor(
|
|
412
|
+
releases: AgentReleaseBindings,
|
|
413
|
+
tenantId: TenantId,
|
|
414
|
+
agentId: string,
|
|
415
|
+
scope: LiveScope,
|
|
416
|
+
): Promise<GatePolicy | null> {
|
|
417
|
+
return releases.gatePolicies === undefined
|
|
418
|
+
? null
|
|
419
|
+
: releases.gatePolicies.resolve({ tenantId, agentId, scope });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The scope's coordinates, as a run in it would resolve its version. */
|
|
423
|
+
function coordinatesOf(scope: LiveScope): Omit<LiveResolveInput, 'tenantId' | 'agentId'> {
|
|
424
|
+
switch (scope.kind) {
|
|
425
|
+
case 'tenant':
|
|
426
|
+
return {};
|
|
427
|
+
case 'org':
|
|
428
|
+
return { orgId: scope.orgId };
|
|
429
|
+
case 'project':
|
|
430
|
+
return { projectId: scope.projectId };
|
|
431
|
+
case 'segment':
|
|
432
|
+
return { projectId: scope.projectId, segments: scope.path };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** A finished comparison's summary, or `null` for any other eval run. */
|
|
437
|
+
function summaryOf(run: EvalRun): JudgedComparisonSummary | null {
|
|
438
|
+
const summary = run.result?.summary;
|
|
439
|
+
return run.kind === 'judged' && summary !== null && typeof summary === 'object'
|
|
440
|
+
? (summary as JudgedComparisonSummary)
|
|
441
|
+
: null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The gate for a promotion request: what serves the scope now, the
|
|
446
|
+
* comparison it names, and the policy's checks against them. With no
|
|
447
|
+
* policy there's nothing to check.
|
|
448
|
+
*/
|
|
449
|
+
async function runGate(
|
|
450
|
+
registry: AgentRegistryBinding,
|
|
451
|
+
releases: AgentReleaseBindings,
|
|
452
|
+
deps: AgentReleaseGateDeps,
|
|
453
|
+
req: {
|
|
454
|
+
readonly tenantId: TenantId;
|
|
455
|
+
readonly agentId: string;
|
|
456
|
+
readonly version: Semver;
|
|
457
|
+
readonly scope: LiveScope;
|
|
458
|
+
readonly evalRunId?: string;
|
|
459
|
+
},
|
|
460
|
+
policy: GatePolicy | null,
|
|
461
|
+
): Promise<
|
|
462
|
+
| { kind: 'ok'; result: GateResult; servingVersion: string }
|
|
463
|
+
| { kind: 'err'; error: { code: string; message: string } }
|
|
464
|
+
> {
|
|
465
|
+
const { tenantId, agentId } = req;
|
|
466
|
+
const err = (code: string, message: string) => ({
|
|
467
|
+
kind: 'err' as const,
|
|
468
|
+
error: { code, message },
|
|
469
|
+
});
|
|
470
|
+
const promoted = await registry.getVersion({
|
|
471
|
+
tenantId,
|
|
472
|
+
agentId: agentId as AgentId,
|
|
473
|
+
version: req.version,
|
|
474
|
+
});
|
|
475
|
+
if (promoted === null || promoted.unregisteredAt !== undefined) {
|
|
476
|
+
return err('agent-version-not-found', `${agentId} has no active version ${req.version}`);
|
|
477
|
+
}
|
|
478
|
+
const live = await releases.live.resolve({ tenantId, agentId, ...coordinatesOf(req.scope) });
|
|
479
|
+
let servingVersion = live?.version as unknown as string | undefined;
|
|
480
|
+
if (servingVersion === undefined) {
|
|
481
|
+
const latest = await registry.get({ tenantId, agentId: agentId as AgentId });
|
|
482
|
+
if (latest === null) return err('agent-not-found', `No agent "${agentId}" is registered`);
|
|
483
|
+
servingVersion = latest.version as unknown as string;
|
|
484
|
+
}
|
|
485
|
+
if (policy === null) return { kind: 'ok', result: { checks: [], passed: true }, servingVersion };
|
|
486
|
+
|
|
487
|
+
let summary: JudgedComparisonSummary | null = null;
|
|
488
|
+
if (req.evalRunId !== undefined) {
|
|
489
|
+
const run =
|
|
490
|
+
deps.evalRuns === undefined
|
|
491
|
+
? null
|
|
492
|
+
: await deps.evalRuns.get({ tenantId, runId: req.evalRunId as RunId });
|
|
493
|
+
if (run === null) return err('eval-run-not-found', `No eval run ${req.evalRunId}`);
|
|
494
|
+
summary = summaryOf(run);
|
|
495
|
+
if (summary === null) {
|
|
496
|
+
return err(
|
|
497
|
+
'bad-input',
|
|
498
|
+
`Eval run ${req.evalRunId} isn't a finished comparison: it has no comparison summary`,
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
let summaryProjectOrgId: string | undefined;
|
|
503
|
+
const judgedIn = summary?.scope.projectId;
|
|
504
|
+
if (req.scope.kind === 'org' && judgedIn !== undefined && deps.projects !== undefined) {
|
|
505
|
+
const project = await deps.projects.get(tenantId, judgedIn as ProjectId);
|
|
506
|
+
summaryProjectOrgId = project?.orgId as unknown as string | undefined;
|
|
507
|
+
}
|
|
508
|
+
const result = evaluateGate({
|
|
509
|
+
spec: policy.spec,
|
|
510
|
+
promotion: {
|
|
511
|
+
agentId,
|
|
512
|
+
version: req.version as unknown as string,
|
|
513
|
+
pinsDigest: promoted.pinsDigest ?? null,
|
|
514
|
+
scope: req.scope,
|
|
515
|
+
},
|
|
516
|
+
summary,
|
|
517
|
+
servingVersion,
|
|
518
|
+
...(summaryProjectOrgId !== undefined && { summaryProjectOrgId }),
|
|
519
|
+
now: new Date(),
|
|
520
|
+
});
|
|
521
|
+
return { kind: 'ok', result, servingVersion };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Who asked, from the request's principal. */
|
|
525
|
+
function actorOf(c: Ctx): PromotionActor {
|
|
526
|
+
const actor = c.get('principal')?.actor;
|
|
527
|
+
if (actor === undefined) return { kind: 'service', id: 'unknown' };
|
|
528
|
+
return { kind: actor.kind === 'user' ? 'user' : 'service', id: actor.id };
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** `?scopeKind=tenant|org|project|segment&scopeId=…&segment=key:value` for the history filter. */
|
|
532
|
+
export function scopeFromQuery(
|
|
533
|
+
query: (name: string) => string | undefined,
|
|
534
|
+
segmentValues: readonly string[],
|
|
535
|
+
): { kind: 'ok'; scope?: LiveScope } | { kind: 'err'; message: string } {
|
|
536
|
+
const kind = query('scopeKind');
|
|
537
|
+
const id = query('scopeId');
|
|
538
|
+
if (kind === undefined || kind === '') {
|
|
539
|
+
return id === undefined
|
|
540
|
+
? { kind: 'ok' }
|
|
541
|
+
: { kind: 'err', message: '`scopeId` needs `scopeKind`' };
|
|
542
|
+
}
|
|
543
|
+
if (kind === 'tenant') return { kind: 'ok', scope: { kind: 'tenant' } };
|
|
544
|
+
if (id === undefined || !UUID_RE.test(id)) {
|
|
545
|
+
return { kind: 'err', message: '`scopeId` must be an org or project id (a UUID)' };
|
|
546
|
+
}
|
|
547
|
+
if (kind === 'org') return { kind: 'ok', scope: { kind: 'org', orgId: id as OrgId } };
|
|
548
|
+
if (kind === 'project')
|
|
549
|
+
return { kind: 'ok', scope: { kind: 'project', projectId: id as ProjectId } };
|
|
550
|
+
if (kind === 'segment') {
|
|
551
|
+
const path = parseSegmentsQuery(segmentValues);
|
|
552
|
+
if (path.kind === 'err') return path;
|
|
553
|
+
if (path.segments === undefined)
|
|
554
|
+
return { kind: 'err', message: 'A segment scope needs `segment`' };
|
|
555
|
+
return {
|
|
556
|
+
kind: 'ok',
|
|
557
|
+
scope: { kind: 'segment', projectId: id as ProjectId, path: path.segments },
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
return { kind: 'err', message: '`scopeKind` must be one of tenant, org, project, segment' };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
export function serializePromotion(p: Promotion): Record<string, unknown> {
|
|
564
|
+
return {
|
|
565
|
+
id: p.id,
|
|
566
|
+
agentId: p.agentId,
|
|
567
|
+
scope: liveScopeToWire(p.scope),
|
|
568
|
+
action: p.action,
|
|
569
|
+
fromVersion: p.fromVersion as unknown as string | null,
|
|
570
|
+
toVersion: p.toVersion as unknown as string | null,
|
|
571
|
+
requestedBy: p.requestedBy,
|
|
572
|
+
...(p.reason !== undefined && { reason: p.reason }),
|
|
573
|
+
...(p.evalRunId !== undefined && { evalRunId: p.evalRunId }),
|
|
574
|
+
createdAt: p.createdAt as unknown as string,
|
|
575
|
+
...(p.status !== undefined && { status: p.status }),
|
|
576
|
+
...(p.policy !== undefined && { policy: p.policy }),
|
|
577
|
+
...(p.checks !== undefined && { checks: p.checks }),
|
|
578
|
+
...(p.approvalId !== undefined && { approvalId: p.approvalId }),
|
|
579
|
+
...(p.resolvedAt !== undefined && { resolvedAt: p.resolvedAt as unknown as string }),
|
|
580
|
+
};
|
|
581
|
+
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -23,9 +23,18 @@ import {
|
|
|
23
23
|
nextFreeAgentVersion,
|
|
24
24
|
} from '../derive-agent-version.js';
|
|
25
25
|
import { statusFor, toWireError } from '../errors.js';
|
|
26
|
+
import type { AgentReleaseBindings } from '../live-version-binding.js';
|
|
26
27
|
import type { Authorizer } from '../middleware/authorize.js';
|
|
28
|
+
import { refuseWritesWhenReadOnly } from '../registry-read-only.js';
|
|
27
29
|
import type { ToolRegistryBinding } from '../tool-binding.js';
|
|
28
30
|
import type { AppEnv } from '../types.js';
|
|
31
|
+
import {
|
|
32
|
+
type AgentReleaseGateDeps,
|
|
33
|
+
isPromotionCheck,
|
|
34
|
+
isPromotionWrite,
|
|
35
|
+
mountAgentReleaseRoutes,
|
|
36
|
+
} from './agent-releases.js';
|
|
37
|
+
import { liveScopeToWire } from './live-scope-wire.js';
|
|
29
38
|
import { clampLimit } from './pagination.js';
|
|
30
39
|
import { parseScopeParams } from './scope-params.js';
|
|
31
40
|
|
|
@@ -51,8 +60,18 @@ export function agentsRouter(
|
|
|
51
60
|
authorizer?: Authorizer,
|
|
52
61
|
toolRegistry?: ToolRegistryBinding,
|
|
53
62
|
blockRegistry?: BlockRegistryBinding,
|
|
63
|
+
/** Live versions per scope and promotions (evals step 4); absent → those routes aren't mounted. */
|
|
64
|
+
releases?: AgentReleaseBindings,
|
|
65
|
+
/** What a promotion's gate reads besides the releases (evals step 4b). */
|
|
66
|
+
gateDeps?: AgentReleaseGateDeps,
|
|
54
67
|
): Hono<AppEnv> {
|
|
55
68
|
const r = new Hono<AppEnv>();
|
|
69
|
+
// A read-only registry (under `kindgi dev`, the pack's files) refuses
|
|
70
|
+
// every write before anything else runs.
|
|
71
|
+
r.use(
|
|
72
|
+
'*',
|
|
73
|
+
refuseWritesWhenReadOnly(() => binding.readOnly),
|
|
74
|
+
);
|
|
56
75
|
|
|
57
76
|
// Authorization — check the resource directly (ref('agent', businessId));
|
|
58
77
|
// permissions cascade from the parent project because the
|
|
@@ -64,6 +83,9 @@ export function agentsRouter(
|
|
|
64
83
|
// POST /:agentId/versions → publish on the agent (derive)
|
|
65
84
|
// POST /:agentId/versions/:version/unregister → admin on the agent
|
|
66
85
|
// POST /:agentId/versions/:version/reinstate → admin on the agent
|
|
86
|
+
// POST /:agentId/promotions, /live/rollback, /live/unpin → promote on the agent
|
|
87
|
+
// POST /:agentId/promotions/check → read on the agent (changes nothing)
|
|
88
|
+
// GET /:agentId/live, /live-versions, /promotions[/…] → read on the agent
|
|
67
89
|
// GET / (list) → tenant-scoped fetch;
|
|
68
90
|
// with a project scope
|
|
69
91
|
// (?scopeKind=project&scopeId=),
|
|
@@ -101,9 +123,16 @@ export function agentsRouter(
|
|
|
101
123
|
});
|
|
102
124
|
r.use('/:agentId/*', async (c, next) => {
|
|
103
125
|
// Deriving a version (POST …/versions) is `publish` on the agent;
|
|
104
|
-
//
|
|
126
|
+
// changing what's live is its own permission (`promote`, granted to
|
|
127
|
+
// the agent's admins); unregister and reinstate are `admin`.
|
|
105
128
|
const action =
|
|
106
|
-
c.req.method === 'GET'
|
|
129
|
+
c.req.method === 'GET' || isPromotionCheck(c.req.method, c.req.path)
|
|
130
|
+
? 'read'
|
|
131
|
+
: isPromotionWrite(c.req.method, c.req.path)
|
|
132
|
+
? 'promote'
|
|
133
|
+
: c.req.path.endsWith('/versions')
|
|
134
|
+
? 'publish'
|
|
135
|
+
: 'admin';
|
|
107
136
|
const agentId = c.req.param('agentId') ?? '';
|
|
108
137
|
const mw = authorizer.authorize(action, () => ref('agent', agentId));
|
|
109
138
|
return mw(c, next);
|
|
@@ -323,7 +352,7 @@ export function agentsRouter(
|
|
|
323
352
|
toWireError(
|
|
324
353
|
{
|
|
325
354
|
code: 'validation-failed',
|
|
326
|
-
message: `Agent "${defined.value.id as unknown as string}" uses tool versions
|
|
355
|
+
message: `Agent "${defined.value.id as unknown as string}" uses tool or data-block versions it can't pin (${resolved.issues.length} issue${resolved.issues.length === 1 ? '' : 's'})`,
|
|
327
356
|
issues: resolved.issues as unknown as Record<string, unknown>[],
|
|
328
357
|
},
|
|
329
358
|
requestId,
|
|
@@ -427,6 +456,23 @@ export function agentsRouter(
|
|
|
427
456
|
const version = c.req.param('version') as Semver;
|
|
428
457
|
|
|
429
458
|
const outcome = await binding.unregister({ tenantId, agentId, version });
|
|
459
|
+
if (!outcome.unregistered && outcome.live !== undefined && outcome.live.length > 0) {
|
|
460
|
+
c.status(statusFor('agent-version-live') as never);
|
|
461
|
+
return c.json(
|
|
462
|
+
toWireError(
|
|
463
|
+
{
|
|
464
|
+
code: 'agent-version-live',
|
|
465
|
+
message: `${agentId as unknown as string} ${version as unknown as string} is live in ${
|
|
466
|
+
outcome.live.length === 1 ? 'a scope' : `${outcome.live.length} scopes`
|
|
467
|
+
}: roll back, unpin, or promote another version there first, then unregister it.`,
|
|
468
|
+
agentId: agentId as unknown as string,
|
|
469
|
+
version: version as unknown as string,
|
|
470
|
+
scopes: outcome.live.map(liveScopeToWire),
|
|
471
|
+
},
|
|
472
|
+
requestId,
|
|
473
|
+
),
|
|
474
|
+
);
|
|
475
|
+
}
|
|
430
476
|
if (!outcome.unregistered) {
|
|
431
477
|
c.status(statusFor('agent-not-found') as never);
|
|
432
478
|
return c.json(
|
|
@@ -482,6 +528,8 @@ export function agentsRouter(
|
|
|
482
528
|
});
|
|
483
529
|
});
|
|
484
530
|
|
|
531
|
+
if (releases !== undefined) mountAgentReleaseRoutes(r, binding, releases, gateDeps);
|
|
532
|
+
|
|
485
533
|
return r;
|
|
486
534
|
}
|
|
487
535
|
|
|
@@ -592,6 +640,8 @@ function derived(
|
|
|
592
640
|
case 'ok':
|
|
593
641
|
c.status(201);
|
|
594
642
|
return c.json(serializeAgent(outcome.agent));
|
|
643
|
+
case 'reused':
|
|
644
|
+
return c.json(serializeAgent(outcome.agent));
|
|
595
645
|
case 'not-found':
|
|
596
646
|
return fail('agent-not-found', {
|
|
597
647
|
code: 'agent-not-found',
|