@substrat-run/control-plane-api 0.98.1 → 0.104.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.
package/dist/api.d.ts CHANGED
@@ -260,7 +260,30 @@ export interface ControlPlaneApiOptions {
260
260
  * (correct for a deployment that mints no platform hostnames).
261
261
  */
262
262
  platformBaseDomains?: string[];
263
+ /**
264
+ * The CLI version advisory (#971) — what `substrat` reads off every response to nudge
265
+ * a builder whose CLI has fallen behind (`packages/cli/src/version.ts`). The control
266
+ * plane is the authority on whether a CLI is still *compatible*, which npm's `latest`
267
+ * tag is not, so the two values are deployment facts, not package metadata:
268
+ * `minVersion` the oldest CLI this platform still accepts pushes from (the floor)
269
+ * `latestVersion` the newest published CLI
270
+ * Each is emitted only when supplied, as {@link CLI_MIN_VERSION_HEADER} /
271
+ * {@link CLI_LATEST_VERSION_HEADER}. Absent ⇒ no header at all, and a CLI that reads
272
+ * nothing says nothing — a server that stays silent costs nobody anything. Advisory
273
+ * only: this package does not refuse a push below the floor.
274
+ */
275
+ cliAdvisory?: {
276
+ minVersion?: string;
277
+ latestVersion?: string;
278
+ };
263
279
  }
280
+ /**
281
+ * Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
282
+ * The CLI reads these by the same spelling (`packages/cli/src/version.ts`); exported so a
283
+ * host or a test names the header rather than restating the string.
284
+ */
285
+ export declare const CLI_MIN_VERSION_HEADER = "x-substrat-cli-min-version";
286
+ export declare const CLI_LATEST_VERSION_HEADER = "x-substrat-cli-latest-version";
264
287
  type Vars = {
265
288
  actor: PlatformActorId;
266
289
  principal: Principal;
package/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
package/dist/api.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
- import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, } from '@substrat-run/contracts';
2
+ import { parseHostname, withLabel, RESERVED_LABEL_SEPARATOR, adminAction, ASSET_PART_PREFIX, assetHash, channelName, connectionActivity, connectionActivitySource, connectionCredential, connectionFilter, connectionProbe, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, errorCode, issueStatus, issueStatusInput, listPageQuery, matchesOutboundHost, pageOf, platformRequestFilter, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, DEFAULT_DENIAL_LIMIT, DENIAL_LIMIT_MAX, registerVerticalInput, serviceDimensions, sweepRunKind, sweepRunOutcome, scopeDump, dataSubjectId as dataSubjectIdSchema, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, versionOrigin, z, PROBLEM_CONTENT_TYPE, toProblem, } from '@substrat-run/contracts';
3
+ import { attributeFailure } from './failure-attribution.js';
3
4
  import { migrationProgress, ulid } from '@substrat-run/kernel';
4
5
  import { TENANT_HEADER } from './auth.js';
5
6
  import { connectionGrantsForScope } from './vertical-client.js';
@@ -30,6 +31,13 @@ import { mintPushToken, pushActorFor } from './push-token.js';
30
31
  import { namespacesForScript } from './do-namespaces.js';
31
32
  import { backupDirectoryIfDue } from './directory-backup.js';
32
33
  import { isCustomHostname, validateBindableHostname, } from './custom-hostnames.js';
34
+ /**
35
+ * Response headers carrying the CLI version advisory (`ControlPlaneApiOptions.cliAdvisory`).
36
+ * The CLI reads these by the same spelling (`packages/cli/src/version.ts`); exported so a
37
+ * host or a test names the header rather than restating the string.
38
+ */
39
+ export const CLI_MIN_VERSION_HEADER = 'x-substrat-cli-min-version';
40
+ export const CLI_LATEST_VERSION_HEADER = 'x-substrat-cli-latest-version';
33
41
  // -- request schemas ---------------------------------------------------------
34
42
  // Parse, don't trust: every input crosses Zod at the boundary. The ids stay
35
43
  // CALLER-SUPPLIED rather than minted here, exactly as the contract has them —
