@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,110 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A flow candidate's `versions`, checked against the flow version when
|
|
6
|
+
* the comparison starts: each id must be an agent or tool the flow uses,
|
|
7
|
+
* at a published version. Checked once, here, before any run exists: the
|
|
8
|
+
* runs apply them as given, on resume too.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AgentId } from '@kindgi/agents';
|
|
12
|
+
import { type FlowVersionOverrides, overridableRefs } from '@kindgi/flow';
|
|
13
|
+
import type { FlowId, Semver, TenantId, ToolId } from '@kindgi/types';
|
|
14
|
+
|
|
15
|
+
import type { AgentRegistryBinding } from '../agent-binding.js';
|
|
16
|
+
import type { FlowRegistryBinding } from '../flow-binding.js';
|
|
17
|
+
import type { ToolRegistryBinding } from '../tool-binding.js';
|
|
18
|
+
|
|
19
|
+
/** Where the check reads the flow version and the agents' and tools' versions. */
|
|
20
|
+
export interface FlowVersionsCheck {
|
|
21
|
+
readonly flows: FlowRegistryBinding;
|
|
22
|
+
readonly agents?: AgentRegistryBinding;
|
|
23
|
+
readonly tools?: ToolRegistryBinding;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface VersionsIssue {
|
|
27
|
+
readonly path: string;
|
|
28
|
+
readonly message: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A JSON Pointer token: `~` and `/` escaped. */
|
|
32
|
+
const token = (id: string): string => id.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
33
|
+
|
|
34
|
+
async function agentIssues(
|
|
35
|
+
check: FlowVersionsCheck,
|
|
36
|
+
tenantId: TenantId,
|
|
37
|
+
label: string,
|
|
38
|
+
used: ReadonlySet<string>,
|
|
39
|
+
overrides: Readonly<Record<string, string>>,
|
|
40
|
+
): Promise<VersionsIssue[]> {
|
|
41
|
+
const issues: VersionsIssue[] = [];
|
|
42
|
+
for (const [id, version] of Object.entries(overrides)) {
|
|
43
|
+
const path = `/versions/agents/${token(id)}`;
|
|
44
|
+
if (!used.has(id)) {
|
|
45
|
+
issues.push({ path, message: `flow ${label} doesn't use agent ${id}` });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const found = await check.agents?.getVersion({
|
|
49
|
+
tenantId,
|
|
50
|
+
agentId: id as AgentId,
|
|
51
|
+
version: version as Semver,
|
|
52
|
+
});
|
|
53
|
+
if (found === null) issues.push({ path, message: `agent ${id} has no version ${version}` });
|
|
54
|
+
else if (found?.unregisteredAt !== undefined) {
|
|
55
|
+
issues.push({ path, message: `agent ${id} ${version} is unregistered` });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return issues;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function toolIssues(
|
|
62
|
+
check: FlowVersionsCheck,
|
|
63
|
+
tenantId: TenantId,
|
|
64
|
+
label: string,
|
|
65
|
+
used: ReadonlySet<string>,
|
|
66
|
+
overrides: Readonly<Record<string, string>>,
|
|
67
|
+
): Promise<VersionsIssue[]> {
|
|
68
|
+
const issues: VersionsIssue[] = [];
|
|
69
|
+
for (const [id, version] of Object.entries(overrides)) {
|
|
70
|
+
const path = `/versions/tools/${token(id)}`;
|
|
71
|
+
if (!used.has(id)) {
|
|
72
|
+
issues.push({ path, message: `flow ${label} doesn't use tool ${id}` });
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const found = await check.tools?.getVersion({
|
|
76
|
+
tenantId,
|
|
77
|
+
toolId: id as ToolId,
|
|
78
|
+
version: version as Semver,
|
|
79
|
+
});
|
|
80
|
+
if (found === null) issues.push({ path, message: `tool ${id} has no version ${version}` });
|
|
81
|
+
}
|
|
82
|
+
return issues;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What's wrong with a flow candidate's `versions`, one issue per id; none
|
|
87
|
+
* when they fit. Without an agent or tool registry, those versions'
|
|
88
|
+
* existence isn't checked (the runs refuse an unpublished one).
|
|
89
|
+
*/
|
|
90
|
+
export async function checkFlowVersions(
|
|
91
|
+
check: FlowVersionsCheck,
|
|
92
|
+
tenantId: TenantId,
|
|
93
|
+
flowRef: { readonly flowId: string; readonly version: string },
|
|
94
|
+
versions: FlowVersionOverrides,
|
|
95
|
+
): Promise<readonly VersionsIssue[]> {
|
|
96
|
+
const label = `${flowRef.flowId} ${flowRef.version}`;
|
|
97
|
+
const flow = await check.flows.getVersion({
|
|
98
|
+
tenantId,
|
|
99
|
+
flowId: flowRef.flowId as FlowId,
|
|
100
|
+
version: flowRef.version,
|
|
101
|
+
});
|
|
102
|
+
if (flow === null) {
|
|
103
|
+
return [{ path: '/flowRef/version', message: `flow ${label} isn't registered` }];
|
|
104
|
+
}
|
|
105
|
+
const refs = overridableRefs(flow);
|
|
106
|
+
return [
|
|
107
|
+
...(await agentIssues(check, tenantId, label, new Set(refs.agents), versions.agents ?? {})),
|
|
108
|
+
...(await toolIssues(check, tenantId, label, new Set(refs.tools), versions.tools ?? {})),
|
|
109
|
+
];
|
|
110
|
+
}
|
package/src/routes/flows.ts
CHANGED
|
@@ -12,7 +12,9 @@ import type { UnpinnableRef } from '../agent-pins.js';
|
|
|
12
12
|
import { statusFor, toWireError } from '../errors.js';
|
|
13
13
|
import type { FlowRegistryBinding, FlowVersionRecord } from '../flow-binding.js';
|
|
14
14
|
import { resolveFlowPins } from '../flow-pins.js';
|
|
15
|
+
import type { LiveVersionBinding } from '../live-version-binding.js';
|
|
15
16
|
import type { Authorizer } from '../middleware/authorize.js';
|
|
17
|
+
import { refuseWritesWhenReadOnly } from '../registry-read-only.js';
|
|
16
18
|
import type { ToolRegistryBinding } from '../tool-binding.js';
|
|
17
19
|
import type { AppEnv } from '../types.js';
|
|
18
20
|
import { clampLimit } from './pagination.js';
|
|
@@ -36,18 +38,26 @@ import { parseScopeParams } from './scope-params.js';
|
|
|
36
38
|
* `/v1/flows/:flowId/run` route is added here.
|
|
37
39
|
*
|
|
38
40
|
* With `pinning` (the tool and agent registries), a published version is
|
|
39
|
-
* pinned: each tool it runs
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* pins
|
|
41
|
+
* pinned: each tool it runs resolves once, at publish, to its latest
|
|
42
|
+
* version, and each agent it runs at no named version to what a run in
|
|
43
|
+
* the flow's project would get, its live version there with
|
|
44
|
+
* `pinning.live`, else its latest. Every run of that version uses those
|
|
45
|
+
* (`pins`, see `resolveFlowPins`); one with no published version refuses
|
|
46
|
+
* the publish. Without `pinning`, versions carry no pins and bind the
|
|
47
|
+
* latest versions per run.
|
|
44
48
|
*/
|
|
45
49
|
export function flowsRouter(
|
|
46
50
|
binding: FlowRegistryBinding,
|
|
47
51
|
authorizer?: Authorizer,
|
|
48
|
-
pinning?:
|
|
52
|
+
pinning?: FlowPinning,
|
|
49
53
|
): Hono<AppEnv> {
|
|
50
54
|
const r = new Hono<AppEnv>();
|
|
55
|
+
// A read-only registry (under `kindgi dev`, the pack's files) refuses
|
|
56
|
+
// every write before anything else runs.
|
|
57
|
+
r.use(
|
|
58
|
+
'*',
|
|
59
|
+
refuseWritesWhenReadOnly(() => binding.readOnly),
|
|
60
|
+
);
|
|
51
61
|
|
|
52
62
|
// Authorization — mirrors agents 1:1. Check the resource directly;
|
|
53
63
|
// permissions cascade from the parent project because publish writes
|
|
@@ -293,7 +303,7 @@ export function flowsRouter(
|
|
|
293
303
|
// Pin the version: every tool and unversioned agent resolves now,
|
|
294
304
|
// once, to the version its runs use. One with no published version
|
|
295
305
|
// refuses the publish rather than store a partly pinned version.
|
|
296
|
-
const pinned = await pinFlow(pinning, tenantId, loaded.value);
|
|
306
|
+
const pinned = await pinFlow(pinning, tenantId, projectId, loaded.value);
|
|
297
307
|
if (pinned.kind === 'unpinnable') {
|
|
298
308
|
c.status(statusFor('validation-failed') as never);
|
|
299
309
|
return c.json(toWireError(unpinnableFlow(loaded.value, pinned.issues), requestId));
|
|
@@ -408,19 +418,32 @@ export function flowsRouter(
|
|
|
408
418
|
return r;
|
|
409
419
|
}
|
|
410
420
|
|
|
421
|
+
/** The registries a published flow version is pinned against. */
|
|
422
|
+
export interface FlowPinning {
|
|
423
|
+
readonly tools: ToolRegistryBinding;
|
|
424
|
+
readonly agents: AgentRegistryBinding;
|
|
425
|
+
/** The agents' live versions: an agent step pins the one live for the flow's project. */
|
|
426
|
+
readonly live?: LiveVersionBinding;
|
|
427
|
+
}
|
|
428
|
+
|
|
411
429
|
/** The flow with its pins, or the references with no published version; as it is without `pinning`. */
|
|
412
430
|
async function pinFlow(
|
|
413
|
-
pinning:
|
|
414
|
-
| { readonly tools: ToolRegistryBinding; readonly agents: AgentRegistryBinding }
|
|
415
|
-
| undefined,
|
|
431
|
+
pinning: FlowPinning | undefined,
|
|
416
432
|
tenantId: TenantId,
|
|
433
|
+
projectId: ProjectId,
|
|
417
434
|
flow: Flow,
|
|
418
435
|
): Promise<
|
|
419
436
|
| { readonly kind: 'ok'; readonly flow: Flow }
|
|
420
437
|
| { readonly kind: 'unpinnable'; readonly issues: readonly UnpinnableRef[] }
|
|
421
438
|
> {
|
|
422
439
|
if (pinning === undefined) return { kind: 'ok', flow };
|
|
423
|
-
const resolved = await resolveFlowPins(
|
|
440
|
+
const resolved = await resolveFlowPins(
|
|
441
|
+
pinning.tools,
|
|
442
|
+
pinning.agents,
|
|
443
|
+
tenantId,
|
|
444
|
+
flow,
|
|
445
|
+
pinning.live !== undefined ? { binding: pinning.live, projectId } : undefined,
|
|
446
|
+
);
|
|
424
447
|
if (resolved.kind === 'unpinnable') return resolved;
|
|
425
448
|
return {
|
|
426
449
|
kind: 'ok',
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import { type Context, Hono } from 'hono';
|
|
5
|
+
|
|
6
|
+
import { ref } from '@kindgi/authz';
|
|
7
|
+
import type { Cursor, TenantId } from '@kindgi/types';
|
|
8
|
+
|
|
9
|
+
import { statusFor, toWireError } from '../errors.js';
|
|
10
|
+
import type { GatePolicyBinding, GatePolicyError } from '../gate-policy-binding.js';
|
|
11
|
+
import type { Authorizer } from '../middleware/authorize.js';
|
|
12
|
+
import type { AppEnv } from '../types.js';
|
|
13
|
+
import { scopeFromQuery } from './agent-releases.js';
|
|
14
|
+
import { parseGatePolicySpec } from './gate-policy-spec.js';
|
|
15
|
+
import { serializeGatePolicy } from './gate-policy-wire.js';
|
|
16
|
+
import { parseLiveScopeBody } from './live-scope-wire.js';
|
|
17
|
+
import { clampLimit } from './pagination.js';
|
|
18
|
+
|
|
19
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
20
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gate policies (evals step 4b): what a promotion of an agent must show
|
|
24
|
+
* before a version goes live for a scope. The same registry shape as
|
|
25
|
+
* retention policies:
|
|
26
|
+
* GET / each policy's latest active version (?agentId=, scope)
|
|
27
|
+
* GET /:policyId its latest active version
|
|
28
|
+
* GET /:policyId/versions[/:version] every version, or one
|
|
29
|
+
* POST / publish {id, version, agentId, scope, spec, description?}
|
|
30
|
+
* POST /:policyId/versions/:version/unregister|reinstate
|
|
31
|
+
*
|
|
32
|
+
* Writes need `admin` on the tenant: whoever may promote mustn't be able
|
|
33
|
+
* to loosen the gate on their own promotion.
|
|
34
|
+
*/
|
|
35
|
+
export function gatePoliciesRouter(
|
|
36
|
+
binding: GatePolicyBinding,
|
|
37
|
+
authorizer?: Authorizer,
|
|
38
|
+
): Hono<AppEnv> {
|
|
39
|
+
const r = new Hono<AppEnv>();
|
|
40
|
+
|
|
41
|
+
if (authorizer !== undefined) {
|
|
42
|
+
r.use('*', async (c, next) => {
|
|
43
|
+
if (c.req.method !== 'POST') return next();
|
|
44
|
+
const tenantId = c.get('tenantId') as unknown as string;
|
|
45
|
+
return authorizer.authorize('admin', () => ref('tenant', tenantId))(c, next);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
r.get('/', async (c) => {
|
|
50
|
+
const requestId = c.get('requestId');
|
|
51
|
+
const scope = scopeFromQuery((n) => c.req.query(n), c.req.queries('segment') ?? []);
|
|
52
|
+
if (scope.kind === 'err') return badInput(c, requestId, scope.message);
|
|
53
|
+
const agentId = c.req.query('agentId');
|
|
54
|
+
const cursor = c.req.query('cursor');
|
|
55
|
+
const page = await binding.list({
|
|
56
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
57
|
+
limit: clampLimit(c.req.query('limit')),
|
|
58
|
+
...(agentId !== undefined && agentId.length > 0 && { agentId }),
|
|
59
|
+
...(scope.scope !== undefined && { scope: scope.scope }),
|
|
60
|
+
...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
|
|
61
|
+
});
|
|
62
|
+
return c.json({
|
|
63
|
+
data: page.data.map(serializeGatePolicy),
|
|
64
|
+
hasMore: page.nextCursor !== undefined,
|
|
65
|
+
...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
r.get('/:policyId', async (c) => {
|
|
70
|
+
const found = await binding.get({
|
|
71
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
72
|
+
id: c.req.param('policyId'),
|
|
73
|
+
});
|
|
74
|
+
if (found === null) return notFound(c, c.req.param('policyId'));
|
|
75
|
+
return c.json(serializeGatePolicy(found));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
r.get('/:policyId/versions', async (c) => {
|
|
79
|
+
const versions = await binding.listVersions({
|
|
80
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
81
|
+
id: c.req.param('policyId'),
|
|
82
|
+
});
|
|
83
|
+
if (versions.length === 0) return notFound(c, c.req.param('policyId'));
|
|
84
|
+
return c.json({ data: versions.map(serializeGatePolicy), hasMore: false });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
r.get('/:policyId/versions/:version', async (c) => {
|
|
88
|
+
const found = await binding.getVersion({
|
|
89
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
90
|
+
id: c.req.param('policyId'),
|
|
91
|
+
version: c.req.param('version'),
|
|
92
|
+
});
|
|
93
|
+
if (found === null) {
|
|
94
|
+
return notFound(c, c.req.param('policyId'), c.req.param('version'));
|
|
95
|
+
}
|
|
96
|
+
return c.json(serializeGatePolicy(found));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
r.post('/', async (c) => {
|
|
100
|
+
const requestId = c.get('requestId');
|
|
101
|
+
let body: unknown;
|
|
102
|
+
try {
|
|
103
|
+
body = await c.req.json();
|
|
104
|
+
} catch {
|
|
105
|
+
return badInput(c, requestId, 'Request body must be a JSON object');
|
|
106
|
+
}
|
|
107
|
+
const parsed = parsePublishBody(body);
|
|
108
|
+
if (parsed.kind === 'err') {
|
|
109
|
+
c.status(statusFor('validation-failed') as never);
|
|
110
|
+
return c.json(
|
|
111
|
+
toWireError(
|
|
112
|
+
{
|
|
113
|
+
code: 'validation-failed',
|
|
114
|
+
message: `The gate policy isn't valid: ${parsed.issues.map((i) => `${i.path}: ${i.message}`).join('; ')}`,
|
|
115
|
+
issues: parsed.issues,
|
|
116
|
+
},
|
|
117
|
+
requestId,
|
|
118
|
+
),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const outcome = await binding.publish({
|
|
122
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
123
|
+
...parsed.value,
|
|
124
|
+
});
|
|
125
|
+
if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
|
|
126
|
+
c.status(201);
|
|
127
|
+
return c.json(serializeGatePolicy(outcome.value));
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
for (const action of ['unregister', 'reinstate'] as const) {
|
|
131
|
+
r.post(`/:policyId/versions/:version/${action}`, async (c) => {
|
|
132
|
+
const outcome = await binding[action]({
|
|
133
|
+
tenantId: c.get('tenantId') as TenantId,
|
|
134
|
+
id: c.req.param('policyId'),
|
|
135
|
+
version: c.req.param('version'),
|
|
136
|
+
});
|
|
137
|
+
if (outcome.kind === 'err') return failed(c, c.get('requestId'), outcome.error);
|
|
138
|
+
return c.json(serializeGatePolicy(outcome.value));
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return r;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type Ctx = Context<AppEnv>;
|
|
146
|
+
|
|
147
|
+
function badInput(c: Ctx, requestId: string, message: string) {
|
|
148
|
+
c.status(statusFor('bad-input') as never);
|
|
149
|
+
return c.json(toWireError({ code: 'bad-input', message }, requestId));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function notFound(c: Ctx, policyId: string, version?: string) {
|
|
153
|
+
c.status(statusFor('gate-policy-not-found') as never);
|
|
154
|
+
return c.json(
|
|
155
|
+
toWireError(
|
|
156
|
+
{
|
|
157
|
+
code: 'gate-policy-not-found',
|
|
158
|
+
message:
|
|
159
|
+
version === undefined
|
|
160
|
+
? `No active gate policy "${policyId}"`
|
|
161
|
+
: `Gate policy "${policyId}" has no version ${version}`,
|
|
162
|
+
policyId,
|
|
163
|
+
},
|
|
164
|
+
c.get('requestId'),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function failed(c: Ctx, requestId: string, error: GatePolicyError) {
|
|
170
|
+
c.status(statusFor(error.code) as never);
|
|
171
|
+
return c.json(toWireError({ ...error }, requestId));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type Issue = { readonly path: string; readonly message: string };
|
|
175
|
+
|
|
176
|
+
function parsePublishBody(body: unknown):
|
|
177
|
+
| {
|
|
178
|
+
kind: 'ok';
|
|
179
|
+
value: Omit<Parameters<GatePolicyBinding['publish']>[0], 'tenantId'>;
|
|
180
|
+
}
|
|
181
|
+
| { kind: 'err'; issues: readonly Issue[] } {
|
|
182
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
183
|
+
return { kind: 'err', issues: [{ path: '', message: 'the body must be a JSON object' }] };
|
|
184
|
+
}
|
|
185
|
+
const b = body as Record<string, unknown>;
|
|
186
|
+
const issues: Issue[] = [];
|
|
187
|
+
for (const key of Object.keys(b)) {
|
|
188
|
+
if (!['id', 'version', 'agentId', 'scope', 'spec', 'description'].includes(key)) {
|
|
189
|
+
issues.push({ path: `/${key}`, message: `\`${key}\` isn't a gate policy field` });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (typeof b.id !== 'string' || !ID_RE.test(b.id)) {
|
|
193
|
+
issues.push({
|
|
194
|
+
path: '/id',
|
|
195
|
+
message:
|
|
196
|
+
'`id` must be lowercase letters, digits, `.`, `_` or `-` (e.g. "acme.drafting-prod")',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
if (typeof b.version !== 'string' || !SEMVER_RE.test(b.version)) {
|
|
200
|
+
issues.push({ path: '/version', message: '`version` must be a semver version (e.g. "1.0.0")' });
|
|
201
|
+
}
|
|
202
|
+
if (typeof b.agentId !== 'string' || b.agentId === '') {
|
|
203
|
+
issues.push({ path: '/agentId', message: '`agentId` is required: the agent the policy gates' });
|
|
204
|
+
}
|
|
205
|
+
const scope = parseLiveScopeBody(b.scope);
|
|
206
|
+
if (scope.kind === 'err') issues.push({ path: '/scope', message: scope.message });
|
|
207
|
+
const spec = parseGatePolicySpec(b.spec);
|
|
208
|
+
if (spec.kind === 'err') issues.push(...spec.issues);
|
|
209
|
+
if (b.description !== undefined && (typeof b.description !== 'string' || b.description === '')) {
|
|
210
|
+
issues.push({ path: '/description', message: '`description` must be a non-empty string' });
|
|
211
|
+
}
|
|
212
|
+
if (issues.length > 0 || scope.kind === 'err' || spec.kind === 'err') {
|
|
213
|
+
return { kind: 'err', issues };
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
kind: 'ok',
|
|
217
|
+
value: {
|
|
218
|
+
id: b.id as string,
|
|
219
|
+
version: b.version as string,
|
|
220
|
+
agentId: b.agentId as string,
|
|
221
|
+
scope: scope.scope,
|
|
222
|
+
spec: spec.spec,
|
|
223
|
+
...(typeof b.description === 'string' && { description: b.description }),
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import { REVIEWER_ROLE_RANK, type ReviewerRole } from '@kindgi/authz';
|
|
5
|
+
|
|
6
|
+
import { GATE_METRICS, type GateMetricSpec, type GatePolicySpec } from '../gate-policy-binding.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A gate policy's `spec`, checked strictly: an unknown key is an issue, so
|
|
10
|
+
* a typo can't leave a check silently off.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface SpecIssue {
|
|
14
|
+
readonly path: string;
|
|
15
|
+
readonly message: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Raw = Readonly<Record<string, unknown>>;
|
|
19
|
+
|
|
20
|
+
const REVIEWER_ROLES = Object.keys(REVIEWER_ROLE_RANK) as readonly ReviewerRole[];
|
|
21
|
+
|
|
22
|
+
const isObject = (v: unknown): v is Raw => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
23
|
+
|
|
24
|
+
/** Keys the policy may not use yet, with why. */
|
|
25
|
+
const LATER: Readonly<Record<string, string>> = {
|
|
26
|
+
'/spec/approvals/count': 'one reviewer decides a promotion today; leave `count` out',
|
|
27
|
+
'/spec/approvals/forRollback': "rollbacks aren't gated yet; leave `forRollback` out",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function unknownKeys(raw: Raw, allowed: readonly string[], at: string, issues: SpecIssue[]) {
|
|
31
|
+
for (const key of Object.keys(raw)) {
|
|
32
|
+
if (allowed.includes(key)) continue;
|
|
33
|
+
const path = `${at}/${key}`;
|
|
34
|
+
issues.push({
|
|
35
|
+
path,
|
|
36
|
+
message:
|
|
37
|
+
LATER[path] ??
|
|
38
|
+
`\`${key}\` isn't a gate policy setting here (expected: ${allowed.join(', ')})`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A number in [min, max], when present. */
|
|
44
|
+
function num(
|
|
45
|
+
raw: Raw,
|
|
46
|
+
key: string,
|
|
47
|
+
at: string,
|
|
48
|
+
issues: SpecIssue[],
|
|
49
|
+
opts: { min: number; max?: number; integer?: boolean; positive?: boolean },
|
|
50
|
+
): number | undefined {
|
|
51
|
+
const v = raw[key];
|
|
52
|
+
if (v === undefined) return undefined;
|
|
53
|
+
const ok =
|
|
54
|
+
typeof v === 'number' &&
|
|
55
|
+
Number.isFinite(v) &&
|
|
56
|
+
v >= opts.min &&
|
|
57
|
+
(opts.max === undefined || v <= opts.max) &&
|
|
58
|
+
(opts.integer !== true || Number.isInteger(v)) &&
|
|
59
|
+
(opts.positive !== true || v > 0);
|
|
60
|
+
if (!ok) {
|
|
61
|
+
const range = opts.max === undefined ? `at least ${opts.min}` : `${opts.min} to ${opts.max}`;
|
|
62
|
+
issues.push({
|
|
63
|
+
path: `${at}/${key}`,
|
|
64
|
+
message: `\`${key}\` must be ${opts.integer === true ? 'a whole number' : 'a number'}, ${opts.positive === true ? 'above 0' : range}`,
|
|
65
|
+
});
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return v as number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function bool(raw: Raw, key: string, at: string, issues: SpecIssue[]): boolean | undefined {
|
|
72
|
+
const v = raw[key];
|
|
73
|
+
if (v === undefined) return undefined;
|
|
74
|
+
if (typeof v !== 'boolean') {
|
|
75
|
+
issues.push({ path: `${at}/${key}`, message: `\`${key}\` must be true or false` });
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
return v;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function block(raw: Raw, key: string, issues: SpecIssue[]): Raw | undefined {
|
|
82
|
+
const v = raw[key];
|
|
83
|
+
if (v === undefined) return undefined;
|
|
84
|
+
if (!isObject(v)) {
|
|
85
|
+
issues.push({ path: `/spec/${key}`, message: `\`${key}\` must be an object` });
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
return v;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseGatePolicySpec(
|
|
92
|
+
raw: unknown,
|
|
93
|
+
): { kind: 'ok'; spec: GatePolicySpec } | { kind: 'err'; issues: readonly SpecIssue[] } {
|
|
94
|
+
if (!isObject(raw)) {
|
|
95
|
+
return { kind: 'err', issues: [{ path: '/spec', message: '`spec` must be an object' }] };
|
|
96
|
+
}
|
|
97
|
+
const issues: SpecIssue[] = [];
|
|
98
|
+
unknownKeys(
|
|
99
|
+
raw,
|
|
100
|
+
['comparison', 'evidence', 'metrics', 'replay', 'approvals', 'onlyRestrictedClasses'],
|
|
101
|
+
'/spec',
|
|
102
|
+
issues,
|
|
103
|
+
);
|
|
104
|
+
const spec: {
|
|
105
|
+
-readonly [K in keyof GatePolicySpec]: GatePolicySpec[K];
|
|
106
|
+
} = {};
|
|
107
|
+
|
|
108
|
+
const comparison = block(raw, 'comparison', issues);
|
|
109
|
+
if (comparison !== undefined) {
|
|
110
|
+
const at = '/spec/comparison';
|
|
111
|
+
unknownKeys(comparison, ['maxAgeHours', 'suite'], at, issues);
|
|
112
|
+
const maxAgeHours = num(comparison, 'maxAgeHours', at, issues, { min: 0, positive: true });
|
|
113
|
+
let suite: { id: string; version?: string } | undefined;
|
|
114
|
+
if (comparison.suite !== undefined) {
|
|
115
|
+
const s = comparison.suite;
|
|
116
|
+
if (!isObject(s) || typeof s.id !== 'string' || s.id === '') {
|
|
117
|
+
issues.push({ path: `${at}/suite`, message: '`suite` must be `{ id, version? }`' });
|
|
118
|
+
} else {
|
|
119
|
+
unknownKeys(s, ['id', 'version'], `${at}/suite`, issues);
|
|
120
|
+
if (s.version !== undefined && (typeof s.version !== 'string' || s.version === '')) {
|
|
121
|
+
issues.push({ path: `${at}/suite/version`, message: '`version` must be a version' });
|
|
122
|
+
}
|
|
123
|
+
suite = { id: s.id, ...(typeof s.version === 'string' && { version: s.version }) };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
spec.comparison = {
|
|
127
|
+
...(maxAgeHours !== undefined && { maxAgeHours }),
|
|
128
|
+
...(suite !== undefined && { suite }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const evidence = block(raw, 'evidence', issues);
|
|
133
|
+
if (evidence !== undefined) {
|
|
134
|
+
const at = '/spec/evidence';
|
|
135
|
+
unknownKeys(evidence, ['minCases', 'minWeight'], at, issues);
|
|
136
|
+
const minCases = num(evidence, 'minCases', at, issues, { min: 0, integer: true });
|
|
137
|
+
const minWeight = num(evidence, 'minWeight', at, issues, { min: 0 });
|
|
138
|
+
spec.evidence = {
|
|
139
|
+
...(minCases !== undefined && { minCases }),
|
|
140
|
+
...(minWeight !== undefined && { minWeight }),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (raw.metrics !== undefined) {
|
|
145
|
+
if (!Array.isArray(raw.metrics)) {
|
|
146
|
+
issues.push({ path: '/spec/metrics', message: '`metrics` must be a list' });
|
|
147
|
+
} else {
|
|
148
|
+
const metrics: GateMetricSpec[] = [];
|
|
149
|
+
const seen = new Set<string>();
|
|
150
|
+
raw.metrics.forEach((m: unknown, i: number) => {
|
|
151
|
+
const at = `/spec/metrics/${i}`;
|
|
152
|
+
const before = issues.length;
|
|
153
|
+
if (!isObject(m)) {
|
|
154
|
+
issues.push({ path: at, message: 'each metric must be an object' });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
unknownKeys(m, ['name', 'k', 'minCandidate', 'maxDrop'], at, issues);
|
|
158
|
+
const name = m.name;
|
|
159
|
+
if (typeof name !== 'string' || !(GATE_METRICS as readonly string[]).includes(name)) {
|
|
160
|
+
issues.push({
|
|
161
|
+
path: `${at}/name`,
|
|
162
|
+
message: `\`name\` must be one of: ${GATE_METRICS.join(', ')}`,
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (seen.has(name)) {
|
|
167
|
+
issues.push({ path: `${at}/name`, message: `\`${name}\` is gated twice` });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
seen.add(name);
|
|
171
|
+
if (m.k !== undefined && name !== 'weightedPrecisionAtK') {
|
|
172
|
+
issues.push({ path: `${at}/k`, message: '`k` applies to `weightedPrecisionAtK` only' });
|
|
173
|
+
}
|
|
174
|
+
const k = num(m, 'k', at, issues, { min: 1, max: 100, integer: true });
|
|
175
|
+
const minCandidate = num(m, 'minCandidate', at, issues, { min: 0, max: 1 });
|
|
176
|
+
const maxDrop = num(m, 'maxDrop', at, issues, { min: 0, max: 1 });
|
|
177
|
+
if (minCandidate === undefined && maxDrop === undefined && issues.length === before) {
|
|
178
|
+
issues.push({
|
|
179
|
+
path: at,
|
|
180
|
+
message: `\`${name}\` needs \`minCandidate\`, \`maxDrop\` or both: what it must reach`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
metrics.push({
|
|
184
|
+
name: name as GateMetricSpec['name'],
|
|
185
|
+
...(k !== undefined && { k }),
|
|
186
|
+
...(minCandidate !== undefined && { minCandidate }),
|
|
187
|
+
...(maxDrop !== undefined && { maxDrop }),
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
spec.metrics = metrics;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const replay = block(raw, 'replay', issues);
|
|
195
|
+
if (replay !== undefined) {
|
|
196
|
+
const at = '/spec/replay';
|
|
197
|
+
const knobs = ['maxDiverged', 'maxErrors', 'maxRefusedWrites', 'maxStopped'] as const;
|
|
198
|
+
unknownKeys(replay, knobs, at, issues);
|
|
199
|
+
const out: { -readonly [K in (typeof knobs)[number]]?: number } = {};
|
|
200
|
+
for (const knob of knobs) {
|
|
201
|
+
const v = num(replay, knob, at, issues, { min: 0, integer: true });
|
|
202
|
+
if (v !== undefined) out[knob] = v;
|
|
203
|
+
}
|
|
204
|
+
spec.replay = out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const approvals = block(raw, 'approvals', issues);
|
|
208
|
+
if (approvals !== undefined) {
|
|
209
|
+
const at = '/spec/approvals';
|
|
210
|
+
unknownKeys(approvals, ['role', 'separateApprover'], at, issues);
|
|
211
|
+
const role = approvals.role;
|
|
212
|
+
if (role !== undefined && !(REVIEWER_ROLES as readonly unknown[]).includes(role)) {
|
|
213
|
+
issues.push({
|
|
214
|
+
path: `${at}/role`,
|
|
215
|
+
message: `\`role\` must be one of: ${REVIEWER_ROLES.join(', ')}`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
const separateApprover = bool(approvals, 'separateApprover', at, issues);
|
|
219
|
+
spec.approvals = {
|
|
220
|
+
...(typeof role === 'string' && { role: role as ReviewerRole }),
|
|
221
|
+
...(separateApprover !== undefined && { separateApprover }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const onlyRestricted = bool(raw, 'onlyRestrictedClasses', '/spec', issues);
|
|
226
|
+
if (onlyRestricted !== undefined) spec.onlyRestrictedClasses = onlyRestricted;
|
|
227
|
+
|
|
228
|
+
return issues.length > 0 ? { kind: 'err', issues } : { kind: 'ok', spec };
|
|
229
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { GatePolicy } from '../gate-policy-binding.js';
|
|
5
|
+
import { liveScopeToWire } from './live-scope-wire.js';
|
|
6
|
+
|
|
7
|
+
export function serializeGatePolicy(p: GatePolicy): Record<string, unknown> {
|
|
8
|
+
return {
|
|
9
|
+
id: p.id,
|
|
10
|
+
version: p.version,
|
|
11
|
+
agentId: p.agentId,
|
|
12
|
+
scope: liveScopeToWire(p.scope),
|
|
13
|
+
spec: p.spec,
|
|
14
|
+
...(p.description !== undefined && { description: p.description }),
|
|
15
|
+
createdAt: p.createdAt as unknown as string,
|
|
16
|
+
...(p.unregisteredAt !== undefined && {
|
|
17
|
+
unregisteredAt: p.unregisteredAt as unknown as string,
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
}
|