@kindgi/api 0.1.4-rc.2 → 0.1.4-rc.4

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.
@@ -385,6 +385,15 @@ const ApprovalRequiredRoleQueryParam: ParameterSpec = {
385
385
  schema: { $ref: '#/components/schemas/ReviewerRole' },
386
386
  };
387
387
 
388
+ const WaitTokenIdQueryParam: ParameterSpec = {
389
+ name: 'waitTokenId',
390
+ in: 'query',
391
+ required: false,
392
+ description:
393
+ "Only approvals linked to one of these run waits (an approval's `waitTokenId`; a run's journal names its open waits in `wait.suspended` entries). Repeat it for several, at most 50.",
394
+ schema: { type: 'array', items: { type: 'string', minLength: 1 }, maxItems: 50 },
395
+ };
396
+
388
397
  const CreatedAfterQueryParam: ParameterSpec = {
389
398
  name: 'createdAfter',
390
399
  in: 'query',
@@ -1612,6 +1621,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
1612
1621
  ApprovalStatusQueryParam,
1613
1622
  ApprovalRequiredRoleQueryParam,
1614
1623
  CreatedAfterQueryParam,
1624
+ WaitTokenIdQueryParam,
1615
1625
  ],
1616
1626
  responses: {
1617
1627
  '200': { description: 'Page of approvals.', schema: ref('ApprovalCollectionPage') },
@@ -1962,7 +1972,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
1962
1972
  operationId: 'agents.promotions.create',
1963
1973
  summary: 'Make a version live for a scope',
1964
1974
  description:
1965
- "Pins `version` live for `scope`: runs in that scope that don't name a version use it, from the next run. Open conversations keep their version. The version must be registered and active. Every promotion is recorded, with who asked and why. Needs `promote` on the agent.\n\nWith a gate policy for the scope (`GET …/gate-policy`), the promotion is checked first against the comparison named by `evalRunId`: `201` promoted; `202` the gate passed and the policy wants a reviewer's approval (a HITL approval, subject `agent-promotion`; the live version moves once it's approved, if nothing changed meanwhile); `422 gate-failed` with `details.promotionId`, `details.policy` and every check in `details.checks`. A refused promotion is recorded too.",
1975
+ "Pins `version` live for `scope`: runs in that scope that don't name a version use it, from the next run. Open conversations keep their version. The version must be registered and active. Every promotion is recorded, with who asked and why. Needs `promote` on the agent.\n\nWith a gate policy for the scope (`GET …/gate-policy`), the promotion is checked first against the comparison named by `evalRunId`: `201` promoted; `202` the gate passed and the policy wants a reviewer's approval (a HITL approval, subject `agent-promotion`; the live version moves once it's approved, if nothing changed meanwhile); `422 gate-failed` with `details.promotionId`, `details.policy` and every check in `details.checks`. A refused promotion is recorded too.\n\nA pin in place skips the gate: when the scope has no live version of its own and already serves exactly `version` (from a scope above, or as the latest), pinning it there changes nothing any run gets, so it is promoted (`201`) with one passing `pinInPlace` check, no comparison and no approval, its reason starting `pin-in-place`. It is re-checked as it is written; if the scope moved meanwhile, `409 promotion-superseded`: check again.",
1966
1976
  tags: ['agents'],
1967
1977
  security: 'bearer',
1968
1978
  parameters: [AgentIdPathParam, IdempotencyKeyParam],
@@ -1978,7 +1988,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
1978
1988
  'The version is not registered, or was unregistered; or the eval run is not found.',
1979
1989
  ),
1980
1990
  '409': ErrorResponse(
1981
- "`promotion-superseded`: the scope's live version changed while the gate ran; check again.",
1991
+ "`promotion-superseded`: the scope's live version changed while the gate ran; check again. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.",
1982
1992
  ),
1983
1993
  '422': ErrorResponse(
1984
1994
  '`gate-failed`: the gate refused it. `details.checks` has every check; the refusal is recorded (`details.promotionId`).',
@@ -2076,7 +2086,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
2076
2086
  ...CommonMutationErrors,
2077
2087
  '400': ErrorResponse('Validation failed (see `details.issues`).'),
2078
2088
  '409': ErrorResponse(
2079
- '`gate-policy-already-registered`: that (id, version) exists. `gate-policy-scope-taken`: another policy gates the agent for the scope (`details.heldBy`). `gate-policy-scope-changed`: the version would change the agent or scope. `gate-policy-scope-unpinned`: nothing covering the scope is pinned, so a published version would go live there ungated; pin a version for the scope, or one above it, first.',
2089
+ '`gate-policy-already-registered`: that (id, version) exists. `gate-policy-scope-taken`: another policy gates the agent for the scope (`details.heldBy`). `gate-policy-scope-changed`: the version would change the agent or scope. `gate-policy-scope-unpinned`: the scope has no live version of its own; a gated scope holds its own pin (a pin above it is not enough, since a promotion there would change this scope ungated), so pin a version for the scope first.',
2080
2090
  ),
2081
2091
  },