@@ -253,14 +261,46 @@ const opsFailuresQuery = z.object({
253
261
  tenantId: tenantIdSchema.optional(),
254
262
  scopeId: scopeIdSchema.optional(),
255
263
  vertical: z.string().optional(),
264
+ // The version-registry id — the release-health narrowing (#1231).
265
+ version: z.string().optional(),
256
266
  operation: z.string().optional(),
257
267
  // Exact match — the lookup a CI log's `reference = <id>` line lands on.
258
268
  reference: z.string().optional(),
269
+ // The taxonomy code — the error-shape narrowing an issues view groups by (#1233).
270
+ code: errorCode.optional(),
259
271
  since: z.string().optional(),
260
272
  until: z.string().optional(),
261
273
  // Bounded by default exactly as /admin-log, and for the same reason.
262
274
  ...listPageQuery.shape,
263
275
  });
276
+ // The issues read (#1233). No cursor by design: grouping IS the compression —
277
+ // cardinality is the number of distinct failure shapes — and `limit` bounds it.
278
+ const issuesQuery = z.object({
279
+ status: issueStatus.optional(),
280
+ operation: z.string().optional(),
281
+ code: errorCode.optional(),
282
+ limit: z.coerce.number().int().min(1).max(500).optional(),
283
+ });
284
+ // The lifecycle verdict (#1233). The fingerprint rides in the BODY, not the path:
285
+ // it embeds U+001F by construction, and a path segment would force every caller
286
+ // through percent-encoding it can get subtly wrong.
287
+ const issueStatusUpdate = z.object({
288
+ fingerprint: z.string().min(1),
289
+ status: issueStatusInput,
290
+ });
291
+ const sweepRunsQuery = z.object({
292
+ kind: sweepRunKind.optional(),
293
+ unit: z.string().optional(),
294
+ outcome: sweepRunOutcome.optional(),
295
+ tenantId: tenantIdSchema.optional(),
296
+ scopeId: scopeIdSchema.optional(),
297
+ vertical: z.string().optional(),
298
+ connectionId: z.string().optional(),
299
+ since: z.string().optional(),
300
+ until: z.string().optional(),
301
+ // Bounded by default exactly as /ops-failures, and for the same reason.
302
+ ...listPageQuery.shape,
303
+ });
264
304
  const modelUsageQuery = z.object({
265
305
  tenantId: tenantIdSchema.optional(),
266
306
  scopeId: scopeIdSchema.optional(),
@@ -311,6 +351,20 @@ export function createControlPlaneApi(options) {
311
351
  const { host, authenticate, authenticateBuilder } = options;
312
352
  const admin = host.admin;
313
353
  const app = new Hono();
354
+ // The CLI version advisory (#971), stamped on EVERY response — including the 401 the
355
+ // auth middleware below answers with, since a CLI too old to authenticate is exactly
356
+ // the one that needs to hear it. Registered first so nothing can return around it.
357
+ // Nothing configured ⇒ no header touched, byte-identical to before this existed.
358
+ const advisory = options.cliAdvisory;
359
+ if (advisory?.minVersion || advisory?.latestVersion) {
360
+ app.use('*', async (c, next) => {
361
+ await next();
362
+ if (advisory.minVersion)
363
+ c.header(CLI_MIN_VERSION_HEADER, advisory.minVersion);
364
+ if (advisory.latestVersion)
365
+ c.header(CLI_LATEST_VERSION_HEADER, advisory.latestVersion);
366
+ });
367
+ }
314
368
  // Fail closed, before any route runs: no principal, no reach. Staff/service first
315
369
  // (unchanged, a superset); a builder session only when staff declines.
316
370
  app.use('*', async (c, next) => {
@@ -355,6 +409,10 @@ export function createControlPlaneApi(options) {
355
409
  // The declared permission registry of one version (D-39, #336) — owner-narrowed in the
356
410
  // handler like the versions list; the builder-facing Permissions tab reads it.
357
411
  { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/registry$/ },
412
+ // The emitted entity model of one version (#1214) — owner-narrowed the same way;
413
+ // the dashboard's Model tab reads it.
414
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/model$/ },
415
+ { method: 'GET', re: /\/verticals\/[^/]+\/versions\/[^/]+\/schedules$/ },
358
416
  { method: 'GET', re: /\/verticals\/[^/]+\/channels$/ },
359
417
  { method: 'GET', re: /\/verticals\/[^/]+\/channels\/[^/]+\/history$/ },
360
418
  { method: 'POST', re: /\/verticals\/[^/]+\/channels\/[^/]+\/promote$/ },
@@ -399,6 +457,14 @@ export function createControlPlaneApi(options) {
399
457
  // preview / provision fail. Tenant-narrowed in the handler (the forced-filter
400
458
  // pattern, like GET /scopes); the allowlist alone is not authz.
401
459
  { method: 'GET', re: /\/ops-failures$/ },
460
+ // The service→(vertical, version) join (#1231): what MY deployed scripts mean in
461
+ // signal dimensions. Tenant-narrowed in the handler (the same forced-filter
462
+ // pattern); the allowlist alone is not authz.
463
+ { method: 'GET', re: /\/service-refs$/ },
464
+ // The sweep record (#1232): when were MY connections last swept, and how did it
465
+ // go. Tenant-narrowed in the handler (the forced-filter pattern); the allowlist
466
+ // alone is not authz.
467
+ { method: 'GET', re: /\/sweep-runs$/ },
402
468
  ];
403
469
  app.use('*', async (c, next) => {
404
470
  if (c.get('principal').kind === 'builder') {
@@ -412,10 +478,21 @@ export function createControlPlaneApi(options) {
412
478
  // mask the failure it is recording, so every path through this swallows its own
413
479
  // errors. The upstream reference is extracted here — one place — so a caller
414
480
  // that only has the message still lands a searchable row.
415
- const recordFailure = (entry) => {
481
+ // Not `cause?: unknown` with an undefined check: a caught value CAN be literally
482
+ // `undefined` (`throw undefined`, a bare rejection), and that throw deserves an
483
+ // `unknown` attribution, not a skipped one. Only true omission skips.
484
+ const NO_CAUSE = Symbol('no-cause');
485
+ const recordFailure = (entry, cause = NO_CAUSE) => {
486
+ // The error SHAPE rides beside the prose (#1233): attributed here — one place,
487
+ // from the throw itself — so a fingerprint groups on a column and never has to
488
+ // regex a message. A caller with no throw in hand leaves the columns null,
489
+ // which reads as "nobody classified this" rather than a guess nobody made.
490
+ const attributed = cause === NO_CAUSE ? undefined : attributeFailure(cause);
416
491
  void admin
417
492
  .recordOpsFailure({
418
493
  ...entry,
494
+ origin: entry.origin ?? attributed?.origin ?? null,
495
+ code: entry.code ?? attributed?.code ?? null,
419
496
  reference: entry.reference ?? UPSTREAM_REFERENCE.exec(entry.message)?.[1] ?? null,
420
497
  })
421
498
  .catch(() => undefined);
@@ -474,7 +551,7 @@ export function createControlPlaneApi(options) {
474
551
  scopeId: c.req.param('scopeId') ?? null,
475
552
  status,
476
553
  message: err instanceof Error ? err.message : String(err),
477
- });
554
+ }, err);
478
555
  }
479
556
  // A 500 is, by definition, a throw whose message `mapError` did not recognise — so the
480
557
  // client gets a GENERIC body that discloses nothing, and until now nothing recorded WHAT
@@ -1350,9 +1427,10 @@ export function createControlPlaneApi(options) {
1350
1427
  tenantId,
1351
1428
  scopeId,
1352
1429
  vertical: scope.vertical,
1430
+ version: scope.verticalVersionId ?? null,
1353
1431
  status: e instanceof ControlPlaneError ? e.status : null,
1354
1432
  message,
1355
- });
1433
+ }, e);
1356
1434
  storeErrors.push(message);
1357
1435
  return undefined;
1358
1436
  }
@@ -2490,7 +2568,7 @@ export function createControlPlaneApi(options) {
2490
2568
  tenantId: input.tenantId,
2491
2569
  status: e.status,
2492
2570
  message: e.message,
2493
- });
2571
+ }, e);
2494
2572
  }
