@kindgi/api 0.1.3 → 0.1.4-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/dist/agent-binding.d.ts +18 -4
  2. package/dist/agent-binding.d.ts.map +1 -1
  3. package/dist/agent-pins.d.ts +48 -0
  4. package/dist/agent-pins.d.ts.map +1 -0
  5. package/dist/agent-pins.js +102 -0
  6. package/dist/agent-pins.js.map +1 -0
  7. package/dist/app.d.ts +22 -0
  8. package/dist/app.d.ts.map +1 -1
  9. package/dist/app.js +20 -2
  10. package/dist/app.js.map +1 -1
  11. package/dist/block-binding.d.ts +132 -0
  12. package/dist/block-binding.d.ts.map +1 -0
  13. package/dist/block-binding.js +4 -0
  14. package/dist/block-binding.js.map +1 -0
  15. package/dist/block-pins.d.ts +22 -0
  16. package/dist/block-pins.d.ts.map +1 -0
  17. package/dist/block-pins.js +112 -0
  18. package/dist/block-pins.js.map +1 -0
  19. package/dist/cost-binding.d.ts +20 -1
  20. package/dist/cost-binding.d.ts.map +1 -1
  21. package/dist/cost-binding.js +4 -0
  22. package/dist/cost-binding.js.map +1 -1
  23. package/dist/deploy-versions.d.ts +58 -0
  24. package/dist/deploy-versions.d.ts.map +1 -0
  25. package/dist/deploy-versions.js +91 -0
  26. package/dist/deploy-versions.js.map +1 -0
  27. package/dist/deployment-binding.d.ts +24 -3
  28. package/dist/deployment-binding.d.ts.map +1 -1
  29. package/dist/derive-agent-version.d.ts +69 -0
  30. package/dist/derive-agent-version.d.ts.map +1 -0
  31. package/dist/derive-agent-version.js +139 -0
  32. package/dist/derive-agent-version.js.map +1 -0
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/errors.js +17 -0
  35. package/dist/errors.js.map +1 -1
  36. package/dist/eval-case-binding.d.ts +65 -0
  37. package/dist/eval-case-binding.d.ts.map +1 -0
  38. package/dist/eval-case-binding.js +4 -0
  39. package/dist/eval-case-binding.js.map +1 -0
  40. package/dist/eval-run-binding.d.ts +30 -0
  41. package/dist/eval-run-binding.d.ts.map +1 -1
  42. package/dist/eval-run-dispatcher.d.ts +38 -3
  43. package/dist/eval-run-dispatcher.d.ts.map +1 -1
  44. package/dist/eval-run-dispatcher.js +21 -15
  45. package/dist/eval-run-dispatcher.js.map +1 -1
  46. package/dist/eval-suite-binding.d.ts +1 -1
  47. package/dist/eval-suite-binding.d.ts.map +1 -1
  48. package/dist/eval-suite-binding.js +2 -0
  49. package/dist/eval-suite-binding.js.map +1 -1
  50. package/dist/flow-binding.d.ts +10 -4
  51. package/dist/flow-binding.d.ts.map +1 -1
  52. package/dist/flow-pins.d.ts +36 -0
  53. package/dist/flow-pins.d.ts.map +1 -0
  54. package/dist/flow-pins.js +81 -0
  55. package/dist/flow-pins.js.map +1 -0
  56. package/dist/index.d.ts +17 -6
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -2
  59. package/dist/index.js.map +1 -1
  60. package/dist/judged-dispatcher.d.ts +134 -0
  61. package/dist/judged-dispatcher.d.ts.map +1 -0
  62. package/dist/judged-dispatcher.js +297 -0
  63. package/dist/judged-dispatcher.js.map +1 -0
  64. package/dist/judged-items.d.ts +86 -0
  65. package/dist/judged-items.d.ts.map +1 -0
  66. package/dist/judged-items.js +184 -0
  67. package/dist/judged-items.js.map +1 -0
  68. package/dist/judgment-binding.d.ts +316 -0
  69. package/dist/judgment-binding.d.ts.map +1 -0
  70. package/dist/judgment-binding.js +19 -0
  71. package/dist/judgment-binding.js.map +1 -0
  72. package/dist/openapi/generate.d.ts.map +1 -1
  73. package/dist/openapi/generate.js +4 -1
  74. package/dist/openapi/generate.js.map +1 -1
  75. package/dist/openapi/operations.d.ts.map +1 -1
  76. package/dist/openapi/operations.js +461 -7
  77. package/dist/openapi/operations.js.map +1 -1
  78. package/dist/openapi/schemas.d.ts +40 -0
  79. package/dist/openapi/schemas.d.ts.map +1 -1
  80. package/dist/openapi/schemas.js +843 -7
  81. package/dist/openapi/schemas.js.map +1 -1
  82. package/dist/provider-binding.d.ts +12 -7
  83. package/dist/provider-binding.d.ts.map +1 -1
  84. package/dist/routes/agents.d.ts +9 -1
  85. package/dist/routes/agents.d.ts.map +1 -1
  86. package/dist/routes/agents.js +175 -11
  87. package/dist/routes/agents.js.map +1 -1
  88. package/dist/routes/blocks.d.ts +19 -0
  89. package/dist/routes/blocks.d.ts.map +1 -0
  90. package/dist/routes/blocks.js +281 -0
  91. package/dist/routes/blocks.js.map +1 -0
  92. package/dist/routes/cost.d.ts.map +1 -1
  93. package/dist/routes/cost.js +47 -2
  94. package/dist/routes/cost.js.map +1 -1
  95. package/dist/routes/deployments.d.ts +3 -0
  96. package/dist/routes/deployments.d.ts.map +1 -1
  97. package/dist/routes/deployments.js +208 -55
  98. package/dist/routes/deployments.js.map +1 -1
  99. package/dist/routes/eval-comparison.d.ts +14 -0
  100. package/dist/routes/eval-comparison.d.ts.map +1 -0
  101. package/dist/routes/eval-comparison.js +87 -0
  102. package/dist/routes/eval-comparison.js.map +1 -0
  103. package/dist/routes/eval-runs.d.ts.map +1 -1
  104. package/dist/routes/eval-runs.js +8 -0
  105. package/dist/routes/eval-runs.js.map +1 -1
  106. package/dist/routes/flows.d.ts +13 -1
  107. package/dist/routes/flows.d.ts.map +1 -1
  108. package/dist/routes/flows.js +44 -3
  109. package/dist/routes/flows.js.map +1 -1
  110. package/dist/routes/hierarchy-errors.d.ts +35 -0
  111. package/dist/routes/hierarchy-errors.d.ts.map +1 -0
  112. package/dist/routes/hierarchy-errors.js +39 -0
  113. package/dist/routes/hierarchy-errors.js.map +1 -0
  114. package/dist/routes/judged-suites.d.ts +20 -0
  115. package/dist/routes/judged-suites.d.ts.map +1 -0
  116. package/dist/routes/judged-suites.js +272 -0
  117. package/dist/routes/judged-suites.js.map +1 -0
  118. package/dist/routes/judgment-context.d.ts +22 -0
  119. package/dist/routes/judgment-context.d.ts.map +1 -0
  120. package/dist/routes/judgment-context.js +88 -0
  121. package/dist/routes/judgment-context.js.map +1 -0
  122. package/dist/routes/judgment-flow-context.d.ts +32 -0
  123. package/dist/routes/judgment-flow-context.d.ts.map +1 -0
  124. package/dist/routes/judgment-flow-context.js +195 -0
  125. package/dist/routes/judgment-flow-context.js.map +1 -0
  126. package/dist/routes/judgments.d.ts +41 -0
  127. package/dist/routes/judgments.d.ts.map +1 -0
  128. package/dist/routes/judgments.js +566 -0
  129. package/dist/routes/judgments.js.map +1 -0
  130. package/dist/routes/orgs.d.ts +5 -2
  131. package/dist/routes/orgs.d.ts.map +1 -1
  132. package/dist/routes/orgs.js +38 -22
  133. package/dist/routes/orgs.js.map +1 -1
  134. package/dist/routes/policies.d.ts.map +1 -1
  135. package/dist/routes/policies.js +12 -1
  136. package/dist/routes/policies.js.map +1 -1
  137. package/dist/routes/projects.d.ts +10 -2
  138. package/dist/routes/projects.d.ts.map +1 -1
  139. package/dist/routes/projects.js +87 -79
  140. package/dist/routes/projects.js.map +1 -1
  141. package/dist/routes/providers.d.ts.map +1 -1
  142. package/dist/routes/providers.js +6 -1
  143. package/dist/routes/providers.js.map +1 -1
  144. package/dist/routes/runs.js +24 -3
  145. package/dist/routes/runs.js.map +1 -1
  146. package/dist/routes/teams.d.ts +6 -2
  147. package/dist/routes/teams.d.ts.map +1 -1
  148. package/dist/routes/teams.js +77 -73
  149. package/dist/routes/teams.js.map +1 -1
  150. package/openapi.json +13545 -10103
  151. package/package.json +21 -21
  152. package/src/agent-binding.ts +19 -4
  153. package/src/agent-pins.ts +147 -0
  154. package/src/app.ts +71 -2
  155. package/src/block-binding.ts +137 -0
  156. package/src/block-pins.ts +148 -0
  157. package/src/cost-binding.ts +21 -1
  158. package/src/deploy-versions.ts +157 -0
  159. package/src/deployment-binding.ts +27 -3
  160. package/src/derive-agent-version.ts +206 -0
  161. package/src/errors.ts +17 -0
  162. package/src/eval-case-binding.ts +71 -0
  163. package/src/eval-run-binding.ts +33 -0
  164. package/src/eval-run-dispatcher.ts +57 -16
  165. package/src/eval-suite-binding.ts +2 -0
  166. package/src/flow-binding.ts +11 -4
  167. package/src/flow-pins.ts +113 -0
  168. package/src/index.ts +85 -2
  169. package/src/judged-dispatcher.ts +507 -0
  170. package/src/judged-items.ts +263 -0
  171. package/src/judgment-binding.ts +349 -0
  172. package/src/openapi/generate.ts +7 -1
  173. package/src/openapi/operations.ts +523 -7
  174. package/src/openapi/schemas.ts +1009 -88
  175. package/src/provider-binding.ts +12 -7
  176. package/src/routes/agents.ts +243 -19
  177. package/src/routes/blocks.ts +362 -0
  178. package/src/routes/cost.ts +55 -1
  179. package/src/routes/deployments.ts +266 -56
  180. package/src/routes/eval-comparison.ts +101 -0
  181. package/src/routes/eval-runs.ts +11 -0
  182. package/src/routes/flows.ts +63 -5
  183. package/src/routes/hierarchy-errors.ts +51 -0
  184. package/src/routes/judged-suites.ts +363 -0
  185. package/src/routes/judgment-context.ts +128 -0
  186. package/src/routes/judgment-flow-context.ts +245 -0
  187. package/src/routes/judgments.ts +743 -0
  188. package/src/routes/orgs.ts +44 -27
  189. package/src/routes/policies.ts +19 -0
  190. package/src/routes/projects.ts +106 -95
  191. package/src/routes/providers.ts +5 -0
  192. package/src/routes/runs.ts +28 -3
  193. package/src/routes/teams.ts +96 -90