2082
2092
  },
@@ -2158,6 +2168,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
2158
2168
  '200': { description: 'The reinstated version.', schema: ref('GatePolicy') },
2159
2169
  ...CommonMutationErrors,
2160
2170
  '404': ErrorResponse('No such version.'),
2171
+ '409': ErrorResponse(
2172
+ '`gate-policy-scope-taken`: another policy gates the agent for the scope now (`details.heldBy`). `gate-policy-scope-unpinned`: the scope has no live version of its own; pin one first.',
2173
+ ),
2161
2174
  },
2162
2175
  },
2163
2176
  {
@@ -2215,7 +2228,9 @@ export const OPERATIONS: readonly OperationSpec[] = [
2215
2228
  '200': { description: 'Rolled back.', schema: ref('Promotion') },
2216
2229
  ...CommonMutationErrors,
2217
2230
  '404': ErrorResponse('`toVersion` is not registered, or was unregistered.'),
2218
- '409': ErrorResponse('The scope has no pin, or no earlier version to go back to.'),
2231
+ '409': ErrorResponse(
2232
+ '`not-pinned`: the scope has no pin of its own. `nothing-to-roll-back`: no earlier version to go back to. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.',
2233
+ ),
2219
2234
  },
2220
2235
  },
2221
2236
  {
@@ -2234,7 +2249,7 @@ export const OPERATIONS: readonly OperationSpec[] = [
2234
2249
  '200': { description: 'Unpinned.', schema: ref('Promotion') },
2235
2250
  ...CommonMutationErrors,
2236
2251
  '409': ErrorResponse(
2237
- '`not-pinned`: the scope has no pin of its own. `gate-policy-needs-pin`: unpinning would leave a scope a gate policy applies to on the latest version, where publishing goes live ungated.',
2252
+ '`not-pinned`: the scope has no pin of its own. `gate-policy-needs-pin`: the scope has a gate policy of its own, and a gated scope keeps its own pin; unregister the policy first, or roll back instead. `gate-policy-descendant-unpinned`: the change would also move a narrower scope a gate policy applies to, which has no live version of its own and so follows this scope, without its gate; the message names each one: pin it at its current version first.',
2238
2253
  ),
2239
2254
  },
2240
2255
  },
@@ -184,6 +184,7 @@ export function mountAgentReleaseRoutes(
184
184
  passed: gate.result.passed,
185
185
  ...(gate.result.approval !== undefined && { approval: gate.result.approval }),
186
186
  servingVersion: gate.servingVersion as Semver,
187
+ ...(gate.pinInPlace && { pinInPlace: true }),
187
188
  },
188
189
  });
189
190
  if (outcome.kind === 'err') return failed(c, requestId, outcome.error);
@@ -441,10 +442,32 @@ function summaryOf(run: EvalRun): JudgedComparisonSummary | null {
441
442
  : null;
442
443
  }
443
444
 
445
+ /** Whether two live scopes are the same scope. */
446
+ function sameScope(a: LiveScope, b: LiveScope): boolean {
447
+ switch (a.kind) {
448
+ case 'tenant':
449
+ return b.kind === 'tenant';
450
+ case 'org':
451
+ return b.kind === 'org' && b.orgId === a.orgId;
452
+ case 'project':
453
+ return b.kind === 'project' && b.projectId === a.projectId;
454
+ case 'segment':
455
+ return (
456
+ b.kind === 'segment' &&
457
+ b.projectId === a.projectId &&
458
+ b.path.length === a.path.length &&
459
+ b.path.every((s, i) => s.key === a.path[i]?.key && s.value === a.path[i]?.value)
460
+ );
461
+ }
462
+ }
463
+
444
464
  /**
445
465
  * The gate for a promotion request: what serves the scope now, the
446
466
  * comparison it names, and the policy's checks against them. With no
447
- * policy there's nothing to check.
467
+ * policy there's nothing to check. A pin in place (the scope has no pin
468
+ * of its own and serves exactly the version already) changes nothing any
469
+ * run gets, so the policy's checks and approval don't apply: one passing
470
+ * `pinInPlace` check, and `pinInPlace` for the binding to re-check.
448
471
  */