2495
2573
  return c.json({ error: e.message }, e.status);
2496
2574
  }
@@ -2562,6 +2640,68 @@ export function createControlPlaneApi(options) {
2562
2640
  const visible = p.kind === 'builder' ? all.filter((v) => v.ownerTenant === p.tenantId) : all;
2563
2641
  return c.json(pageSlice(visible, page, (v) => v.slug));
2564
2642
  });
2643
+ // -- the service→dimensions resolver (#1231's last item) -----------------------
2644
+ // What a Cloudflare service ref MEANS: each owned vertical's serving script and
2645
+ // per-version archive scripts, mapped to the signals stamp {vertical, version}
2646
+ // plus the human label beside it. A DIRECTORY read — deliberately not behind the
2647
+ // `options.observability` 501 guard, because the join must answer (for the
2648
+ // release-health and issues views) whether or not a telemetry backend is
2649
+ // configured. The dashboard's ownedServiceRefs() fetches this instead of
2650
+ // re-deriving it, so every consumer means the same thing by `version`.
2651
+ app.get('/service-refs', async (c) => {
2652
+ const p = c.get('principal');
2653
+ // The /ops-failures forced-filter pattern: a builder's tenant comes from the
2654
+ // principal, never the query. A staff/service caller must SAY whose view it
2655
+ // wants — answering fleet-wide on a forgotten param would leak, so refuse.
2656
+ const tenantId = p.kind === 'builder' ? p.tenantId : c.req.query('tenantId');
2657
+ if (!tenantId)
2658
+ throw new ControlPlaneError(400, 'tenantId is required');
2659
+ const query = z
2660
+ .object({ tenantId: tenantIdSchema, vertical: z.string().optional() })
2661
+ .parse({ tenantId, vertical: c.req.query('vertical') });
2662
+ const actor = c.get('actor');
2663
+ const all = (await admin.listVerticals(actor)).filter((v) => v.ownerTenant === query.tenantId);
2664
+ let considered = all;
2665
+ if (query.vertical !== undefined) {
2666
+ const slug = await resolveVerticalId(c, query.vertical);
2667
+ considered = all.filter((v) => v.slug === slug);
2668
+ // Non-ownership reads as absence (K-3), exactly like the per-slug registry reads.
2669
+ if (considered.length === 0)
2670
+ throw new ControlPlaneError(404, 'not found');
2671
+ }
2672
+ // Cap the per-vertical fan-out and REPORT the cut — truncating silently is the
2673
+ // defect the egress route names (#859), and the same posture holds here.
2674
+ const MAX_VERTICALS = 50;
2675
+ const verticalsTruncated = considered.length > MAX_VERTICALS;
2676
+ const entries = [];
2677
+ for (const v of considered.slice(0, MAX_VERTICALS)) {
2678
+ const versions = await admin.listVersions(actor, v.slug);
2679
+ const labelOf = new Map(versions.map((ver) => [ver.id, ver.version]));
2680
+ // The stable serving script — where real traffic lands. Its version is the
2681
+ // registry row's own servingVersionId: authoritative, not the scope-derived
2682
+ // approximation the dashboard used to make.
2683
+ if (v.servingRef && v.servingVersionId) {
2684
+ entries.push(serviceDimensions.parse({
2685
+ service: v.servingRef,
2686
+ role: 'serving',
2687
+ stamp: { vertical: v.slug, version: v.servingVersionId },
2688
+ versionLabel: labelOf.get(v.servingVersionId) ?? null,
2689
+ }));
2690
+ }
2691
+ // Each per-version archive script IS its version (previews serve these).
2692
+ for (const ver of versions) {
2693
+ if (!ver.deploymentRef)
2694
+ continue;
2695
+ entries.push(serviceDimensions.parse({
2696
+ service: ver.deploymentRef,
2697
+ role: 'archive',
2698
+ stamp: { vertical: v.slug, version: ver.id },
2699
+ versionLabel: ver.version,
2700
+ }));
2701
+ }
2702
+ }
2703
+ return c.json({ entries, verticalsTruncated });
2704
+ });
2565
2705
  app.post('/verticals', async (c) => {
2566
2706
  const input = registerVerticalInput.parse(await c.req.json());
2567
2707
  const p = c.get('principal');
@@ -2628,6 +2768,35 @@ export function createControlPlaneApi(options) {
2628
2768
  const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
2629
2769
  return c.json({ registry });
2630
2770
  });
2771
+ // The emitted entity model (#1214) one version ships: entities, field schemas, parent
2772
+ // edges, declared lifecycles — read out of the same retained manifest as the registry
2773
+ // above, and owner-narrowed the same way. `model` is null for a version pushed by a
2774
+ // pre-#1214 CLI or by a vertical with no model.json — the dashboard's Model tab renders
2775
+ // an empty state for both, and distinguishing them buys the tenant nothing.
2776
+ // #1232: the declared schedules of ONE version — what the dashboard's schedule-health
2777
+ // view joins the sweep record against. Null for a version pushed before the field.
2778
+ app.get('/verticals/:slug/versions/:id/schedules', async (c) => {
2779
+ const p = c.get('principal');
2780
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
2781
+ if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
2782
+ return c.json({ error: 'not found' }, 404);
2783
+ }
2784
+ const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
2785
+ const parsed = json ? storedDeployManifest.parse(JSON.parse(json)) : null;
2786
+ // #1232: freshness rides the same read — the manifest is already in hand, and a
2787
+ // sibling route would cost a second full parse for a field sitting beside this one.
2788
+ return c.json({ schedules: parsed?.schedules ?? null, freshness: parsed?.freshness ?? null });
2789
+ });
2790
+ app.get('/verticals/:slug/versions/:id/model', async (c) => {
2791
+ const p = c.get('principal');
2792
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
2793
+ if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
2794
+ return c.json({ error: 'not found' }, 404);
2795
+ }
2796
+ const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
2797
+ const model = json ? (storedDeployManifest.parse(JSON.parse(json)).model ?? null) : null;
2798
+ return c.json({ model });
2799
+ });
2631
2800
  // The static files (#340) one version ships: path, size, content type, content address —
