@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 +23 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +236 -8
- package/dist/api.js.map +1 -1
- package/dist/cf-observability.js +1 -1
- package/dist/cf-observability.js.map +1 -1
- package/dist/connect-url.d.ts +52 -0
- package/dist/connect-url.d.ts.map +1 -0
- package/dist/connect-url.js +129 -0
- package/dist/connect-url.js.map +1 -0
- package/dist/custom-hostnames.d.ts +6 -1
- package/dist/custom-hostnames.d.ts.map +1 -1
- package/dist/custom-hostnames.js +1 -1
- package/dist/custom-hostnames.js.map +1 -1
- package/dist/deploy.d.ts +5 -0
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +9 -0
- package/dist/deploy.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +4 -2
- package/dist/observability.d.ts.map +1 -1
- package/dist/platform-drain.d.ts +18 -0
- package/dist/platform-drain.d.ts.map +1 -1
- package/dist/platform-drain.js +54 -1
- package/dist/platform-drain.js.map +1 -1
- package/dist/wfp.d.ts.map +1 -1
- package/dist/wfp.js +12 -1
- package/dist/wfp.js.map +1 -1
- package/package.json +5 -5
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":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;
|
|
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
|
-
|
|
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
|