449
472
  async function runGate(
450
473
  registry: AgentRegistryBinding,
@@ -459,7 +482,7 @@ async function runGate(
459
482
  },
460
483
  policy: GatePolicy | null,
461
484
  ): Promise<
462
- | { kind: 'ok'; result: GateResult; servingVersion: string }
485
+ | { kind: 'ok'; result: GateResult; servingVersion: string; pinInPlace?: boolean }
463
486
  | { kind: 'err'; error: { code: string; message: string } }
464
487
  > {
465
488
  const { tenantId, agentId } = req;
@@ -483,6 +506,27 @@ async function runGate(
483
506
  servingVersion = latest.version as unknown as string;
484
507
  }
485
508
  if (policy === null) return { kind: 'ok', result: { checks: [], passed: true }, servingVersion };
509
+ if (servingVersion === (req.version as unknown as string)) {
510
+ const pins = await releases.live.list({ tenantId, agentId });
511
+ if (!pins.some((pin) => sameScope(pin.scope, req.scope))) {
512
+ const from = live === null ? 'as the latest version' : 'from a pin above it';
513
+ return {
514
+ kind: 'ok',
515
+ result: {
516
+ checks: [
517
+ {
518
+ name: 'pinInPlace',
519
+ passed: true,
520
+ message: `${agentId} ${servingVersion} already serves this scope (${from}), so pinning it here changes nothing for runs: the gate's checks and approval don't apply.`,
521
+ },
522
+ ],
523
+ passed: true,
524
+ },
525
+ servingVersion,
526
+ pinInPlace: true,
527
+ };
528
+ }
529
+ }
486
530
 
487
531
  let summary: JudgedComparisonSummary | null = null;
488
532
  if (req.evalRunId !== undefined) {
@@ -57,6 +57,10 @@ const DECISION_KINDS: ReadonlySet<ReviewDecisionKind> = new Set([
57
57
 
58
58
  const ROLE_VALUES: ReadonlySet<ReviewerRole> = new Set(['standard', 'senior', 'admin']);
59
59
 
60
+ /** How many `waitTokenId` values a list takes, and how long each may be. */
61
+ const MAX_WAIT_TOKEN_IDS = 50;
62
+ const MAX_WAIT_TOKEN_ID_LENGTH = 512;
63
+
60
64
  /**
61
65
  * Terminal approval statuses — the ones an audit bundle can be exported for.
62
66
  * An `expired` approval has no recorded decision.
@@ -214,6 +218,23 @@ export function approvalsRouter(
214
218
  createdAfterIso = parsed.toISOString();
215
219
  }
216
220
 
221
+ const waitTokenIds = c.req.queries('waitTokenId') ?? [];
222
+ if (
223
+ waitTokenIds.length > MAX_WAIT_TOKEN_IDS ||
224
+ waitTokenIds.some((t) => t.length === 0 || t.length > MAX_WAIT_TOKEN_ID_LENGTH)
225
+ ) {
226
+ c.status(statusFor('bad-input') as never);
227
+ return c.json(
228
+ toWireError(
229
+ {
230
+ code: 'bad-input',
231
+ message: `\`waitTokenId\` takes at most ${MAX_WAIT_TOKEN_IDS} values, each 1–${MAX_WAIT_TOKEN_ID_LENGTH} characters`,
232
+ },
233
+ requestId,
234
+ ),
235
+ );
236
+ }
237
+
217
238
  const cursor = c.req.query('cursor');
218
239
  if (cursor !== undefined && cursor.length > 0) {
219
240
  const parsed = new Date(cursor);
@@ -238,6 +259,7 @@ export function approvalsRouter(
238
259
  ...(requiredRoleFilter !== undefined && { requiredRole: requiredRoleFilter }),
239
260
  ...(createdAfterIso !== undefined && { since: createdAfterIso as unknown as Timestamp }),
240
261
  ...(cursor !== undefined && cursor.length > 0 && { cursor: cursor as Cursor }),
262
+ ...(waitTokenIds.length > 0 && { waitTokenIds }),
241
263
  };
242
264
  const listed = await hitlBinding.listApprovals(listInput);
243
265
  if (listed.kind === 'err') {