2632
2801
  // read straight out of the retained manifest, which is where they were persisted for the
2633
2802
  // promote path anyway. Owner-narrowed exactly like the registry route above. `assets` is
@@ -2792,6 +2961,11 @@ export function createControlPlaneApi(options) {
2792
2961
  modules,
2793
2962
  doClasses: manifest.doClasses,
2794
2963
  bindings: [...manifest.bindings, ...storeBindings],
2964
+ // #1242: the version now being served, injected as `SUBSTRAT_VERSION_ID` so the
2965
+ // scope host stamps the signals `version` dimension. The serving script is one
2966
+ // stable script updated in place, so this refresh is what keeps the binding
2967
+ // naming the code that actually runs.
2968
+ versionId,
2795
2969
  // #1054: the model runtime is bound only for a version that ASKED for it, so the
2796
2970
  // capability is visible in the manifest diff at admit rather than fleet-wide.
2797
2971
  ...(manifest.usesModels ? { usesModels: true } : {}),
@@ -2883,7 +3057,7 @@ export function createControlPlaneApi(options) {
2883
3057
  catch (e) {
2884
3058
  const message = e instanceof Error ? e.message : String(e);
2885
3059
  console.error('stores.backfill.failed', { slug, detail: message });
2886
- recordFailure({ actor, operation: 'promote.store-backfill', vertical: slug, message });
3060
+ recordFailure({ actor, operation: 'promote.store-backfill', vertical: slug, message }, e);
2887
3061
  return { minted: [], error: message };
2888
3062
  }
2889
3063
  };
@@ -3178,6 +3352,9 @@ export function createControlPlaneApi(options) {
3178
3352
  modules,
3179
3353
  doClasses: manifest.doClasses,
3180
3354
  bindings: manifest.bindings,
3355
+ // #1242: the freshly minted version id — this archive script IS that version,
3356
+ // and a preview routed at it stamps the signals `version` dimension honestly.
3357
+ versionId: id,
3181
3358
  // #340: the verified bytes go up with the bundle. The manifest's routing config
3182
3359
  // rides along untouched — it decides what the RUNTIME does with paths, and carries
3183
3360
  // no reach, so there is nothing in it for the sandbox contract to refuse.
@@ -3210,7 +3387,7 @@ export function createControlPlaneApi(options) {
3210
3387
  tenantId: ownerTenant ?? null,
3211
3388
  status: rejected ? 422 : 502,
3212
3389
  message: detail,
3213
- });
3390
+ }, e);
3214
3391
  return rejected