@@ -0,0 +1,743 @@
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 { ConversationBinding } from '@kindgi/agents';
7
+ import { ref } from '@kindgi/authz';
8
+ import type { RunBinding } from '@kindgi/runtime';
9
+ import type { Cursor, ProjectId, RunId, TenantId } from '@kindgi/types';
10
+
11
+ import { statusFor, toWireError } from '../errors.js';
12
+ import type { FlowRegistryBinding } from '../flow-binding.js';
13
+ import {
14
+ type JudgeClass,
15
+ type JudgeClassScope,
16
+ type JudgedItem,
17
+ type JudgedRunContext,
18
+ type JudgedSubject,
19
+ type Judgment,
20
+ type JudgmentAssertedBy,
21
+ type JudgmentRegistryBinding,
22
+ type JudgmentWithCopies,
23
+ VERDICTS,
24
+ type Verdict,
25
+ judgeClassApplies,
26
+ } from '../judgment-binding.js';
27
+ import type { Authorizer } from '../middleware/authorize.js';
28
+ import type { AppEnv } from '../types.js';
29
+ import { captureTurnContext } from './judgment-context.js';
30
+ import { captureFlowContext } from './judgment-flow-context.js';
31
+ import { clampLimit } from './pagination.js';
32
+ import { parseListScope } from './scope-params.js';
33
+
34
+ /**
35
+ * Judgments: yes or no, with an optional reason, about one item of a
36
+ * run's output, recorded under a judge class.
37
+ *
38
+ * - `POST /v1/judgments`: judge an item of a finished run.
39
+ * - `GET /v1/judgments`: live judgments, filtered by run, agent version,
40
+ * flow, verdict, class, participant or project scope.
41
+ * - `GET /v1/judgments/:id`: one judgment with the stored copies.
42
+ * - `POST /v1/judgments/:id/unregister`: remove a judgment (soft).
43
+ *
44
+ * Authorization: judging needs `write` on the run's project (a run
45
+ * inherits its permissions from its project). Reading and removing go by
46
+ * the judgment's project (`read` / `write`), since a judgment outlives
47
+ * its run.
48
+ */
49
+ export function judgmentsRouter(
50
+ binding: JudgmentRegistryBinding,
51
+ runBinding: RunBinding,
52
+ authorizer?: Authorizer,
53
+ conversations?: ConversationBinding,
54
+ flows?: FlowRegistryBinding,
55
+ ): Hono<AppEnv> {
56
+ const r = new Hono<AppEnv>();
57
+
58
+ r.post('/', async (c) => {
59
+ const requestId = c.get('requestId');
60
+ const tenantId = c.get('tenantId') as TenantId;
61
+ const fail = (code: string, message: string) => {
62
+ c.status(statusFor(code) as never);
63
+ return c.json(toWireError({ code, message }, requestId));
64
+ };
65
+
66
+ const parsed = parseJudgmentBody(await c.req.json().catch(() => null));
67
+ if (parsed.kind === 'err') return fail('bad-input', parsed.message);
68
+ const body = parsed.body;
69
+
70
+ const asserted = assertedByOf(c.get('principal'));
71
+ if (asserted === undefined) {
72
+ return fail('permission-denied', 'Judging needs a user or a service token.');
73
+ }
74
+
75
+ const prepared = await prepareJudgment(c, body, runBinding, binding, authorizer);
76
+ if (prepared.kind === 'err') return fail(prepared.code, prepared.message);
77
+ const { run, subject, projectId, itemValue, conversationId } = prepared;
78
+ const context = (await isFirstJudgment(binding, tenantId, body.runId))
79
+ ? await captureContext({
80
+ tenantId,
81
+ runId: body.runId,
82
+ subject,
83
+ output: run.output,
84
+ conversationId,
85
+ runBinding,
86
+ conversations,
87
+ flows,
88
+ })
89
+ : undefined;
90
+
91
+ const judgment = await binding.record({
92
+ tenantId,
93
+ projectId,
94
+ runId: body.runId,
95
+ run: {
96
+ subject,
97
+ input: run.input,
98
+ output: run.output,
99
+ ...(context !== undefined && { context }),
100
+ },
101
+ item: body.item,
102
+ ...(itemValue !== undefined && { itemValue }),
103
+ verdict: body.verdict,
104
+ ...(body.reason !== undefined && { reason: body.reason }),
105
+ ...(body.judgeClassId !== undefined && { judgeClassId: body.judgeClassId }),
106
+ assertedBy: asserted,
107
+ ...(body.participantId !== undefined && { participantId: body.participantId }),
108
+ });
109
+ c.status(201);
110
+ return c.json(serializeJudgment(judgment));
111
+ });
112
+
113
+ r.get('/', async (c) => {
114
+ const requestId = c.get('requestId');
115
+ const tenantId = c.get('tenantId') as TenantId;
116
+ const fail = (code: string, message: string) => {
117
+ c.status(statusFor(code) as never);
118
+ return c.json(toWireError({ code, message }, requestId));
119
+ };
120
+ const q = (name: string) => {
121
+ const v = c.req.query(name);
122
+ return v !== undefined && v.length > 0 ? v : undefined;
123
+ };
124
+
125
+ const scope = parseListScope(c.req.query(), { tenantId });
126
+ if (scope.kind === 'err') return fail('scope-invalid', scope.message);
127
+ const verdict = q('verdict');
128
+ if (verdict !== undefined && !isVerdict(verdict)) {
129
+ return fail('bad-input', `verdict must be one of: ${VERDICTS.join(', ')}.`);
130
+ }
131
+ if (q('agentVersion') !== undefined && q('agentId') === undefined) {
132
+ return fail('bad-input', 'agentVersion needs agentId.');
133
+ }
134
+
135
+ const page = await binding.list({
136
+ tenantId,
137
+ limit: clampLimit(c.req.query('limit')),
138
+ ...(scope.scope !== undefined && { scope: scope.scope }),
139
+ ...optional('runId', q('runId')),
140
+ ...optional('agentId', q('agentId')),
141
+ ...optional('agentVersion', q('agentVersion')),
142
+ ...optional('flowId', q('flowId')),
143
+ ...(verdict !== undefined && { verdict: verdict as Verdict }),
144
+ ...optional('judgeClassId', q('judgeClassId')),
145
+ ...optional('participantId', q('participantId')),
146
+ ...(q('cursor') !== undefined && { cursor: q('cursor') as Cursor }),
147
+ });
148
+ const visible =
149
+ authorizer === undefined
150
+ ? page.data
151
+ : await authorizer.filterByCan(c, 'read', page.data, (j) => ref('project', j.projectId));
152
+ return c.json({
153
+ data: visible.map(serializeJudgment),
154
+ hasMore: page.hasMore,
155
+ ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
156
+ });
157
+ });
158
+
159
+ r.get('/:judgmentId', async (c) => {
160
+ const requestId = c.get('requestId');
161
+ const tenantId = c.get('tenantId') as TenantId;
162
+ const judgmentId = c.req.param('judgmentId');
163
+ const judgment = await binding.get({ tenantId, judgmentId });
164
+ if (
165
+ judgment === null ||
166
+ (authorizer !== undefined &&
167
+ !(await authorizer.can(c, 'read', ref('project', judgment.projectId))))
168
+ ) {
169
+ c.status(statusFor('judgment-not-found') as never);
170
+ return c.json(
171
+ toWireError(
172
+ { code: 'judgment-not-found', message: `No judgment "${judgmentId}".` },
173
+ requestId,
174
+ ),
175
+ );
176
+ }
177
+ return c.json(serializeJudgmentWithCopies(judgment));
178
+ });
179
+
180
+ r.post('/:judgmentId/unregister', async (c) => {
181
+ const requestId = c.get('requestId');
182
+ const tenantId = c.get('tenantId') as TenantId;
183
+ const judgmentId = c.req.param('judgmentId');
184
+ const notFound = () => {
185
+ c.status(statusFor('judgment-not-found') as never);
186
+ return c.json(
187
+ toWireError(
188
+ { code: 'judgment-not-found', message: `No live judgment "${judgmentId}".` },
189
+ requestId,
190
+ ),
191
+ );
192
+ };
193
+ const judgment = await binding.get({ tenantId, judgmentId });
194
+ if (judgment === null || judgment.unregisteredAt !== undefined) return notFound();
195
+ if (
196
+ authorizer !== undefined &&
197
+ !(await authorizer.can(c, 'write', ref('project', judgment.projectId)))
198
+ ) {
199
+ c.status(statusFor('permission-denied') as never);
200
+ return c.json(
201
+ toWireError(
202
+ { code: 'permission-denied', message: `Not allowed to remove judgment "${judgmentId}".` },
203
+ requestId,
204
+ ),
205
+ );
206
+ }
207
+ const outcome = await binding.unregister({ tenantId, judgmentId });
208
+ if (!outcome.unregistered) return notFound();
209
+ return c.json({ judgmentId, unregistered: true });
210
+ });
211
+
212
+ return r;
213
+ }
214
+
215
+ /**
216
+ * Judge classes: the deployment's named classes of judges ("expert",
217
+ * "user", …), each with a weight, scoped to the tenant, a project, or an
218
+ * agent in a project.
219
+ *
220
+ * Managing a tenant-scoped class needs `admin` on the tenant; a project-
221
+ * or agent-scoped class needs `admin` on its project. Listing returns the
222
+ * classes the caller may read.
223
+ */
224
+ export function judgeClassesRouter(
225
+ binding: JudgmentRegistryBinding,
226
+ authorizer?: Authorizer,
227
+ ): Hono<AppEnv> {
228
+ const r = new Hono<AppEnv>();
229
+
230
+ const scopeRef = (tenantId: TenantId, scope: JudgeClassScope) =>
231
+ scope.kind === 'tenant' ? ref('tenant', tenantId) : ref('project', scope.projectId);
232
+
233
+ r.post('/', async (c) => {
234
+ const requestId = c.get('requestId');
235
+ const tenantId = c.get('tenantId') as TenantId;
236
+ const fail = (code: string, message: string) => {
237
+ c.status(statusFor(code) as never);
238
+ return c.json(toWireError({ code, message }, requestId));
239
+ };
240
+ const parsed = parseClassBody(await c.req.json().catch(() => null));
241
+ if (parsed.kind === 'err') return fail('bad-input', parsed.message);
242
+ const { scope, name, weight, description } = parsed.body;
243
+ if (
244
+ authorizer !== undefined &&
245
+ !(await authorizer.can(c, 'admin', scopeRef(tenantId, scope)))
246
+ ) {
247
+ return fail('permission-denied', 'Not allowed to manage judge classes in this scope.');
248
+ }
249
+ const outcome = await binding.createClass({
250
+ tenantId,
251
+ scope,
252
+ name,
253
+ weight,
254
+ ...(description !== undefined && { description }),
255
+ });
256
+ if (outcome.kind === 'name-taken') {
257
+ return fail('judge-class-name-taken', `A judge class named "${name}" already exists here.`);
258
+ }
259
+ c.status(201);
260
+ return c.json(serializeJudgeClass(outcome.judgeClass));
261
+ });
262
+
263
+ r.get('/', async (c) => {
264
+ const requestId = c.get('requestId');
265
+ const tenantId = c.get('tenantId') as TenantId;
266
+ const scope = parseClassScopeQuery(c.req.query());
267
+ if (scope.kind === 'err') {
268
+ c.status(statusFor('bad-input') as never);
269
+ return c.json(toWireError({ code: 'bad-input', message: scope.message }, requestId));
270
+ }
271
+ const cursor = c.req.query('cursor');
272
+ const page = await binding.listClasses({
273
+ tenantId,
274
+ limit: clampLimit(c.req.query('limit')),
275
+ ...(scope.scope !== undefined && { scope: scope.scope }),
276
+ ...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
277
+ });
278
+ const visible =
279
+ authorizer === undefined
280
+ ? page.data
281
+ : await authorizer.filterByCan(c, 'read', page.data, (k) => scopeRef(tenantId, k.scope));
282
+ return c.json({
283
+ data: visible.map(serializeJudgeClass),
284
+ hasMore: page.hasMore,
285
+ ...(page.nextCursor !== undefined && { nextCursor: page.nextCursor as unknown as string }),
286
+ });
287
+ });
288
+
289
+ const loadClass = async (
290
+ c: Context<AppEnv>,
291
+ action: 'read' | 'admin',
292
+ ): Promise<JudgeClass | Response> => {
293
+ const requestId = c.get('requestId');
294
+ const tenantId = c.get('tenantId') as TenantId;
295
+ const judgeClassId = c.req.param('judgeClassId') ?? '';
296
+ const found = await binding.getClass({
297
+ tenantId,
298
+ judgeClassId,
299
+ includeUnregistered: action === 'read',
300
+ });
301
+ if (found !== null && authorizer !== undefined) {
302
+ const allowed = await authorizer.can(c, action, scopeRef(tenantId, found.scope));
303
+ if (
304
+ !allowed &&
305
+ action === 'admin' &&
306
+ (await authorizer.can(c, 'read', scopeRef(tenantId, found.scope)))
307
+ ) {
308
+ c.status(statusFor('permission-denied') as never);
309
+ return c.json(
310
+ toWireError(
311
+ {
312
+ code: 'permission-denied',
313
+ message: 'Not allowed to manage judge classes in this scope.',
314
+ },
315
+ requestId,
316
+ ),
317
+ );
318
+ }
319
+ if (!allowed) return classNotFound(c, judgeClassId);
320
+ }
321
+ return found ?? classNotFound(c, judgeClassId);
322
+ };
323
+
324
+ r.get('/:judgeClassId', async (c) => {
325
+ const found = await loadClass(c, 'read');
326
+ return found instanceof Response ? found : c.json(serializeJudgeClass(found));
327
+ });
328
+
329
+ r.patch('/:judgeClassId', async (c) => {
330
+ const requestId = c.get('requestId');
331
+ const tenantId = c.get('tenantId') as TenantId;
332
+ const raw = (await c.req.json().catch(() => null)) as Record<string, unknown> | null;
333
+ const weight = raw?.weight;
334
+ const description = raw?.description;
335
+ if (
336
+ raw === null ||
337
+ typeof raw !== 'object' ||
338
+ (weight === undefined && description === undefined) ||
339
+ (weight !== undefined && !isWeight(weight)) ||
340
+ (description !== undefined && typeof description !== 'string')
341
+ ) {
342
+ c.status(statusFor('bad-input') as never);
343
+ return c.json(
344
+ toWireError(
345
+ {
346
+ code: 'bad-input',
347
+ message: 'Send weight (a number ≥ 0) and/or description (a string).',
348
+ },
349
+ requestId,
350
+ ),
351
+ );
352
+ }
353
+ const found = await loadClass(c, 'admin');
354
+ if (found instanceof Response) return found;
355
+ if (found.unregisteredAt !== undefined) return classNotFound(c, found.id);
356
+ const updated = await binding.updateClass({
357
+ tenantId,
358
+ judgeClassId: found.id,
359
+ ...(weight !== undefined && { weight: weight as number }),
360
+ ...(description !== undefined && { description: description as string }),
361
+ });
362
+ return updated === null ? classNotFound(c, found.id) : c.json(serializeJudgeClass(updated));
363
+ });
364
+
365
+ r.post('/:judgeClassId/unregister', async (c) => {
366
+ const tenantId = c.get('tenantId') as TenantId;
367
+ const found = await loadClass(c, 'admin');
368
+ if (found instanceof Response) return found;
369
+ const outcome = await binding.unregisterClass({ tenantId, judgeClassId: found.id });
370
+ if (!outcome.unregistered) return classNotFound(c, found.id);
371
+ return c.json({ judgeClassId: found.id, unregistered: true });
372
+ });
373
+
374
+ return r;
375
+ }
376
+
377
+ type Prepared =
378
+ | {
379
+ readonly kind: 'ok';
380
+ readonly run: { readonly input: unknown; readonly output: unknown };
381
+ readonly subject: JudgedSubject;
382
+ readonly projectId: ProjectId;
383
+ readonly itemValue?: unknown;
384
+ readonly conversationId?: string;
385
+ }
386
+ | { readonly kind: 'err'; readonly code: string; readonly message: string };
387
+
388
+ /**
389
+ * Everything a judgment needs from its run, or why it can't be recorded:
390
+ * the run exists, the caller may judge it, it has finished with an output,
391
+ * the class (if any) applies to it, and the pointer (if any) resolves.
392
+ */
393
+ async function prepareJudgment(
394
+ c: Context<AppEnv>,
395
+ body: JudgmentBody,
396
+ runBinding: RunBinding,
397
+ binding: JudgmentRegistryBinding,
398
+ authorizer: Authorizer | undefined,
399
+ ): Promise<Prepared> {
400
+ const err = (code: string, message: string): Prepared => ({ kind: 'err', code, message });
401
+ const tenantId = c.get('tenantId') as TenantId;
402
+ const run = await runBinding.getRun(tenantId, body.runId as RunId);
403
+ if (run === null) return err('run-not-found', `No run "${body.runId}".`);
404
+ // A run inherits its permissions from its project (as cancelling one
405
+ // does): judging it needs `write` there.
406
+ if (
407
+ authorizer !== undefined &&
408
+ !(await authorizer.can(c, 'write', ref('project', run.projectId as unknown as string)))
409
+ ) {
410
+ return err('permission-denied', `Not allowed to judge run "${body.runId}".`);
411
+ }
412
+ if (run.status !== 'completed' || run.output === undefined || run.output === null) {
413
+ return err(
414
+ 'run-not-finished',
415
+ `Run "${body.runId}" has no output to judge yet (status ${run.status}).`,
416
+ );
417
+ }
418
+ const subject = subjectOf(run);
419
+ const projectId = run.projectId as unknown as ProjectId;
420
+ if (
421
+ body.judgeClassId !== undefined &&
422
+ !(await classApplies(binding, tenantId, body.judgeClassId, { projectId, subject }))
423
+ ) {
424
+ return err(
425
+ 'judge-class-not-applicable',
426
+ `Judge class "${body.judgeClassId}" doesn't exist or doesn't apply to this run's project or agent.`,
427
+ );
428
+ }
429
+ const copy = { input: run.input, output: run.output };
430
+ const turn =
431
+ run.agent !== undefined ? { conversationId: run.agent.conversationId as string } : {};
432
+ if (body.item.pointer === undefined)
433
+ return { kind: 'ok', run: copy, subject, projectId, ...turn };
434
+ const found = resolvePointer(run.output, body.item.pointer);
435
+ if (!found.found) {
436
+ return err(
437
+ 'item-not-found',
438
+ `Nothing at "${body.item.pointer}" in run "${body.runId}"'s output.`,
439
+ );
440
+ }
441
+ return { kind: 'ok', run: copy, subject, projectId, itemValue: found.value, ...turn };
442
+ }
443
+
444
+ /** Whether the run has no live judgment yet (its copy is stored with the first). */
445
+ /**
446
+ * What a run's first judgment keeps so the run can be replayed later: a
447
+ * turn's history and retrieved context; a flow run's tool calls with
448
+ * their results.
449
+ */
450
+ function captureContext(input: {
451
+ readonly tenantId: TenantId;
452
+ readonly runId: string;
453
+ readonly subject: JudgedSubject;
454
+ readonly output: unknown;
455
+ readonly conversationId: string | undefined;
456
+ readonly runBinding: RunBinding;
457
+ readonly conversations: ConversationBinding | undefined;
458
+ readonly flows: FlowRegistryBinding | undefined;
459
+ }): Promise<JudgedRunContext | undefined> {
460
+ const { tenantId, runId, subject, runBinding } = input;
461
+ return subject.kind === 'agent'
462
+ ? captureTurnContext({
463
+ tenantId,
464
+ runId,
465
+ output: input.output,
466
+ conversationId: input.conversationId,
467
+ runBinding,
468
+ conversations: input.conversations,
469
+ })
470
+ : captureFlowContext({
471
+ tenantId,
472
+ run: { runId, flowId: subject.id, flowVersion: subject.version },
473
+ runBinding,
474
+ flows: input.flows,
475
+ });
476
+ }
477
+
478
+ async function isFirstJudgment(
479
+ binding: JudgmentRegistryBinding,
480
+ tenantId: TenantId,
481
+ runId: string,
482
+ ): Promise<boolean> {
483
+ const page = await binding.list({ tenantId, runId, limit: 1 });
484
+ return page.data.length === 0;
485
+ }
486
+
487
+ /** Whether a live class exists and its scope covers the run. */
488
+ async function classApplies(
489
+ binding: JudgmentRegistryBinding,
490
+ tenantId: TenantId,
491
+ judgeClassId: string,
492
+ run: { readonly projectId: ProjectId; readonly subject: JudgedSubject },
493
+ ): Promise<boolean> {
494
+ const judgeClass = await binding.getClass({ tenantId, judgeClassId });
495
+ return judgeClass !== null && judgeClassApplies(judgeClass.scope, run);
496
+ }
497
+
498
+ // ---------- request parsing ----------
499
+
500
+ interface JudgmentBody {
501
+ readonly runId: string;
502
+ readonly item: JudgedItem;
503
+ readonly verdict: Verdict;
504
+ readonly reason?: string;
505
+ readonly judgeClassId?: string;
506
+ readonly participantId?: string;
507
+ }
508
+
509
+ type Parsed<T> =
510
+ | { readonly kind: 'ok'; readonly body: T }
511
+ | { readonly kind: 'err'; readonly message: string };
512
+
513
+ const MAX_REASON = 4000;
514
+
515
+ function parseJudgmentBody(raw: unknown): Parsed<JudgmentBody> {
516
+ const err = (message: string): Parsed<JudgmentBody> => ({ kind: 'err', message });
517
+ if (raw === null || typeof raw !== 'object') return err('Send a JSON object.');
518
+ const b = raw as Record<string, unknown>;
519
+ if (!nonEmpty(b.runId)) return err('runId is required.');
520
+ if (typeof b.verdict !== 'string' || !isVerdict(b.verdict)) {
521
+ return err(`verdict must be one of: ${VERDICTS.join(', ')}.`);
522
+ }
523
+ const item = parseItem(b.item);
524
+ if (typeof item === 'string') return err(item);
525
+ const optional = optionalFieldError(b);
526
+ if (optional !== undefined) return err(optional);
527
+ return {
528
+ kind: 'ok',
529
+ body: {
530
+ runId: b.runId,
531
+ item,
532
+ verdict: b.verdict,
533
+ ...(typeof b.judgeClassId === 'string' && { judgeClassId: b.judgeClassId }),
534
+ ...(typeof b.reason === 'string' && b.reason.length > 0 && { reason: b.reason }),
535
+ ...(typeof b.participantId === 'string' && { participantId: b.participantId }),
536
+ },
537
+ };
538
+ }
539
+
540
+ /** What's wrong with the optional fields (class, reason, participant), if anything. */
541
+ function optionalFieldError(b: Record<string, unknown>): string | undefined {
542
+ if (b.judgeClassId !== undefined && !nonEmpty(b.judgeClassId)) {
543
+ return 'judgeClassId must be a judge class id, or left out for an unclassified judgment.';
544
+ }
545
+ if (b.reason !== undefined && (typeof b.reason !== 'string' || b.reason.length > MAX_REASON)) {
546
+ return `reason must be a string of at most ${MAX_REASON} characters.`;
547
+ }
548
+ if (b.participantId !== undefined && !nonEmpty(b.participantId)) {
549
+ return 'participantId must be a non-empty string.';
550
+ }
551
+ return undefined;
552
+ }
553
+
554
+ function parseItem(raw: unknown): JudgedItem | string {
555
+ if (raw === null || typeof raw !== 'object') return 'item is required: { key, pointer?, rank? }.';
556
+ const i = raw as Record<string, unknown>;
557
+ if (!nonEmpty(i.key)) return 'item.key is required.';
558
+ if (i.pointer !== undefined && (typeof i.pointer !== 'string' || !isPointer(i.pointer))) {
559
+ return 'item.pointer must be a JSON Pointer such as "/matches/2" (or "" for the whole output).';
560
+ }
561
+ if (i.rank !== undefined && !(Number.isInteger(i.rank) && (i.rank as number) >= 0)) {
562
+ return 'item.rank must be a whole number ≥ 0.';
563
+ }
564
+ return {
565
+ key: i.key,
566
+ ...(typeof i.pointer === 'string' && { pointer: i.pointer }),
567
+ ...(typeof i.rank === 'number' && { rank: i.rank }),
568
+ };
569
+ }
570
+
571
+ interface ClassBody {
572
+ readonly scope: JudgeClassScope;
573
+ readonly name: string;
574
+ readonly weight: number;
575
+ readonly description?: string;
576
+ }
577
+
578
+ function parseClassBody(raw: unknown): Parsed<ClassBody> {
579
+ const err = (message: string): Parsed<ClassBody> => ({ kind: 'err', message });
580
+ if (raw === null || typeof raw !== 'object') return err('Send a JSON object.');
581
+ const b = raw as Record<string, unknown>;
582
+ if (!nonEmpty(b.name) || b.name.length > 100)
583
+ return err('name is required (at most 100 characters).');
584
+ if (!isWeight(b.weight)) return err('weight is required: a number ≥ 0.');
585
+ if (b.description !== undefined && typeof b.description !== 'string') {
586
+ return err('description must be a string.');
587
+ }
588
+ const scope = parseClassScope(b.scope);
589
+ if (typeof scope === 'string') return err(scope);
590
+ return {
591
+ kind: 'ok',
592
+ body: {
593
+ scope,
594
+ name: b.name,
595
+ weight: b.weight,
596
+ ...(typeof b.description === 'string' && { description: b.description }),
597
+ },
598
+ };
599
+ }
600
+
601
+ function parseClassScope(raw: unknown): JudgeClassScope | string {
602
+ const usage =
603
+ 'scope is required: { kind: "tenant" }, { kind: "project", projectId }, or { kind: "agent", projectId, agentId }.';
604
+ if (raw === null || typeof raw !== 'object') return usage;
605
+ const s = raw as Record<string, unknown>;
606
+ if (s.kind === 'tenant') return { kind: 'tenant' };
607
+ if (!nonEmpty(s.projectId)) return usage;
608
+ const projectId = s.projectId as ProjectId;
609
+ if (s.kind === 'project') return { kind: 'project', projectId };
610
+ if (s.kind === 'agent' && nonEmpty(s.agentId))
611
+ return { kind: 'agent', projectId, agentId: s.agentId };
612
+ return usage;
613
+ }
614
+
615
+ /** `?scopeKind=tenant|project|agent&projectId=&agentId=` on the class list. */
616
+ function parseClassScopeQuery(
617
+ query: Record<string, string>,
618
+ ):
619
+ | { readonly kind: 'ok'; readonly scope?: JudgeClassScope }
620
+ | { readonly kind: 'err'; readonly message: string } {
621
+ const kind = query.scopeKind;
622
+ if (kind === undefined || kind.length === 0) return { kind: 'ok' };
623
+ const scope = parseClassScope({ kind, projectId: query.projectId, agentId: query.agentId });
624
+ return typeof scope === 'string' ? { kind: 'err', message: scope } : { kind: 'ok', scope };
625
+ }
626
+
627
+ // ---------- helpers ----------
628
+
629
+ function nonEmpty(v: unknown): v is string {
630
+ return typeof v === 'string' && v.trim().length > 0;
631
+ }
632
+
633
+ function isVerdict(v: string): v is Verdict {
634
+ return (VERDICTS as readonly string[]).includes(v);
635
+ }
636
+
637
+ function isWeight(v: unknown): v is number {
638
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0;
639
+ }
640
+
641
+ function optional<K extends string>(key: K, value: string | undefined): { [P in K]?: string } {
642
+ return (value === undefined ? {} : { [key]: value }) as { [P in K]?: string };
643
+ }
644
+
645
+ function classNotFound(c: Context<AppEnv>, id: string): Response {
646
+ c.status(statusFor('judge-class-not-found') as never);
647
+ return c.json(
648
+ toWireError(
649
+ { code: 'judge-class-not-found', message: `No judge class "${id}".` },
650
+ c.get('requestId'),
651
+ ),
652
+ );
653
+ }
654
+
655
+ /** The caller as a judgment records it, from the request's principal. */
656
+ function assertedByOf(
657
+ principal: AppEnv['Variables']['principal'] | undefined,
658
+ ): JudgmentAssertedBy | undefined {
659
+ if (principal === undefined) return undefined;
660
+ const actor = principal.actor;
661
+ return { kind: actor.kind === 'user' ? 'user' : 'service', id: actor.id };
662
+ }
663
+
664
+ /** What a run ran: its agent at a version (agent turns), else its flow. */
665
+ function subjectOf(run: {
666
+ readonly flowId: string;
667
+ readonly flowVersion: string;
668
+ readonly agent?: { readonly id: string; readonly version: string };
669
+ }): JudgedSubject {
670
+ return run.agent !== undefined
671
+ ? { kind: 'agent', id: run.agent.id, version: run.agent.version }
672
+ : { kind: 'flow', id: run.flowId, version: run.flowVersion };
673
+ }
674
+
675
+ const POINTER_RE = /^(\/([^~/]|~[01])*)*$/;
676
+
677
+ function isPointer(p: string): boolean {
678
+ return POINTER_RE.test(p);
679
+ }
680
+
681
+ /** RFC 6901: the value at `pointer` in `doc`. "" is the whole document. */
682
+ export function resolvePointer(
683
+ doc: unknown,
684
+ pointer: string,
685
+ ): { readonly found: true; readonly value: unknown } | { readonly found: false } {
686
+ if (pointer === '') return { found: true, value: doc };
687
+ let current: unknown = doc;
688
+ for (const raw of pointer.slice(1).split('/')) {
689
+ const token = raw.replace(/~1/g, '/').replace(/~0/g, '~');
690
+ if (Array.isArray(current)) {
691
+ if (!/^(0|[1-9]\d*)$/.test(token) || Number(token) >= current.length) return { found: false };
692
+ current = current[Number(token)];
693
+ } else if (current !== null && typeof current === 'object' && Object.hasOwn(current, token)) {
694
+ current = (current as Record<string, unknown>)[token];
695
+ } else {
696
+ return { found: false };
697
+ }
698
+ }
699
+ return { found: true, value: current };
700
+ }
701
+
702
+ // ---------- serialization ----------
703
+
704
+ function serializeJudgment(j: Judgment): Record<string, unknown> {
705
+ return {
706
+ id: j.id,
707
+ tenantId: j.tenantId,
708
+ projectId: j.projectId,
709
+ runId: j.runId,
710
+ subject: j.subject,
711
+ item: j.item,
712
+ verdict: j.verdict,
713
+ ...(j.reason !== undefined && { reason: j.reason }),
714
+ ...(j.judgeClassId !== undefined && { judgeClassId: j.judgeClassId }),
715
+ assertedBy: j.assertedBy,
716
+ ...(j.participantId !== undefined && { participantId: j.participantId }),
717
+ createdAt: j.createdAt,
718
+ ...(j.unregisteredAt !== undefined && { unregisteredAt: j.unregisteredAt }),
719
+ ...(j.supersededBy !== undefined && { supersededBy: j.supersededBy }),
720
+ };
721
+ }
722
+
723
+ function serializeJudgmentWithCopies(j: JudgmentWithCopies): Record<string, unknown> {
724
+ return {
725
+ ...serializeJudgment(j),
726
+ run: j.run,
727
+ ...(j.itemValue !== undefined && { itemValue: j.itemValue }),
728
+ };
729
+ }
730
+
731
+ function serializeJudgeClass(k: JudgeClass): Record<string, unknown> {
732
+ return {
733
+ id: k.id,
734
+ tenantId: k.tenantId,
735
+ scope: k.scope,
736
+ name: k.name,
737
+ weight: k.weight,
738
+ ...(k.description !== undefined && { description: k.description }),
739
+ createdAt: k.createdAt,
740
+ updatedAt: k.updatedAt,
741
+ ...(k.unregisteredAt !== undefined && { unregisteredAt: k.unregisteredAt }),
742
+ };
743
+ }