3215
3392
  ? c.json({ error: 'deploy rejected', detail }, 422)
3216
3393
  : c.json({ error: 'deploy upload failed', detail }, 502);
@@ -3801,9 +3978,10 @@ export function createControlPlaneApi(options) {
3801
3978
  tenantId,
3802
3979
  scopeId: previewId,
3803
3980
  vertical: slug,
3981
+ version: opts.versionId,
3804
3982
  status: e.status,
3805
3983
  message: e.message,
3806
- });
3984
+ }, e);
3807
3985
  }
3808
3986
  throw e;
3809
3987
  }
@@ -4021,7 +4199,9 @@ export function createControlPlaneApi(options) {
4021
4199
  tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
4022
4200
  scopeId: c.req.query('scopeId'),
4023
4201
  vertical: c.req.query('vertical'),
4202
+ version: c.req.query('version'),
4024
4203
  operation: c.req.query('operation'),
4204
+ code: c.req.query('code'),
4025
4205
  reference: c.req.query('reference'),
4026
4206
  since: c.req.query('since'),
4027
4207
  until: c.req.query('until'),
@@ -4032,6 +4212,54 @@ export function createControlPlaneApi(options) {
4032
4212
  const entries = await admin.listOpsFailures(c.get('actor'), filter);
4033
4213
  return c.json(pageOf(entries, filter.limit, (e) => e.id));
4034
4214
  });
4215
+ // -- sweep runs (#1232): the durable record of what a sweep pass touched -------
4216
+ // Staff read fleet-wide (the console's later view); a builder's tenant is forced
4217
+ // from the principal, the /ops-failures posture exactly.
4218
+ app.get('/sweep-runs', async (c) => {
4219
+ const p = c.get('principal');
4220
+ const filter = sweepRunsQuery.parse({
4221
+ kind: c.req.query('kind'),
4222
+ unit: c.req.query('unit'),
4223
+ outcome: c.req.query('outcome'),
4224
+ tenantId: p.kind === 'builder' ? p.tenantId : c.req.query('tenantId'),
4225
+ scopeId: c.req.query('scopeId'),
4226
+ vertical: c.req.query('vertical'),
4227
+ connectionId: c.req.query('connectionId'),
4228
+ since: c.req.query('since'),
4229
+ until: c.req.query('until'),
4230
+ limit: c.req.query('limit'),
4231
+ cursor: c.req.query('cursor'),
4232
+ order: c.req.query('order'),
4233
+ });
4234
+ const entries = await admin.listSweepRuns(c.get('actor'), filter);
4235
+ return c.json(pageOf(entries, filter.limit, (e) => e.id));
4236
+ });
4237
+ // -- issues (#1233): failures grouped by fingerprint, with a lifecycle --------
4238
+ // Staff-only (absent from BUILDER_ROUTES): an issue is a fleet-scoped aggregate
4239
+ // with no tenant column, so the forced-filter posture cannot narrow it — a
4240
+ // builder's per-app view derives from their tenant-forced /ops-failures instead.
4241
+ app.get('/issues', async (c) => {
4242
+ const filter = issuesQuery.parse({
4243
+ status: c.req.query('status'),
4244
+ operation: c.req.query('operation'),
4245
+ code: c.req.query('code'),
4246
+ limit: c.req.query('limit'),
4247
+ });
4248
+ const entries = await admin.listIssues(c.get('actor'), filter);
4249
+ // No cursor, deliberately — see issuesQuery. `entries` alone, so a client
4250
+ // never walks a continuation that cannot exist.
4251
+ return c.json({ entries });
4252
+ });
4253
+ // The staff verdict on one issue: resolve, ignore, or reopen. `regressed` is
4254
+ // ingest's word and the input schema refuses it. 404 for an unknown fingerprint
4255
+ // rather than an invented row.
4256
+ app.put('/issues/status', async (c) => {
4257
+ const input = issueStatusUpdate.parse(await c.req.json());
4258
+ const updated = await admin.setIssueStatus(c.get('actor'), input.fingerprint, input.status);
4259
+ if (!updated)
4260
+ return c.json({ error: 'unknown issue fingerprint' }, 404);
4261
+ return c.json(updated);
4262
+ });
4035
4263
  // -- model usage (#1054): meter 3, the one D-30 could not compute -------------
4036
4264
  // The ledger the `model-usage` intents drain into. Staff-only: the tenant-facing
4037
4265
  // read is the vertical's own usage screen over its metering engine; this is the