Files
2026-07-13 12:58:18 +08:00

2570 lines
107 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* railway-envs.ts — Single source of truth for Railway IDs used by all
* TypeScript showcase tooling. Only the PROJECT_ID and the two env IDs
* (PRODUCTION_ENV_ID / STAGING_ENV_ID) are also hardcoded in
* `showcase/bin/railway` (Ruby) — if those three drift, prefer this file
* and reconcile bin/railway by hand. Per-service data is NOT mirrored by
* hand: the Ruby side consumes it via `railway-envs.generated.json`
* (see ServiceEntry.legacyJsonCompat).
*
* - PROJECT_ID is the CopilotKit Showcase Railway project.
* - ENV_IDS maps human env names (and common synonyms) to Railway env IDs.
* (bin/railway's Ruby ENV_IDS additionally accepts the "stage" spelling —
* a Ruby-side-only synonym not mirrored here; add it to BOTH registries'
* tooling deliberately if the divergence ever needs reconciling.)
* - SERVICES is the per-service map of serviceId + a per-env `environments`
* record. Each environment carries its own serviceInstance ID, public
* domain (optional — domainless workers omit it), probe flag (optional,
* defaults true), and GHCR repo-name override (optional).
*
* Service-instance IDs are env-scoped, so prod and staging IDs differ for
* the same service. Use instanceIdFor(serviceName, env) to get the right one.
*
* SCHEMA (Option C — unified env map): a service's per-env data lives under
* `environments[envName]`, keyed by the SAME env names the rest of the
* tooling uses ("prod" / "staging"). A service that exists in only one env
* (e.g. a staging-only worker) simply omits the other key — there is no
* placeholder ID. The single env-independent `probeDriver` is hoisted to the
* entry (it never differed per env). `EnvName` is an OPEN string so future
* env names (preview, canary, …) need only a registry entry, not a type
* widening — accessors resolve any registered env name.
*/
export const PROJECT_ID = "6f8c6bff-a80d-4f8f-b78d-50b32bcf4479";
export const PRODUCTION_ENV_ID = "b14919f4-6417-429f-848d-c6ae2201e04f";
export const STAGING_ENV_ID = "8edfef02-ea09-4a20-8689-261f21cc2849";
/**
* Env name. OPEN string (not a closed union) so the SSOT can grow new env
* names without a type change; the per-env-id registry below is what makes
* an env name resolvable. Today the only populated env names are "prod" and
* "staging", but accessors index `environments[env]` generically.
*/
export type EnvName = string;
// Accept common synonyms ("production", "prod", "staging") and normalize.
export const ENV_IDS: Record<string, string> = {
prod: PRODUCTION_ENV_ID,
production: PRODUCTION_ENV_ID,
staging: STAGING_ENV_ID,
};
/**
* Canonical env-name → Railway env-id registry. Keyed by the env names used
* as `environments` keys (NOT the synonyms). This is the contract for
* turning an arbitrary registered env name into its Railway env id — the
* redeploy path resolves env ids through it. Extend this (and add the
* env to a service's `environments`) to introduce a new env name.
*/
export const ENV_ID_BY_NAME: Record<string, string> = {
prod: PRODUCTION_ENV_ID,
staging: STAGING_ENV_ID,
};
/**
* Resolve a human-supplied env spelling (case-insensitive, whitespace
* tolerated) to the canonical `{ env, envId }` pair.
*
* Derived ENTIRELY from the two registries above so there is ONE authority
* for env names: ENV_IDS supplies the accepted spellings (synonyms), and
* the canonical name is whichever ENV_ID_BY_NAME key carries the same
* env-id. Registering a new env (a canonical entry in ENV_ID_BY_NAME plus
* at least its own spelling in ENV_IDS) makes it resolvable here with no
* code change — the open-env contract.
*
* THROWS on an unknown spelling, and THROWS on a mis-wired registry (an
* ENV_IDS spelling whose env-id has no canonical ENV_ID_BY_NAME name).
*/
export function resolveEnv(name: string): { env: EnvName; envId: string } {
const lower = name.trim().toLowerCase();
// Own-key lookup: a bare `ENV_IDS[lower]` truthiness check would resolve
// inherited Object.prototype keys (e.g. "constructor") to truthy non-IDs.
if (!Object.hasOwn(ENV_IDS, lower)) {
throw new Error(
`Unknown env "${name}". Use one of: ${Object.keys(ENV_IDS).join(", ")}.`,
);
}
const envId = ENV_IDS[lower];
// Canonical name = the ENV_ID_BY_NAME key carrying this env-id. Computed
// per call (the registry is tiny) so a runtime-registered env resolves
// without a rebuild step.
const env = Object.keys(ENV_ID_BY_NAME).find(
(n) => ENV_ID_BY_NAME[n] === envId,
);
if (env === undefined) {
throw new Error(
`Env spelling "${lower}" (ENV_IDS) maps to env-id "${envId}", which has no canonical name in ENV_ID_BY_NAME — register the canonical env name there.`,
);
}
return { env, envId };
}
/**
* The probe driver selects which feature-level verifier `verify-deploy.ts`
* runs against a service's URL. "200 ≠ healthy": every driver does more
* than a naked GET. See showcase/scripts/verify-deploy.ts for the per-driver
* implementation; the SSOT only names the driver here.
*
* - "shell" / "docs" / "dashboard" / "dojo" — Next.js shells: load the page
* and assert a known DOM string plus a known network call.
* - "harness" / "eval" — synthetic e2e fixture against the service's API.
* - "aimock" — fixture replay with deterministic-response drift check.
* - "pocketbase" — admin login + known collection list.
* - "webhooks" — synthetic event POST and downstream confirmation.
* - "agent" — generic agent backend (the showcase-* integration services);
* feature-level fixture call into the integration's /api endpoint.
* - "starter" — the starter-template container fleet (`starter-<slug>`).
* Verified by the verify-deploy baseline driver (deployment-SUCCESS +
* HTTP 200 on `/`) in `verify-deploy.drivers.starter.ts`, exactly like the
* Next.js shells — the starters EXPOSE only their Next.js frontend (serving
* `/` + `/api/copilotkit`, NO `/api/health`), so `/` is the only correct
* healthcheck. Starters are always-on + staging-probed, so they enter the
* verify-deploy staging matrix like every other managed showcase service.
* (They are ALSO covered by the harness `starter_smoke` axis — railway-
* services source, namePrefix "starter-", writing `starter:<column-slug>/
* <level>` rows — which is orthogonal to the baseline liveness probe here.)
*/
export type ProbeDriver =
| "shell"
| "harness"
| "eval"
| "aimock"
| "pocketbase"
| "webhooks"
| "dojo"
| "docs"
| "dashboard"
| "agent"
| "starter";
/**
* Provisioning configuration for the `harness-workers` pool fleet. Carried on
* `ServiceEntry.workerProvisioning` and ONLY populated for `harness-workers`.
*
* The worker model is strictly 1-worker-per-replica: Railway runs ONE worker
* process per replica container (keyed on HOSTNAME), so the REAL live worker
* count equals the EFFECTIVE replica count. `HARNESS_POOL_COUNT` is the
* control-plane's informational "expected worker count" hint — it does NOT fork
* additional workers per replica and MUST NOT be treated as a multiplier or fork
* factor. The authoritative concurrency knob per worker is
* `BROWSER_POOL_MAX_CONTEXTS`.
*
* EFFECTIVE REPLICA COUNT — `multiRegionConfig.<region>.numReplicas`, NOT the
* top-level `numReplicas`. The harness-workers service is single-region
* (us-west2), and Railway derives the LIVE running replica count from the
* per-region `multiRegionConfig.us-west2.numReplicas` field. The top-level
* `numReplicas` is the legacy/aggregate knob; on a multiRegion service it is
* Railway-maintained to mirror the region sum but it is NOT the field the
* deploy honors. The SSOT therefore models `multiRegionConfig.us-west2.
* numReplicas` as the authoritative `effectiveReplicas` and keeps the
* top-level `numReplicas` only as a documented mirror. Verified live
* 2026-06-26 via the Railway GraphQL `environment.config` staged-config read:
* BOTH envs carry `deploy.multiRegionConfig = {"us-west2":{"numReplicas":6}}`.
*
* This is a DECLARE-AND-VERIFY record. The tooling (`emit-railway-envs-json.ts`,
* `bin/railway`) is VERIFY-ONLY with respect to the replica count — it does not
* write replica counts to Railway. Applying a replica-count change is a MANUAL
* operation (Railway Dashboard > Service > Settings > Replicas, which edits the
* per-region `multiRegionConfig.us-west2.numReplicas`, or the Railway GraphQL
* API). See `showcase/RAILWAY.md` for the manual procedure.
*/
export interface WorkerProvisioning {
/**
* EFFECTIVE replica count — the AUTHORITATIVE worker-count field. This is the
* `multiRegionConfig.us-west2.numReplicas` value Railway actually uses to
* derive the LIVE running replica count for this single-region (us-west2)
* service. The drift gate watches THIS field because it is the one that
* drives reality; the top-level `numReplicas` mirror below does NOT (Railway
* keeps it in sync as an aggregate, but the deploy honors the per-region
* config). Strictly 1:1 with live worker processes — each replica runs
* exactly ONE worker process (keyed on HOSTNAME). `HARNESS_POOL_COUNT` is
* informational only.
*/
effectiveReplicas: number;
/**
* Top-level Railway `numReplicas` field for this env. Retained as a
* DOCUMENTED MIRROR of `effectiveReplicas` (Railway keeps it equal to the
* region sum on a multiRegion service), NOT as an authoritative knob — the
* deploy honors `multiRegionConfig.<region>.numReplicas` (`effectiveReplicas`).
* Kept here so the SSOT records both and makes the effective-vs-mirror
* distinction explicit. For harness-workers (single region) it always equals
* `effectiveReplicas`.
*/
numReplicas: number;
/**
* Per-worker in-process concurrency budget: the `BROWSER_POOL_MAX_CONTEXTS`
* env var that caps how many Playwright browser contexts each worker may hold
* open simultaneously. NOT a per-fleet total; the fleet-wide budget is
* `effectiveReplicas × BROWSER_POOL_MAX_CONTEXTS`.
*/
BROWSER_POOL_MAX_CONTEXTS: number;
/**
* INFORMATIONAL ONLY — the `HARNESS_POOL_COUNT` env var forwarded to each
* worker as a control-plane "expected worker count" hint. The worker code
* does NOT fork additional processes per pool count; it runs exactly one
* process per replica. The authoritative concurrency knob is
* `BROWSER_POOL_MAX_CONTEXTS`. This field is recorded here purely for
* operational visibility / config-audit purposes.
*/
HARNESS_POOL_COUNT?: number;
/**
* DEPLOY-ROLLOVER CAPACITY FLOOR (seconds) — the Railway
* `serviceInstance.overlapSeconds` setting (env mirror
* `RAILWAY_DEPLOYMENT_OVERLAP_SECONDS`). Railway keeps the OLD deployment
* serving for this many seconds after the NEW deployment goes Active, so the
* live worker count never dips during a rollover (no staleness dip while new
* workers boot, register on the roster, and start claiming). This is PURE
* RAILWAY CONFIG — there is no custom rolling-restart code; the mechanism is
* the service setting alone. Composes with the layer-(b) graceful drain
* (`DEFAULT_WORKER_DRAIN_GRACE_MS`) and the layer-(a) reaper backstop. See
* `showcase/RAILWAY.md` "Deploy rollover" for the rationale and how to apply
* it (GraphQL `serviceInstanceUpdate` / dashboard). DECLARE-AND-VERIFY: the
* tooling is verify-only with respect to this field; applying it is a manual
* Railway operation.
*/
overlapSeconds?: number;
/**
* GRACEFUL-DRAIN WINDOW (seconds) — the Railway
* `serviceInstance.drainingSeconds` setting (env mirror
* `RAILWAY_DEPLOYMENT_DRAINING_SECONDS`): the SIGTERM→SIGKILL window the
* platform grants a draining worker before hard-killing it. Sized to HOST the
* shipped composed worker-drain budget (layer b): the 3s deregister cap
* (`DRAIN_DEREGISTER_TIMEOUT_MS`) + the 90s finish-and-report grace
* (`DEFAULT_WORKER_DRAIN_GRACE_MS`) + the small serial teardown remainder, all
* of which must fit under `PLATFORM_STOP_GRACE_MS` (180s). Keeping this ≥
* `PLATFORM_STOP_GRACE_MS` lets a worker finish and report its in-flight cell
* before the kill instead of having the drain cut short. PURE RAILWAY CONFIG
* (no custom code). See `showcase/RAILWAY.md` "Deploy rollover" + the
* `PLATFORM_STOP_GRACE_MS` doc in `worker-loop.ts` (the C3 requirement). If the
* layer-(b) grace is retuned, raise this in lockstep.
*/
drainingSeconds?: number;
}
/**
* Per-env configuration for a service. One of these lives under each key of
* `ServiceEntry.environments`. A service that exists in only one env has a
* single key here (no placeholder for the missing env).
*
* - `instanceId` — the env-scoped Railway serviceInstance ID. Always set.
* - `domain` — public host (no scheme) for this env. OPTIONAL: domainless
* workers (queue consumers with no HTTP surface) omit it. `domainFor`
* throws when asked for a missing domain so the verify probe fails loud
* rather than silently probing "".
* - `probe` — whether `verify-deploy --env <env>` probes this service in
* this env. OPTIONAL; defaults to `true` (the historic default for every
* service that exists in an env). Domainless workers set `probe: false`.
* - `repoName` — GHCR repo-name override for this env. OPTIONAL. When unset,
* the gate expects `ghcr.io/copilotkit/<serviceName>:<tag>`; when set, it
* uses `ghcr.io/copilotkit/<repoName>:<tag>` for THIS env only.
* - `healthcheckPath` — Railway HTTP healthcheck path for this env (the path
* Railway probes to mark a deploy healthy). OPTIONAL; OMITTED ⇒ "do not
* assert" (Railway default / null — TCP-port liveness, no HTTP path). This
* is the SSOT the promote pin re-asserts on every promote so a prod
* instance whose healthcheckPath silently went null (the aimock incident)
* self-heals to the tracked value. MUST encode the LIVE Railway value
* verbatim per env — a wrong path 404s and WEDGES the deploy forever. A
* live-null service OMITS this field entirely; it is NEVER written as `/`
* or `/api/health` (and the pin mutation OMITS the key when absent rather
* than sending `null`, which would actively CLEAR it).
*/
export interface EnvironmentConfig {
/** env-scoped Railway serviceInstance ID. */
instanceId: string;
/** Public host (no scheme). Omitted for domainless workers. */
domain?: string;
/** Probe this env in verify-deploy? Defaults to true when omitted. */
probe?: boolean;
/** Per-env GHCR repo-name override. Defaults to the service name. */
repoName?: string;
/**
* Railway HTTP healthcheck path for this env. OPTIONAL; omitted ⇒ do not
* assert (Railway default / null). Encode the LIVE value verbatim.
*/
healthcheckPath?: string;
}
export interface ServiceEntry {
/** Railway service ID (env-independent). */
serviceId: string;
/**
* Per-env configuration, keyed by env name ("prod" / "staging" / …).
* A service present in only one env carries only that key.
*/
environments: Record<string, EnvironmentConfig>;
/**
* Feature-level verify-deploy driver for this service (env-independent —
* it never differed per env). See ProbeDriver.
*/
probeDriver: ProbeDriver;
/**
* True iff this service is built and pushed by `showcase_build.yml`.
* webhooks is a first-party GHCR image but is built by its own repo's
* release workflow — it MUST NOT be touched by the showcase build's
* default staging redeploy scope. (pocketbase IS showcase-CI-built:
* its matrix slot is gated to `showcase/pocketbase/**` changes.)
*/
ciBuilt: boolean;
/**
* True iff `verify-railway-image-refs.ts` validates this service's
* image refs. As of WS-C completion this is `true` for every service
* in `SERVICES` except the `gateIgnore` `harness-workers` entry — the
* historic Phase-2 deferral on dashboard, docs,
* dojo, shell, and harness has been retired. New services added to
* the SSOT MUST land with `gateValidated: true` (and a per-env
* `repoName` if the Railway service name does not match the GHCR repo
* name); use the optional `gateIgnore: true` field only for
* deliberately-untracked third-party / domainless / single-env services.
*/
gateValidated: boolean;
/**
* SSOT key of the CI-built service whose GHCR image this service RUNS.
*
* Models explicit image consumption for services that share another
* service's image instead of having their own build slot (e.g.
* `harness-workers` runs the same `showcase-harness` image as the
* `harness` scheduler). `redeploy-env.ts` expands the redeploy scope
* so a rebuilt image redeploys ALL its consumers — without this, a
* main-merge rebuild of `showcase-harness:latest` only bounced the
* scheduler and the workers silently kept the stale image.
*
* Constraints (enforced fail-loud at module load by
* `assertImageConsumersValid`):
* - must point at an existing SSOT key;
* - the target must be `ciBuilt: true` (consuming a non-CI-built
* image can never put the consumer in the CI redeploy scope);
* - the consumer itself must NOT be `ciBuilt` (a build slot is its
* own image producer — no consumer-of-consumer chains);
* - the consumer's declared `environments` must be a NON-EMPTY subset
* of its `imageOf` producer's environments (a consumer env the
* producer never builds for would run a never-rebuilt image there,
* and a consumer with zero declared envs would never be redeployed
* in any env — both are rejected at module load).
*
* The expansion is env-aware: a consumer only enters an env's redeploy
* scope if it declares that env (the staging-only worker never enters
* the prod scope). Omit for any service with its own build slot or a
* pinned/out-of-band image that must NOT follow rebuilds.
*/
imageOf?: string;
/**
* Opt-out flag for the image-ref gate. When `true`, the gate ignores
* this service entirely in BOTH the SSOT→Railway direction (no
* "missing from Railway" failure if absent) AND the Railway→SSOT
* direction (no "untracked Railway service" failure if Railway has
* a service with this name that is not WS4-managed). Default: false.
*
* Intentionally narrow: this exists for deliberately-untracked
* third-party relays, domainless workers, or single-env services. The
* default for every WS4-managed service is `false` (omitted).
*/
gateIgnore?: boolean;
/**
* Promote ordering tier for the equivalence-gated cluster promote
* (`computePromoteClosure`). Lower tiers pin+verify BEFORE higher tiers,
* and a tier gates its dependents (a stale aimock/harness under fresh
* integrations = a non-equivalent prod):
* - 0 — shared infra the whole cluster runs against (`aimock`,
* `pocketbase`, `webhooks`);
* - 1 — the verification control plane + dashboard
* (`harness`, `harness-workers`, `dashboard`), ALWAYS pulled into an
* equivalence-gated promote so the post-promote re-sweep + dashboard
* read run against the just-promoted harness/PB;
* - 2 — integrations and shells (the leaf set an operator names).
* OPTIONAL: defaults to 2 when omitted (the leaf default). This field is
* promote-only — it does NOT affect the staging redeploy scope.
*/
promoteTier?: 0 | 1 | 2;
/**
* STANDALONE service: a leaf that neither depends on anything nor gates on
* anything. When set, `computePromoteClosure` (and the resolve-promote-targets
* jq mirror) (a) do NOT pull the always-on Tier-1 verification set into a
* promote whose REQUESTED set is entirely standalone — so promoting it alone
* promotes ONLY itself, not the whole control plane — and (b) the promote
* fleet runner attempts it UNGATED and never records it NOT-ATTEMPTED because
* an unrelated service failed. Use for self-contained services with no runtime
* dependency on the equivalence control plane (e.g. the `docs` shell). A
* standalone service must therefore declare no `runtimeDeps`. OPTIONAL;
* omitted = false (the normal tier-gated leaf).
*/
standalone?: boolean;
/**
* SSOT keys this service needs PRESENT-AND-CURRENT in the target env for
* its own promote to be meaningful — the transitive runtime dependencies
* `computePromoteClosure` pulls into the promote closure. Distinct from
* `imageOf` (image-sharing for the redeploy scope): `runtimeDeps` models
* "this service talks to that service at runtime", e.g. each `agent`
* integration → `["aimock"]` (it routes LLM traffic at the env-local
* aimock) and the `dashboard` → `["pocketbase","harness"]` (it reads the
* PB rows the harness writes). OPTIONAL; omitted = no runtime deps. Every
* entry must be an existing SSOT key (enforced by `assertClosureValid`).
*/
runtimeDeps?: string[];
/**
* Cross-service env-var references this service carries in the target env
* — an env var (`key`) whose VALUE must point at another SSOT service's
* (`target`) env-LOCAL host (prod→prod, staging→staging). e.g. an agent's
* `OPENAI_BASE_URL` must resolve to the env's own aimock, never the other
* env's. The Stage-2 Ruby preflight (U5) ASSERTS these (REFUSE on
* mismatch — the `ms-agent-dotnet` cross-env class); it never copies a
* value. OPTIONAL; omitted = no service refs. Every `target` must be an
* existing SSOT key (enforced by `assertClosureValid`).
*/
serviceRefs?: { key: string; target: string }[];
/**
* Ruby/jq-BOUNDARY COMPATIBILITY SHIM — read ONLY by
* `emit-railway-envs-json.ts`, never by any TS accessor.
*
* The generated `railway-envs.generated.json` is consumed by
* `showcase/bin/railway` (Ruby) and workflow jq, which still expect the
* legacy per-service shape `{ prodInstanceId, stagingInstanceId,
* domains:{prod,staging}, probe:{prod,staging,driver}, … }` — every
* service MUST carry BOTH env keys with non-null values. Domainless /
* single-env workers in the env-map schema legitimately omit a domain
* (and the worker omits its prod env entirely), which would otherwise
* change the emitted JSON. To keep the JSON BYTE-IDENTICAL across the
* refactor (so Ruby + jq + the parity test see no diff), the emitter
* fills the gaps from this shim:
* - `prodInstanceId` for a service with no `prod` env → `serviceId`
* (the legacy non-functional placeholder; never dereferenced because
* the service is staging-only with probe disabled).
* - a missing per-env `domain` → the value provided here (the legacy
* borrowed control-plane host). bin/railway's EXPECTED_DOMAINS
* derivation FILTERS OUT `*.up.railway.app` hosts (only public
* domains enter its domain checks), and resolve-verify-matrix
* filters on `probe.staging===true`, so neither consumer ever
* dereferences these placeholder hosts — they exist only to
* preserve the JSON shape.
*
* Omit this field for any normal (dual-env, domain-bearing) service.
*/
legacyJsonCompat?: {
domains?: { prod?: string; staging?: string };
/**
* Legacy per-env `repoNameOverride` values for an env that the env-map
* schema omits (a single-env worker's absent env still carried a
* placeholder repoName in the legacy JSON). The emitter merges these
* UNDER the real per-env `repoName` values (real wins) so the emitted
* `repoNameOverride` object stays byte-identical.
*/
repoNameOverride?: { prod?: string; staging?: string };
};
/**
* Harness-worker fleet provisioning record. ONLY populated on the
* `harness-workers` entry — all other services omit this field.
*
* These values match CURRENT LIVE REALITY as of 2026-06-26, VERIFIED via the
* Railway GraphQL `environment.config` staged-config read (both envs carry
* `deploy.multiRegionConfig = {"us-west2":{"numReplicas":6}}`):
* prod: effectiveReplicas=6, numReplicas(mirror)=6, BROWSER_POOL_MAX_CONTEXTS=40
* staging: effectiveReplicas=6, numReplicas(mirror)=6, BROWSER_POOL_MAX_CONTEXTS=40
*
* EFFECTIVE FIELD: `effectiveReplicas` models `multiRegionConfig.us-west2.
* numReplicas` — the field Railway actually honors for this single-region
* service. The top-level `numReplicas` is a documented mirror only. The drift
* gate asserts `effectiveReplicas`.
*
* PROD/STAGING PARITY ACHIEVED: B-reconcile scaled prod harness-workers to 6
* replicas (both the top-level field AND `multiRegionConfig.us-west2.
* numReplicas`) to match staging (6). Prod and staging are now at parity
* (6/6). The earlier prod=3 state and the staging config-field-vs-live drift
* (config=2 / live=6) are both RESOLVED: the live staged config now reads 6
* in both envs, verified above.
*
* See the `WorkerProvisioning` interface (above) for the 1-worker-per-replica
* model, the effective-vs-mirror replica distinction, and HARNESS_POOL_COUNT
* informational semantics.
*/
workerProvisioning?: {
prod: WorkerProvisioning;
staging: WorkerProvisioning;
};
}
/**
* Canonical per-service ID map. Keys are the EXACT Railway service names
* (`showcase-*` for integrations; bare names for infra services).
*
* Resolved 2026-05-28 via Railway GraphQL
* `project($id).services.edges[].serviceInstances.edges[]`.
*
* `dispatchName` (when set) is the EXACT `dispatch_name` value used by
* `.github/workflows/showcase_build.yml`'s `ALL_SERVICES` matrix entry
* for this service. The redeploy script uses it to convert the matrix
* output (which carries dispatch_names) back into SSOT keys.
*/
export const SERVICES: Record<
string,
ServiceEntry & { dispatchName?: string }
> = {
aimock: {
serviceId: "0fa0435d-8a66-46f0-84fd-e4250b580013",
ciBuilt: true,
gateValidated: true,
dispatchName: "showcase-aimock",
probeDriver: "aimock",
// Tier-0 shared infra: every agent integration routes its LLM traffic at
// the env-local aimock, so aimock pins+verifies before any tier-1/2.
promoteTier: 0,
// Aimock runs the `showcase-aimock` wrapper image in BOTH envs.
// The wrapper (built from `showcase/aimock/Dockerfile`) bakes the
// showcase fixture tree into base aimock and is the permanent,
// canonical aimock image — it is the only aimock image CI builds.
// Prod is digest-pinned (`@sha256:<digest>`, promote-only); staging
// floats `:latest`. Both envs override to the same `showcase-aimock`
// GHCR repo; there is no migration to the unwrapped `aimock` repo.
environments: {
prod: {
instanceId: "5801d8be-5ad9-4eff-9c9c-7be61d9a023e",
healthcheckPath: "/health",
domain: "showcase-aimock-production.up.railway.app",
probe: true,
repoName: "showcase-aimock",
},
staging: {
instanceId: "9f260dfd-d9d4-43e9-98fe-49696f87fe50",
healthcheckPath: "/health",
domain: "aimock-staging.up.railway.app",
probe: true,
repoName: "showcase-aimock",
},
},
},
dashboard: {
serviceId: "4d5dfd74-be61-40b2-8564-b53b7dd4c15b",
ciBuilt: true,
gateValidated: true,
dispatchName: "shell-dashboard",
probeDriver: "dashboard",
// Tier-1 verification surface: the dashboard reads the PB rows the
// harness writes, so an equivalence-gated promote always pulls it in.
promoteTier: 1,
// Runtime deps: it reads pocketbase rows produced by the harness sweep.
runtimeDeps: ["pocketbase", "harness"],
// Railway service name is `dashboard`; GHCR repo is
// `showcase-shell-dashboard`. Same override for both envs:
// staging :latest, prod @sha256 — uniform across all services.
environments: {
prod: {
instanceId: "e68f98fa-b2ef-41cc-82f6-2ed6f9533bf3",
domain: "dashboard.showcase.copilotkit.ai",
probe: true,
repoName: "showcase-shell-dashboard",
},
staging: {
instanceId: "aea7332e-17a0-4fab-921c-ed5baad2a6f2",
domain: "dashboard.showcase.staging.copilotkit.ai",
probe: true,
repoName: "showcase-shell-dashboard",
},
},
},
docs: {
serviceId: "7badfb8d-4228-414c-9145-b4026803714f",
ciBuilt: true,
gateValidated: true,
dispatchName: "shell-docs",
probeDriver: "docs",
// Standalone: the docs shell is self-contained — it has no runtime
// dependency on the equivalence control plane, so a `docs` promote must
// promote ONLY docs (never drag in the Tier-1 harness/aimock set) and must
// never be gated by an unrelated service's failure (e.g. a harness P6
// env-divergence WARN-refusal must not block docs).
standalone: true,
// Railway service name is `docs`; GHCR repo is `showcase-shell-docs`.
environments: {
prod: {
instanceId: "b15564fc-f832-49b3-82df-fd36f298fe96",
domain: "docs.copilotkit.ai",
probe: true,
repoName: "showcase-shell-docs",
},
staging: {
instanceId: "d5caa51d-73ee-4669-bfea-d87bf1488b02",
domain: "docs.staging.copilotkit.ai",
probe: true,
repoName: "showcase-shell-docs",
},
},
},
dojo: {
serviceId: "7ad1ece7-2228-49cd-8a78-bddf30322907",
ciBuilt: true,
gateValidated: true,
dispatchName: "shell-dojo",
probeDriver: "dojo",
// Railway service name is `dojo`; GHCR repo is `showcase-shell-dojo`.
environments: {
prod: {
instanceId: "2ee4f2aa-11ec-4426-9a4a-41a1ad04f16d",
domain: "dojo.showcase.copilotkit.ai",
probe: true,
repoName: "showcase-shell-dojo",
},
staging: {
instanceId: "1284d717-0ff5-432c-9326-fab12661df61",
domain: "dojo.showcase.staging.copilotkit.ai",
probe: true,
repoName: "showcase-shell-dojo",
},
},
},
harness: {
serviceId: "3a14bfed-0537-4d71-897b-7c593dca161d",
ciBuilt: true,
gateValidated: true,
dispatchName: "showcase-harness",
probeDriver: "harness",
// Tier-1 verification control plane: the post-promote re-sweep runs on
// the just-promoted harness, so it pins+verifies before tier-2.
promoteTier: 1,
// Railway service name is `harness`; GHCR repo is `showcase-harness`.
// ciBuilt: true, gateValidated: true — uniform with the rest of
// the CI-built integrations now that WS-C has flipped the five.
environments: {
prod: {
instanceId: "05fbcdf2-8a50-4b71-b4f6-c92c4b17e626",
healthcheckPath: "/health",
domain: "showcase-harness-production.up.railway.app",
probe: true,
repoName: "showcase-harness",
},
staging: {
instanceId: "0811f68f-fac4-440e-a350-3a7ca5855b80",
healthcheckPath: "/health",
domain: "harness-staging-2ee4.up.railway.app",
probe: true,
repoName: "showcase-harness",
},
},
},
// SSOT key MUST equal the EXACT Railway service name. The Railway service
// is `harness-workers` (PLURAL) — the image-ref gate matches SSOT keys to
// Railway service names verbatim, so the key here is `harness-workers`,
// not `showcase-harness-worker`.
"harness-workers": {
serviceId: "c2aa8a0b-350e-4b76-8541-3012dfac41d0",
// Pool-fleet worker. Workers run in BOTH staging and prod: the prod worker
// is live on Railway (deployed 2026-06-19, HARNESS_ROLE=worker, pool
// count 2) and is now BACKFILLED as a `prod` env entry below (real
// serviceInstance ID `7c48ee43-…`). ciBuilt:false because the worker has
// no build slot of its own — but `imageOf: "harness"` (below) puts it in
// the redeploy scope of BOTH envs whenever the shared showcase-harness
// image is rebuilt. Before this backfill the entry modeled only staging,
// so the env-aware `imageOf` expansion (redeploy-env.ts:278) SILENTLY
// SKIPPED the prod worker on a `showcase-harness:latest` rebuild — the
// prod worker kept its stale 2026-06-19 image (a 1-demo `registry.json`
// for `ms-agent-harness-dotnet` → missing `UI` badge → D0). Declaring the
// prod env here closes that gap: a rebuild now bounces the prod worker too.
ciBuilt: false,
// gateValidated: true — the worker is now a modeled dual-env service, so
// the image-ref gate validates its `showcase-harness` image refs in both
// envs (findMissingServices checks gateValidated:true entries; both env
// entries carry an explicit `repoName: "showcase-harness"`). gateIgnore is
// dropped: the SSOT now fully models the live Railway service in both envs,
// so neither the SSOT→Railway nor the Railway→SSOT gate direction needs an
// opt-out.
gateValidated: true,
probeDriver: "harness",
// Tier-1 verification fleet. With the prod env declared below,
// computePromoteClosure now promotes the prod worker alongside the
// harness control-plane (rather than recording it skipped-with-reason).
promoteTier: 1,
// The worker runs the SAME `showcase-harness` GHCR image that the
// existing `harness` (control-plane) service runs — it is NOT a
// separately-built image. The single `showcase-harness` build slot in
// showcase_build.yml produces the image both services consume; there is
// no `harness-workers` build slot. Hence ciBuilt:false, with the
// consumption modeled explicitly via imageOf so the redeploy after a
// successful `showcase-harness` build bounces the worker in EVERY env it
// declares (it used to be silently skipped in prod, leaving it on the
// stale image). The per-env repoName override points at `showcase-harness`
// so the image-ref shape resolves correctly.
imageOf: "harness",
//
// No public domain (queue worker, not HTTP-exposed) and probe disabled in
// both envs: verify-deploy skips probe:false services, and the schema does
// not require a domain, so we OMIT it rather than point at a borrowed host.
environments: {
prod: {
instanceId: "7c48ee43-6df4-457b-b977-10f1f1ac1680",
healthcheckPath: "/health",
probe: false,
repoName: "showcase-harness",
},
staging: {
instanceId: "362c1e37-5f40-45f2-ac7b-0e5adac565f8",
healthcheckPath: "/health",
probe: false,
repoName: "showcase-harness",
},
},
// Ruby/jq JSON-shape compat (see ServiceEntry.legacyJsonCompat). The prod
// env is now real, so its prodInstanceId/repoName come straight from the
// env map; the only remaining compat shim is the borrowed control-plane
// hosts for the domainless worker's `domains{}` block (probe:false in both
// envs, so these hosts are never dereferenced at runtime — they exist only
// to keep the generated JSON's `domains` shape byte-stable). Not read by TS.
legacyJsonCompat: {
domains: {
prod: "showcase-harness-production.up.railway.app",
staging: "harness-staging-2ee4.up.railway.app",
},
},
// Worker-fleet provisioning (SSOT). Values = CURRENT LIVE REALITY (2026-06-26),
// verified via the Railway GraphQL environment.config staged-config read.
// 1-worker-per-replica model: the EFFECTIVE replica count IS the worker count
// (strictly 1:1). The authoritative field is `effectiveReplicas` =
// multiRegionConfig.us-west2.numReplicas — the value Railway honors. The
// top-level `numReplicas` is a documented mirror only (always equal here,
// single-region). HARNESS_POOL_COUNT is INFORMATIONAL ONLY — it does NOT
// fork workers. Per-worker concurrency knob is BROWSER_POOL_MAX_CONTEXTS.
//
// PARITY ACHIEVED (B-reconcile): prod was scaled 3 → 6 to match staging, in
// BOTH the top-level numReplicas and multiRegionConfig.us-west2.numReplicas.
// Live staged config now reads {"us-west2":{"numReplicas":6}} in both envs.
// prod: effectiveReplicas=6 → 6 live workers.
// staging: effectiveReplicas=6 → 6 live workers.
// The earlier staging config-field-vs-live drift (config=2 / live=6) is also
// resolved: the staged config field now reads 6.
workerProvisioning: {
prod: {
// EFFECTIVE = multiRegionConfig.us-west2.numReplicas (Railway honors this).
effectiveReplicas: 6,
// Top-level mirror (equal, single-region).
numReplicas: 6,
BROWSER_POOL_MAX_CONTEXTS: 40,
// INFORMATIONAL ONLY — not a fork factor.
HARNESS_POOL_COUNT: 3,
// DEPLOY ROLLOVER (layer c) — pure Railway config, no rolling-restart code.
// overlap=45s holds the capacity floor (old deployment serves until new
// workers register+claim); draining=180s ≥ PLATFORM_STOP_GRACE_MS so the
// 3s+90s composed worker-drain (layer b) completes before SIGKILL. See
// showcase/RAILWAY.md "Deploy rollover".
overlapSeconds: 45,
drainingSeconds: 180,
},
staging: {
// EFFECTIVE = multiRegionConfig.us-west2.numReplicas (Railway honors this).
effectiveReplicas: 6,
// Top-level mirror (equal, single-region).
numReplicas: 6,
BROWSER_POOL_MAX_CONTEXTS: 40,
// INFORMATIONAL ONLY — not a fork factor. Live staging value is 2
// (verified via the variables read); it does not gate the worker count.
HARNESS_POOL_COUNT: 2,
// DEPLOY ROLLOVER (layer c) — pure Railway config, no rolling-restart code.
// overlap=45s holds the capacity floor (old deployment serves until new
// workers register+claim); draining=180s ≥ PLATFORM_STOP_GRACE_MS so the
// 3s+90s composed worker-drain (layer b) completes before SIGKILL. See
// showcase/RAILWAY.md "Deploy rollover".
overlapSeconds: 45,
drainingSeconds: 180,
},
},
},
pocketbase: {
serviceId: "ba11e854-d695-4738-9a45-2b0776788824",
// pocketbase is a first-party ghcr.io/copilotkit/ image whose GHCR
// repo name is `showcase-pocketbase` (NOT `pocketbase`). It is now
// built+pushed by showcase_build.yml (the `pocketbase` matrix slot,
// gated to `showcase/pocketbase/**` changes) so PB hook + migration
// changes ship via CI instead of an ad-hoc manual build. ciBuilt:true
// also puts it in the default staging-redeploy scope, but the build's
// redeploy step only touches the matrix∩build-success intersection,
// so pocketbase only redeploys when its own files change.
ciBuilt: true,
gateValidated: true,
dispatchName: "showcase-pocketbase",
probeDriver: "pocketbase",
// Tier-0 shared infra: the verification control plane (harness) writes
// its sweep rows here, so PB pins+verifies before tier-1.
promoteTier: 0,
environments: {
prod: {
instanceId: "1ee376e2-13f2-4464-801e-d0aa0bf76532",
domain: "showcase-pocketbase-production.up.railway.app",
probe: true,
repoName: "showcase-pocketbase",
},
staging: {
instanceId: "0bc7db7b-5a43-4b33-af46-d07fb53c8610",
domain: "pocketbase-staging-eec0.up.railway.app",
probe: true,
repoName: "showcase-pocketbase",
},
},
},
shell: {
serviceId: "40eea0da-6071-4ea8-bdb9-39afb19225ec",
ciBuilt: true,
gateValidated: true,
dispatchName: "shell",
probeDriver: "shell",
// Railway service name is `shell`; GHCR repo is `showcase-shell`.
environments: {
prod: {
instanceId: "01614ccf-e109-4b30-b41b-7c5551c0a34c",
healthcheckPath: "/",
domain: "showcase.copilotkit.ai",
probe: true,
repoName: "showcase-shell",
},
staging: {
instanceId: "25b7de41-188c-4f2e-ac07-538212eaeb91",
healthcheckPath: "/",
domain: "showcase.staging.copilotkit.ai",
probe: true,
repoName: "showcase-shell",
},
},
},
"showcase-ag2": {
serviceId: "4a37481b-f264-4eb7-a9cd-0a9ebb9ac05c",
ciBuilt: true,
gateValidated: true,
dispatchName: "ag2",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "de571c97-03fd-486b-8a54-9767a4a53f95",
healthcheckPath: "/api/health",
domain: "showcase-ag2-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "ecaf81b3-93a8-4862-92b6-04a016b634ed",
healthcheckPath: "/api/health",
domain: "showcase-ag2-staging.up.railway.app",
probe: true,
},
},
},
"showcase-agno": {
serviceId: "32cab80b-e329-45bd-9c73-c4e1ddc94305",
ciBuilt: true,
gateValidated: true,
dispatchName: "agno",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "026d12fb-2844-42af-8f92-b47bc8a06bc8",
healthcheckPath: "/api/health",
domain: "showcase-agno-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "68964ab6-75ca-4095-a64a-52cacfb684f5",
healthcheckPath: "/api/health",
domain: "showcase-agno-staging.up.railway.app",
probe: true,
},
},
},
"showcase-built-in-agent": {
serviceId: "f4f8371a-bc46-45b2-b6d4-9c9af608bdbf",
ciBuilt: true,
gateValidated: true,
dispatchName: "built-in-agent",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "40018ef7-1ed1-4979-b80c-9c2d957b6d88",
healthcheckPath: "/api/health",
domain: "showcase-built-in-agent-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "b89ae7b3-01cc-4ed4-aca6-23aaa63cd59e",
healthcheckPath: "/api/health",
domain: "showcase-built-in-agent-staging.up.railway.app",
probe: true,
},
},
},
"showcase-claude-sdk-python": {
serviceId: "b122ab65-9854-4cb2-a68e-b50ff13f7481",
ciBuilt: true,
gateValidated: true,
dispatchName: "claude-sdk-python",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "bb18caaf-9a3e-4fdd-85ec-562fd82a3a89",
healthcheckPath: "/api/health",
domain: "showcase-claude-sdk-python-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "1ef25aec-5fbd-40b9-8685-57c2681bd45d",
healthcheckPath: "/api/health",
domain: "showcase-claude-sdk-python-staging.up.railway.app",
probe: true,
},
},
},
"showcase-claude-sdk-typescript": {
serviceId: "18a98727-5700-44aa-b497-b60795dbbd6a",
ciBuilt: true,
gateValidated: true,
dispatchName: "claude-sdk-typescript",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "bee425e4-9661-4a88-8888-922b8cd4b61d",
healthcheckPath: "/api/health",
domain: "showcase-claude-sdk-typescript-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "92305747-2f55-4122-aad4-882e989558ab",
healthcheckPath: "/api/health",
domain: "showcase-claude-sdk-typescript-staging.up.railway.app",
probe: true,
},
},
},
"showcase-crewai-crews": {
serviceId: "0e9c284d-8d87-4fcf-9f82-6b704d7e4bd4",
ciBuilt: true,
gateValidated: true,
dispatchName: "crewai-crews",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "3dab0cc3-cab1-4579-b772-947268088514",
healthcheckPath: "/api/health",
domain: "showcase-crewai-crews-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "88c2a14f-435b-499e-a811-ee4f4be18fd8",
healthcheckPath: "/api/health",
domain: "showcase-crewai-crews-staging.up.railway.app",
probe: true,
},
},
},
"showcase-google-adk": {
serviceId: "87f60507-5a3d-4b8a-9e23-2b1de85d939c",
ciBuilt: true,
gateValidated: true,
dispatchName: "google-adk",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "7b2da5db-87d2-40ad-a3d9-b2d7a5485a22",
healthcheckPath: "/api/health",
domain: "showcase-google-adk-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "7efe2fa0-fa78-4585-bc4c-6d39c326e6d1",
healthcheckPath: "/api/health",
domain: "showcase-google-adk-staging.up.railway.app",
probe: true,
},
},
},
"showcase-langgraph-fastapi": {
serviceId: "06cccb5c-59f4-46b5-8adc-7113e77011a4",
ciBuilt: true,
gateValidated: true,
dispatchName: "langgraph-fastapi",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "105b7e01-acd0-48e2-9a09-541e2103e8d2",
healthcheckPath: "/api/health",
domain: "showcase-langgraph-fastapi-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "7899afe0-141b-4217-8dbb-5907813231dc",
healthcheckPath: "/api/health",
domain: "showcase-langgraph-fastapi-staging.up.railway.app",
probe: true,
},
},
},
"showcase-langgraph-python": {
serviceId: "90d03214-4569-41b0-b4c1-6438a8a7b203",
ciBuilt: true,
gateValidated: true,
dispatchName: "langgraph-python",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "aec504f7-63d7-4ea6-9d50-601b00d2ae80",
healthcheckPath: "/api/health",
domain: "showcase-langgraph-python-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "04d29664-a776-4670-9db3-b1d18bce1669",
healthcheckPath: "/api/health",
domain: "showcase-langgraph-python-staging.up.railway.app",
probe: true,
},
},
},
"showcase-langgraph-typescript": {
serviceId: "66246d3b-a18e-46f0-be51-5f3ff7a36e5a",
ciBuilt: true,
gateValidated: true,
dispatchName: "langgraph-typescript",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "f53e9fdc-7c3e-4dfd-9fa8-d7241fd55bb8",
healthcheckPath: "/api/health",
domain: "showcase-langgraph-typescript-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "481ab37f-da8a-4015-bd88-2b28d9eb261a",
healthcheckPath: "/api/health",
domain: "showcase-langgraph-typescript-staging.up.railway.app",
probe: true,
},
},
},
"showcase-langroid": {
serviceId: "6dd9cb0a-66cc-46f1-972e-7cd74756157d",
ciBuilt: true,
gateValidated: true,
dispatchName: "langroid",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "6b5e20b5-8f8e-4ec3-9288-7a41122e42e5",
healthcheckPath: "/api/health",
domain: "showcase-langroid-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "a213f7d9-2117-4944-988b-05e68d819dd5",
healthcheckPath: "/api/health",
domain: "showcase-langroid-staging.up.railway.app",
probe: true,
},
},
},
"showcase-llamaindex": {
serviceId: "285386e8-492d-4cb8-b632-0a7d4607378f",
ciBuilt: true,
gateValidated: true,
dispatchName: "llamaindex",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "b778856e-9f90-4136-9415-fb2b41173f8d",
healthcheckPath: "/api/health",
domain: "showcase-llamaindex-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "17899ea7-355c-43f2-a152-28cb0b7fa864",
healthcheckPath: "/api/health",
domain: "showcase-llamaindex-staging.up.railway.app",
probe: true,
},
},
},
"showcase-mastra": {
serviceId: "d7979eb7-2405-4aab-ad21-438f4a1b08af",
ciBuilt: true,
gateValidated: true,
dispatchName: "mastra",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "eaeddd9c-8b75-426f-b033-0fd935cbf6ef",
healthcheckPath: "/api/health",
domain: "showcase-mastra-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "eec22411-aab5-47a1-8f5b-d097e233d7f8",
healthcheckPath: "/api/health",
domain: "showcase-mastra-staging.up.railway.app",
probe: true,
},
},
},
"showcase-ms-agent-dotnet": {
serviceId: "beeb2dd6-87a4-4599-aa07-0578f7bd6519",
ciBuilt: true,
gateValidated: true,
dispatchName: "ms-agent-dotnet",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "93ca0edf-7b59-4de4-b1fd-3412bb07bc6a",
healthcheckPath: "/api/health",
domain: "showcase-ms-agent-dotnet-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "9826bc58-c472-41e6-b050-29249d4b2a52",
healthcheckPath: "/api/health",
domain: "showcase-ms-agent-dotnet-staging.up.railway.app",
probe: true,
},
},
},
"showcase-ms-agent-harness-dotnet": {
serviceId: "6343d7f9-6c3f-4c8d-9a6e-79f03d2f1e37",
ciBuilt: true,
gateValidated: true,
dispatchName: "ms-agent-harness-dotnet",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "8f91ebc6-95c0-4433-b1f7-657ff49c2d59",
healthcheckPath: "/api/health",
domain: "showcase-ms-agent-harness-dotnet-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "6b0fe181-9156-4a40-9e44-90befe09833a",
healthcheckPath: "/api/health",
domain: "showcase-ms-agent-harness-dotnet-staging.up.railway.app",
probe: true,
},
},
},
"showcase-ms-agent-python": {
serviceId: "655db75a-af8d-427d-a4f9-441570ae5003",
ciBuilt: true,
gateValidated: true,
dispatchName: "ms-agent-python",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "323ed911-4d28-45ab-8fc0-7d151828b938",
healthcheckPath: "/api/health",
domain: "showcase-ms-agent-python-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "741725ce-5fa1-4327-aff5-53dcc000c29c",
healthcheckPath: "/api/health",
domain: "showcase-ms-agent-python-staging.up.railway.app",
probe: true,
},
},
},
"showcase-pydantic-ai": {
serviceId: "0a106173-2282-4887-a994-0ca276a99d69",
ciBuilt: true,
gateValidated: true,
dispatchName: "pydantic-ai",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "192cd647-6824-4f01-937a-1da675d83805",
healthcheckPath: "/api/health",
domain: "showcase-pydantic-ai-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "6edf5ca5-6a56-4d28-92c3-2a3360c735db",
healthcheckPath: "/api/health",
domain: "showcase-pydantic-ai-staging.up.railway.app",
probe: true,
},
},
},
"showcase-spring-ai": {
serviceId: "eed5d041-91be-4282-b414-beea00843401",
ciBuilt: true,
gateValidated: true,
dispatchName: "spring-ai",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "2fbf1db2-5e51-44c9-983c-3f2242d95c61",
healthcheckPath: "/api/health",
domain: "showcase-spring-ai-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "189ac76f-bd77-45c0-9c45-3853dae763cc",
healthcheckPath: "/api/health",
domain: "showcase-spring-ai-staging.up.railway.app",
probe: true,
},
},
},
"showcase-strands": {
serviceId: "92e1cfad-ad53-403f-ab2b-5ab380832232",
ciBuilt: true,
gateValidated: true,
dispatchName: "strands",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "2123c71b-9385-443c-a1c3-bcf4b1669eeb",
healthcheckPath: "/api/health",
domain: "showcase-strands-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "f8a9d2ed-50ec-4f06-85d6-230baced8471",
healthcheckPath: "/api/health",
domain: "showcase-strands-staging.up.railway.app",
probe: true,
},
},
},
// The TypeScript sibling of `showcase-strands`. Now provisioned in BOTH
// staging and prod (dual-env `showcase-strands` shape): the prod
// serviceInstance was created + deployed + health-verified, so the entry
// declares a real `prod` env, gateValidated:true (verify-railway-image-refs
// validates both drift directions), gateIgnore dropped, and the
// legacyJsonCompat prod-domain placeholder removed.
"showcase-strands-typescript": {
serviceId: "d6f47c8c-a0a1-4dbe-991c-50f8463fd68d",
ciBuilt: true,
gateValidated: true,
dispatchName: "strands-typescript",
probeDriver: "agent",
// Tier-2 leaf (default). Runtime dep: the agent routes its LLM traffic
// at the env-local aimock, so a cluster promote pulls aimock (tier-0)
// into the closure. The OPENAI_BASE_URL service-ref is ASSERTED prod→prod
// by the Stage-2 Ruby preflight (never copied).
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "8a50728e-6119-43c4-b59c-d9535b6717a4",
healthcheckPath: "/api/health",
domain: "showcase-strands-typescript-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "3f917b9f-c3f0-4d8b-96ca-7f455e06b5ba",
healthcheckPath: "/api/health",
domain: "showcase-strands-typescript-staging.up.railway.app",
probe: true,
},
},
},
// ───────────────────────── starter-* container fleet ─────────────────────
// The 12 starter-template containers (ghcr.io/copilotkit/starter-<slug>),
// live in BOTH staging and prod. Folded into the cluster-promote SSOT (S1)
// and brought UNDER THE GATE (S2) so they receive the SAME
// dependency-/env-/verification-complete + pinned-prod treatment as the
// showcase-* demos — one whole cluster, no carve-out. The SSOT key === the
// Railway service name === `starter-<RAW starter slug>`, where the RAW slug
// is a BARE key of STARTER_TO_COLUMN (e.g. `adk`, so the SSOT key is
// `starter-adk`) in harness/src/probes/helpers/starter-mapping.ts — NOT the
// remapped dashboard column slug.
//
// S2 SCOPE — these entries are now FULLY gate-managed, identical to a
// showcase-* agent:
// - ciBuilt:true → built+pushed by showcase_build.yml's `build-starters`
// job to `ghcr.io/copilotkit/starter-<slug>:latest`
// (the starter matrix `.image` === `starter-<slug>` ===
// this SSOT key). In the CI_BUILT_SERVICES redeploy
// scope. `dispatchName` === the SSOT key because the
// starter workflow_dispatch choice value is the bare
// `starter-<slug>` (assertDispatchNamesUnique permits a
// dispatchName equal to its OWN key).
// - gateValidated:true / gateIgnore unset → verify-railway-image-refs.ts
// validates the canonical image shape (prod @sha256,
// staging :latest) and BOTH drift directions, exactly
// like showcase-*. No `repoName` override: the Railway
// service name already equals the GHCR repo name
// (`starter-<slug>`), so the gate's default
// `ghcr.io/copilotkit/<serviceName>` is correct.
// - bin/railway lint-prod now COVERS these prod services (asserts they are
// @sha256-pinned).
//
// probeDriver "starter": the starters are verified by the verify-deploy
// baseline driver (deployment-SUCCESS + HTTP 200 on `/`) in
// verify-deploy.drivers.starter.ts, exactly like the Next.js shells. They
// are always-on + staging-probed (prod probe ON and staging probe ON), so
// resolve-verify-matrix routes them through the verify-deploy staging matrix
// (which filters on probe.staging===true) like every other managed showcase
// service. The starters are ALSO auto-discovered by the harness
// `starter_smoke` axis (railway-services source, namePrefix "starter-",
// writing `starter:<column-slug>/<level>` rows) — orthogonal to the baseline
// liveness probe here. The "starter" ProbeDriver is the contract field the
// dispatch switch keys on to route a starter target to probeStarter.
//
// runtimeDeps/serviceRefs mirror the showcase-* agents: each starter routes
// its LLM traffic at the env-local aimock (tier-0), so a cluster promote
// pulls aimock into the closure and the OPENAI_BASE_URL ref is ASSERTED
// prod→prod by the Stage-2 Ruby preflight (never copied).
"starter-adk": {
serviceId: "37691009-c0b2-4af7-8960-9f0b3f0a6be3",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-adk",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "cb23cae4-9555-4ddd-8a62-f1aa1ff72c67",
healthcheckPath: "/",
domain: "starter-adk-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "208a160a-0d7d-44b2-a94d-39e13b24e21a",
healthcheckPath: "/",
domain: "starter-adk-staging.up.railway.app",
probe: true,
},
},
},
"starter-agno": {
serviceId: "5ab3c37e-18a5-44e5-8329-26243dd98da8",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-agno",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "2f58513b-5fe4-4b09-a28f-93d4caa277b5",
healthcheckPath: "/",
domain: "starter-agno-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "9944eb97-7f58-47f8-a49d-65603e209609",
healthcheckPath: "/",
domain: "starter-agno-staging.up.railway.app",
probe: true,
},
},
},
"starter-crewai-crews": {
serviceId: "2a9a4230-e6cd-4c1d-92a5-36e5c624371a",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-crewai-crews",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "1a3e24cb-0752-45c8-b4a2-0c6096899875",
healthcheckPath: "/",
domain: "starter-crewai-crews-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "820895fb-f65c-4834-a07d-d454035d39c4",
healthcheckPath: "/",
domain: "starter-crewai-crews-staging.up.railway.app",
probe: true,
},
},
},
"starter-langgraph-fastapi": {
serviceId: "6ae57213-52ea-4fea-b4a0-7bc304cbc80e",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-langgraph-fastapi",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "0679bc18-e9af-40c6-bc17-0b5eb2cd7bec",
healthcheckPath: "/",
domain: "starter-langgraph-fastapi-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "5f10e976-e121-48a5-bc18-2619798f2f10",
healthcheckPath: "/",
domain: "starter-langgraph-fastapi-staging.up.railway.app",
probe: true,
},
},
},
"starter-langgraph-js": {
serviceId: "d044c3e5-bb27-4d5e-a2bf-e3b382981372",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-langgraph-js",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "50a2205b-8768-4765-b7a1-21941c105051",
healthcheckPath: "/",
domain: "starter-langgraph-js-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "43db83fe-fafb-445b-a19a-51bb086c71b9",
healthcheckPath: "/",
domain: "starter-langgraph-js-staging.up.railway.app",
probe: true,
},
},
},
"starter-langgraph-python": {
serviceId: "10dca514-7c8f-4a32-9708-9f29a944da36",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-langgraph-python",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "24dad599-576a-4154-a621-c3af40629a8f",
healthcheckPath: "/",
domain: "starter-langgraph-python-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "58105e79-4020-4692-8749-c1a63ab63f2c",
healthcheckPath: "/",
domain: "starter-langgraph-python-staging.up.railway.app",
probe: true,
},
},
},
"starter-llamaindex": {
serviceId: "3255b27f-ea84-44b7-b587-b1687b409363",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-llamaindex",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "c119f40b-dc71-4734-9716-c1085754b085",
healthcheckPath: "/",
domain: "starter-llamaindex-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "44446803-0505-456a-b0c4-01fe82fb3832",
healthcheckPath: "/",
domain: "starter-llamaindex-staging.up.railway.app",
probe: true,
},
},
},
"starter-mastra": {
serviceId: "6548403e-3fee-4443-9d59-d8b041a3d43a",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-mastra",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "c6fba6d8-8dde-442b-948f-560bf25fa2f1",
healthcheckPath: "/",
domain: "starter-mastra-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "b246e52d-52d8-4015-bb06-89bd09d54f8f",
healthcheckPath: "/",
domain: "starter-mastra-staging.up.railway.app",
probe: true,
},
},
},
"starter-ms-agent-framework-dotnet": {
serviceId: "1b4c5296-97f6-463d-90af-6e04d7919957",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-ms-agent-framework-dotnet",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "993237a4-9ee7-47b2-a5be-267e247c1409",
healthcheckPath: "/",
domain: "starter-ms-agent-framework-dotnet-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "6684c246-e8fd-45a7-86e4-c529a439976f",
healthcheckPath: "/",
domain: "starter-ms-agent-framework-dotnet-staging.up.railway.app",
probe: true,
},
},
},
"starter-ms-agent-framework-python": {
serviceId: "225d0a06-d1cd-4b82-ae9c-2d1e8ecbaf86",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-ms-agent-framework-python",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "aa934881-340a-4fb7-8b39-9cb0a6f372b2",
healthcheckPath: "/",
domain: "starter-ms-agent-framework-python-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "a162348a-f768-4c3f-815c-f617819f64e6",
healthcheckPath: "/",
domain: "starter-ms-agent-framework-python-staging.up.railway.app",
probe: true,
},
},
},
"starter-pydantic-ai": {
serviceId: "c01d0d24-af88-4631-8a9a-23cffef2b36a",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-pydantic-ai",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "74ce36fe-0b8f-446e-8e06-0b6496b6e829",
healthcheckPath: "/",
domain: "starter-pydantic-ai-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "25f4eb93-501a-4e5e-b7cf-343eb08ea613",
healthcheckPath: "/",
domain: "starter-pydantic-ai-staging.up.railway.app",
probe: true,
},
},
},
"starter-strands-python": {
serviceId: "321735ab-c14d-4e45-a1c2-e47f2b29d774",
ciBuilt: true,
gateValidated: true,
dispatchName: "starter-strands-python",
probeDriver: "starter",
promoteTier: 2,
runtimeDeps: ["aimock"],
serviceRefs: [{ key: "OPENAI_BASE_URL", target: "aimock" }],
environments: {
prod: {
instanceId: "4af440e0-ba05-48a5-b922-5b96a033891a",
healthcheckPath: "/",
domain: "starter-strands-python-production.up.railway.app",
probe: true,
},
staging: {
instanceId: "adc24096-584a-4ef3-93de-0bc92d49235c",
healthcheckPath: "/",
domain: "starter-strands-python-staging.up.railway.app",
probe: true,
},
},
},
webhooks: {
serviceId: "ba6acc13-7585-41fe-a5ee-585b34a58fcd",
ciBuilt: false,
gateValidated: true,
dispatchName: "webhooks",
probeDriver: "webhooks",
// Tier-0 shared infra (eval webhook relay): grouped with the other
// cluster-wide infra so it settles before tier-1/2 promote.
promoteTier: 0,
// webhooks is a first-party ghcr.io/copilotkit/ image, but its
// GHCR repo name is `showcase-eval-webhook` (NOT `webhooks`), and
// it is built by a separate release workflow — not showcase_build.yml.
// The dispatch_name entry exists so humans can redeploy/verify
// webhooks from CI on demand; the build slot is no-op (skip_build).
// NOTE: that no-op slot still reports build status "success", so
// webhooks enters the redeploy CSV whenever its matrix slot is
// selected — and MAY be bounced (Railway re-pulls its out-of-band
// :latest) by BOTH a manual `service=all` build dispatch AND a push
// that touches the build workflow files (the `workflow_config`
// paths-filter disjunct selects EVERY slot, webhooks included).
// Only an ordinary code push — one matching per-service paths
// filters but not workflow_config — leaves webhooks untouched.
environments: {
prod: {
instanceId: "d82ef5b4-3bfd-462e-9436-3d5dbca8681a",
domain: "hooks.showcase.copilotkit.ai",
probe: true,
repoName: "showcase-eval-webhook",
},
staging: {
instanceId: "450e87e0-aba5-4aba-afaf-15f4deab03f0",
domain: "hooks.showcase.staging.copilotkit.ai",
probe: true,
repoName: "showcase-eval-webhook",
},
},
},
};
/**
* Own-property SERVICES lookup. Returns undefined for unknown service
* names, INCLUDING inherited Object.prototype keys ("constructor",
* "toString", "hasOwnProperty", …) which a bare `SERVICES[name]`
* truthiness check would resolve to truthy non-entries. Every exported
* accessor routes through this (or its throwing wrapper `getEntry`) so
* the own-property semantics cannot drift per function.
*/
function findEntry(
serviceName: string,
): (ServiceEntry & { dispatchName?: string }) | undefined {
return Object.hasOwn(SERVICES, serviceName)
? SERVICES[serviceName]
: undefined;
}
/** Own-property `entry.environments` lookup (same rationale as findEntry). */
function findEnvCfg(
entry: ServiceEntry,
env: EnvName,
): EnvironmentConfig | undefined {
return Object.hasOwn(entry.environments, env)
? entry.environments[env]
: undefined;
}
/** findEntry, throwing the curated unknown-service error when absent. */
function getEntry(
serviceName: string,
): ServiceEntry & { dispatchName?: string } {
const entry = findEntry(serviceName);
if (entry === undefined) {
throw new Error(
`Unknown showcase service "${serviceName}". Add it to SERVICES in showcase/scripts/railway-envs.ts.`,
);
}
return entry;
}
/** findEnvCfg, throwing the curated no-such-environment error when absent. */
function getEnvCfg(
serviceName: string,
entry: ServiceEntry,
env: EnvName,
): EnvironmentConfig {
const envCfg = findEnvCfg(entry, env);
if (envCfg === undefined) {
throw new Error(
`Service "${serviceName}" has no "${env}" environment in the SSOT (envs: ${Object.keys(
entry.environments,
)
.sort()
.join(", ")}).`,
);
}
return envCfg;
}
/**
* The env names present in a service's `environments` map. Returns a sorted
* copy so callers get deterministic order regardless of literal order.
* Throws on unknown service (fail loud).
*/
export function envsFor(serviceName: string): EnvName[] {
return Object.keys(getEntry(serviceName).environments).sort();
}
/**
* Every (serviceName, env) pair across the whole SSOT, sorted by service
* name then env name. Intended as the canonical iteration helper for any
* consumer that must visit every env-scoped instance without hardcoding
* ["prod","staging"] — NOT YET CONSUMED by any caller (the image-ref gate
* iterates each entry's `environments` directly); kept exported so new
* exhaustive-iteration consumers reach for it instead of reinventing it.
*/
export function serviceEnvPairs(): Array<{ name: string; env: EnvName }> {
const pairs: Array<{ name: string; env: EnvName }> = [];
for (const name of Object.keys(SERVICES).sort()) {
for (const env of Object.keys(SERVICES[name].environments).sort()) {
pairs.push({ name, env });
}
}
return pairs;
}
export function instanceIdFor(serviceName: string, env: EnvName): string {
return getEnvCfg(serviceName, getEntry(serviceName), env).instanceId;
}
export function listServiceNames(): string[] {
return Object.keys(SERVICES).sort();
}
/**
* The subset of SERVICES that `showcase_build.yml` actually builds and
* pushes. Excludes `webhooks` (released by its own repo's workflow) AND
* the non-CI-built `harness-workers` (consumes the shared showcase-harness
* image via imageOf; no build slot of its own). pocketbase
* IS CI-built (its matrix slot is gated to `showcase/pocketbase/**`
* changes). Default target set for `redeploy-env.ts <env>` when no
* explicit `--services` list is provided — though the actual default
* redeploy scope is this set PLUS any `imageOf` consumers that declare
* the target env (e.g. staging adds harness-workers).
*/
export const CI_BUILT_SERVICES: ReadonlySet<string> = new Set(
Object.entries(SERVICES)
.filter(([, entry]) => entry.ciBuilt)
.map(([name]) => name),
);
/**
* Resolve the expected GHCR repo name for a (serviceName, env) pair.
* Exported so callers (verify-railway-image-refs.ts) and unit tests can
* exercise override resolution directly.
*
* Resolution: the per-env `repoName` override when the service declares
* `env` and sets one; otherwise the service name verbatim (the documented
* default for services whose GHCR repo matches their Railway name).
*
* Fail-loud, consistent with instanceIdFor/domainFor — a silently wrong
* GHCR name is exactly the drift class the image-ref gate exists to catch:
* - THROWS on an unknown service;
* - THROWS on an env name not registered in ENV_ID_BY_NAME (unnormalized
* synonyms like "production" must go through resolveEnv first);
* - THROWS on a registered env the service does not declare (e.g. the
* staging-only harness-workers asked for prod — its repo everywhere
* is showcase-harness, so echoing the service name would be wrong).
*/
export function repoNameFor(serviceName: string, env: EnvName): string {
const entry = getEntry(serviceName);
// Stricter than the other accessors (unchanged behavior): repoNameFor
// also rejects env names that are not registered SSOT env keys, so
// unnormalized synonyms ("production") fail loud before the per-service
// env lookup.
if (!Object.hasOwn(ENV_ID_BY_NAME, env)) {
throw new Error(
`Unknown env "${String(env)}" — repoNameFor requires a normalized SSOT env key (one of: ${Object.keys(ENV_ID_BY_NAME).join(", ")}). Synonyms like "production" must be normalized via resolveEnv() first.`,
);
}
return getEnvCfg(serviceName, entry, env).repoName ?? serviceName;
}
/**
* Resolve the public host (no scheme) for a (serviceName, env) pair.
*
* THROWS on unknown service. THROWS on an env the service does not have.
* THROWS on a missing/scheme-bearing domain. Never returns "" — the verify
* probe and any consumer reading from the SSOT must fail loud, not silently
* probe an empty domain.
*
* Use this from verify-deploy.ts, from any TS caller that needs the URL,
* and from the JSON artifact emitter that the Ruby side consumes.
*/
export function domainFor(serviceName: string, env: EnvName): string {
const envCfg = getEnvCfg(serviceName, getEntry(serviceName), env);
const host = envCfg.domain;
if (!host || host.includes("://")) {
// Defense-in-depth: SERVICES population is asserted by the
// railway-envs.test.ts schema check, but if a future contributor
// ships a scheme-included literal or omits the host this catches it
// at first use instead of returning a malformed value. We test for
// the scheme separator `://` rather than `startsWith("http")` so we
// don't false-reject legitimate hosts whose name happens to begin
// with the letters "http" (e.g. `httpd-…`, `httpbin…`).
throw new Error(
`Service "${serviceName}" has malformed/missing ${env} domain ("${host ?? ""}").`,
);
}
return host;
}
/**
* Whether `verify-deploy --env <env>` should probe this (serviceName, env)
* pair. False when the service has no such env, or the env config sets
* `probe: false`. Defaults to `true` when the env exists and `probe` is
* omitted (the historic default). Returns false (rather than throwing) on
* unknown service AND on an undeclared env so callers can treat "not
* probe-eligible" uniformly — including for inherited Object.prototype
* keys on either axis (the own-property lookups below return undefined
* for those, never a truthy non-entry that would default to `true`).
*/
export function probeEnabled(serviceName: string, env: EnvName): boolean {
const entry = findEntry(serviceName);
if (entry === undefined) return false;
const envCfg = findEnvCfg(entry, env);
if (envCfg === undefined) return false;
return envCfg.probe ?? true;
}
/**
* Resolve the Railway HTTP healthcheck path for a (serviceName, env) pair, or
* `undefined` when none is tracked. Returns undefined (rather than throwing)
* on unknown service AND on an undeclared env — mirroring `probeEnabled` —
* because absence is LEGAL and semantically meaningful: it means "leave the
* Railway default (null); do not assert any path." Callers MUST treat
* undefined as "omit the field" (never coerce to a literal path) — the promote
* pin sends `healthcheckPath` to Railway only when this returns a value, so a
* live-null service is never accidentally cleared OR set to a wrong path.
*/
export function healthcheckPathFor(
serviceName: string,
env: EnvName,
): string | undefined {
const entry = findEntry(serviceName);
if (entry === undefined) return undefined;
const envCfg = findEnvCfg(entry, env);
if (envCfg === undefined) return undefined;
return envCfg.healthcheckPath;
}
/**
* Whether `serviceName` is a tracked SSOT entry. Uses the same own-property
* semantics as {@link findEntry} (so inherited Object.prototype keys are NOT
* counted as members). Callers need this to disambiguate the two reasons
* {@link healthcheckPathFor} returns undefined: a service that is NOT in the
* SSOT at all (brand-new/unknown) versus a TRACKED service that deliberately
* has a null/omitted healthcheckPath (dashboard, docs, dojo, webhooks,
* pocketbase). The former may take an agent-class default; the latter must
* NOT have a healthcheck forced onto it (doing so wedges the deploy — the
* `/api/health` 404 incident).
*/
export function isTrackedService(serviceName: string): boolean {
return findEntry(serviceName) !== undefined;
}
/**
* Resolve the `WorkerProvisioning` record for a (serviceName, env) pair, or
* `undefined` when the service declares no `workerProvisioning` or has no
* entry for the given env. Returns undefined (rather than throwing) on unknown
* service, missing field, or undeclared env — the caller decides whether absence
* is an error (the drift-gate test requires it to be non-null for
* `harness-workers`).
*
* Only `harness-workers` carries this field today. Use it to read the
* authoritative `numReplicas` and `BROWSER_POOL_MAX_CONTEXTS` for a given env.
*/
export function workerProvisioningFor(
serviceName: string,
env: "prod" | "staging",
): WorkerProvisioning | undefined {
const entry = findEntry(serviceName);
if (entry === undefined) return undefined;
return entry.workerProvisioning?.[env];
}
/**
* Resolve a `showcase_build.yml` `dispatch_name` (e.g. `mastra`,
* `shell-dashboard`, `showcase-aimock`) to the canonical SSOT key
* (e.g. `showcase-mastra`, `dashboard`, `aimock`). Returns undefined
* when no SSOT entry carries this dispatchName. Note this does NO ciBuilt
* filtering: any SSOT entry with a matching dispatchName resolves,
* CI-built or not (e.g. the non-CI-built `webhooks` resolves — that is
* what lets a human redeploy it on demand via --services).
*/
export function serviceForDispatchName(
dispatchName: string,
): string | undefined {
for (const [name, entry] of Object.entries(SERVICES)) {
if (entry.dispatchName === dispatchName) return name;
}
return undefined;
}
/** One promotable member of a {@link ClosurePlan}, carrying its tier. */
export interface ClosureMember {
/** Canonical SSOT key. */
name: string;
/** Promote tier (0 infra → 1 verification → 2 leaf). */
tier: 0 | 1 | 2;
/**
* Standalone service (see {@link ClosureEntry.standalone}): promoted ungated
* and never pulls the Tier-1 verification set into its own closure. Only set
* (to `true`) on standalone members so the emitted JSON stays minimal.
*/
standalone?: boolean;
}
/** A member excluded from the promotable set, with an explicit reason. */
export interface ClosureSkip {
/** Canonical SSOT key. */
name: string;
/** Human-readable reason this member is not promoted (never silent). */
reason: string;
}
/**
* The tier-ordered promote plan computed by {@link computePromoteClosure}:
* the `services` an equivalence-gated promote should pin+verify (tier 0→1→2)
* plus the `skipped` members that were pulled into the closure but cannot be
* promoted today (e.g. a member with no `prod` env), each with an explicit
* reason so the exclusion is visible (§4.3 — never silent).
*/
export interface ClosurePlan {
/** Tier-ordered (0→1→2) list of promotable services. */
services: ClosureMember[];
/** Closure members excluded from `services`, each with a reason. */
skipped: ClosureSkip[];
}
/**
* The promote tiers, lowest-first. The promote loop (U4) iterates the
* closure in this order; a tier gates its dependents.
*/
const PROMOTE_TIERS: readonly (0 | 1 | 2)[] = [0, 1, 2];
/** Structural view of a {@link ServiceEntry} the closure math needs. */
type ClosureEntry = {
promoteTier?: 0 | 1 | 2;
runtimeDeps?: string[];
imageOf?: string;
gateIgnore?: boolean;
standalone?: boolean;
environments?: Record<string, unknown>;
};
/** The effective tier of an entry: declared `promoteTier`, default 2. */
function tierOf(entry: ClosureEntry): 0 | 1 | 2 {
return entry.promoteTier ?? 2;
}
/**
* Compute the tier-ordered promote closure for `requested` (SSOT keys OR
* `showcase_build.yml` dispatch_names — resolved the same way
* `resolveTargetServices` does). PURE: reads the SSOT, returns a plan, never
* mutates anything.
*
* The closure is (§4.2):
* requested
* transitive `runtimeDeps` (each member's runtime deps, recursively)
* the FULL Tier-1 verification set (ALWAYS — an equivalence-gated
* promote re-sweeps on the just-promoted harness and reads via the
* dashboard, so the control plane must itself be current)
* explicit `imageOf` consumers of any member.
*
* NOTE the promote path does NOT inherit the staging-redeploy `imageOf`
* EXPANSION wholesale — it pulls a consumer in only because that consumer
* runs a closure member's image and would otherwise run a stale image after
* the member is pinned (§4.2). A member whose `environments`
* omits `prod` (e.g. `harness-workers` today, §4.4) cannot be promoted and is
* recorded in `skipped` with a reason rather than silently dropped (§4.3).
*
* Throws (fail loud) on a requested name that resolves to no SSOT entry.
*
* Accepts an injected map for testing; defaults to the real SERVICES map.
*/
export function computePromoteClosure(
requested: string[],
services: Record<string, ClosureEntry & { dispatchName?: string }> = SERVICES,
): ClosurePlan {
// 1) Resolve requested names → SSOT keys (own-key first, then dispatchName),
// mirroring resolveTargetServices. Fail loud on an unknown name.
const closure = new Set<string>();
const resolveKey = (raw: string): string => {
const name = raw.trim();
if (Object.hasOwn(services, name)) return name;
for (const [key, entry] of Object.entries(services)) {
if (entry.dispatchName === name) return key;
}
throw new Error(
`computePromoteClosure: unknown service "${raw}" — not an SSOT key or dispatch_name in railway-envs.ts.`,
);
};
for (const raw of requested) {
if (raw.trim() === "") continue;
closure.add(resolveKey(raw));
}
// A promote whose REQUESTED set is ENTIRELY standalone services pulls in NO
// Tier-1 verification set: a standalone leaf (e.g. `docs`) is self-contained
// and depends on nothing, so promoting it alone must promote ONLY itself, not
// the whole control plane. A mixed or `all` request still forces Tier-1 below.
const requestedKeys = [...closure];
const allStandalone =
requestedKeys.length > 0 &&
requestedKeys.every((k) => services[k]?.standalone === true);
// 2) Include the full Tier-1 verification set (the control plane + dashboard
// the post-promote re-sweep / equivalence read run against) — UNLESS the
// request is entirely standalone (a standalone leaf needs no control plane).
if (!allStandalone) {
for (const [key, entry] of Object.entries(services)) {
if (tierOf(entry) === 1) closure.add(key);
}
}
// 3) Transitive runtimeDeps closure (BFS). Every dep must be a real key —
// a dangling dep is caught by assertClosureValid, but guard here too so
// the pure function never dereferences a non-entry.
const queue = [...closure];
while (queue.length > 0) {
const key = queue.shift() as string;
const entry = Object.hasOwn(services, key) ? services[key] : undefined;
if (entry === undefined) continue;
for (const dep of entry.runtimeDeps ?? []) {
if (!closure.has(dep) && Object.hasOwn(services, dep)) {
closure.add(dep);
queue.push(dep);
}
}
}
// 4) Explicit imageOf consumers of any closure member (pull, do not inherit
// the staging-redeploy env-aware expansion wholesale — §4.2).
for (const [consumer, entry] of Object.entries(services)) {
const target = entry.imageOf;
if (target !== undefined && closure.has(target)) closure.add(consumer);
}
// 5) Partition into promotable (tier-ordered) vs skipped-with-reason.
const promotable: ClosureMember[] = [];
const skipped: ClosureSkip[] = [];
for (const key of closure) {
const entry = services[key];
// No prod env → cannot be promoted (the staging-only worker today).
const envs = entry.environments ?? {};
if (!Object.hasOwn(envs, "prod")) {
skipped.push({
name: key,
reason: `no "prod" environment in the SSOT — cannot be promoted (it exists in: ${
Object.keys(envs).sort().join(", ") || "no environments"
}).`,
});
continue;
}
promotable.push({
name: key,
tier: tierOf(entry),
...(entry.standalone === true ? { standalone: true } : {}),
});
}
// Tier-ordered (0→1→2), stable within a tier on insertion order.
const services_ordered: ClosureMember[] = [];
for (const tier of PROMOTE_TIERS) {
for (const m of promotable) {
if (m.tier === tier) services_ordered.push(m);
}
}
return { services: services_ordered, skipped };
}
/**
* Throw on SSOT load (or in a test with injected input) if the promote
* closure is malformed (§4.5), MIRRORING `assertImageConsumersValid`:
* - a member's `runtimeDeps` / `serviceRefs.target` names a non-existent
* SSOT key (a dangling dep would silently never be promoted);
* - the SSOT carries NO Tier-1 verification service (an equivalence-gated
* promote with no control plane to re-sweep on is meaningless);
* - the computed closure is EMPTY (refusing to silently promote nothing).
*
* `requested` defaults to the full set of declared dispatch_names so the
* module-load check exercises the real, fully-populated closure. Accepts an
* injected map for testing; defaults to the real SERVICES map.
*/
export function assertClosureValid(
requested: string[] = Object.keys(SERVICES),
services: Record<string, ClosureEntry & { dispatchName?: string }> = SERVICES,
): void {
const problems: string[] = [];
// Dangling runtimeDeps / serviceRefs targets.
for (const [key, entry] of Object.entries(services)) {
for (const dep of entry.runtimeDeps ?? []) {
if (!Object.hasOwn(services, dep)) {
problems.push(
` - runtimeDeps "${dep}" on "${key}" is not an SSOT key in SERVICES`,
);
}
}
const refs = (entry as { serviceRefs?: { target: string }[] }).serviceRefs;
for (const ref of refs ?? []) {
if (!Object.hasOwn(services, ref.target)) {
problems.push(
` - serviceRefs target "${ref.target}" on "${key}" is not an SSOT key in SERVICES`,
);
}
}
}
// The SSOT must declare at least one Tier-1 verification service.
const hasTier1 = Object.values(services).some((e) => tierOf(e) === 1);
if (!hasTier1) {
problems.push(
` - no Tier-1 verification service (promoteTier:1) in SERVICES — an equivalence-gated promote has no control plane to re-sweep on`,
);
}
// The computed closure must be non-empty. Skip this clause when the
// dangling-dep / missing-Tier-1 problems above already fired, since
// computePromoteClosure would throw on an unknown requested name before we
// could surface the curated message.
if (problems.length === 0) {
let plan: ClosurePlan | undefined;
try {
plan = computePromoteClosure(requested, services);
} catch (err) {
problems.push(` - computePromoteClosure threw: ${String(err)}`);
}
if (plan !== undefined && plan.services.length === 0) {
problems.push(
` - the computed promote closure is EMPTY — refusing to silently promote nothing`,
);
}
}
if (problems.length > 0) {
throw new Error(
`railway-envs promote-closure invariant violated:\n${problems.join(
"\n",
)}\n` +
`Fix: every runtimeDeps / serviceRefs target must be an existing ` +
`SSOT key, the SSOT must declare at least one Tier-1 (promoteTier:1) ` +
`service, and the computed closure must be non-empty.`,
);
}
}
/**
* Throw on SSOT load if any two services share the same `dispatchName`,
* or if a service's `dispatchName` equals a DIFFERENT entry's SSOT key.
* `serviceForDispatchName` iterates `Object.entries(SERVICES)` and returns
* the first match — a silent collision would route redeploys to the wrong
* service. The cross-key case is the same trap from the other direction:
* resolveTargetServices checks SSOT keys BEFORE dispatch_names, so a
* dispatchName shadowed by another entry's key silently misroutes to that
* other entry. A dispatchName equal to its OWN key (e.g. shell, webhooks)
* is legal — both lookups land on the same entry. We fail loud at module
* load instead.
*
* Accepts an injected map for testing; defaults to the real SERVICES map.
*/
export function assertDispatchNamesUnique(
services: Record<string, { dispatchName?: string }> = SERVICES,
): void {
const seen = new Map<string, string>(); // dispatchName -> first ssotKey
const problems: string[] = [];
for (const [key, entry] of Object.entries(services)) {
const dn = entry.dispatchName;
if (typeof dn !== "string" || dn.length === 0) continue;
const prior = seen.get(dn);
if (prior !== undefined) {
problems.push(
` - duplicate dispatchName "${dn}" on SSOT keys: ${prior}, ${key}`,
);
} else {
seen.set(dn, key);
}
// Own-property lookup (same rationale as elsewhere in this file): an
// inherited Object.prototype key must not register as a collision.
if (dn !== key && Object.hasOwn(services, dn)) {
problems.push(
` - dispatchName "${dn}" on SSOT key "${key}" equals a DIFFERENT entry's SSOT key — resolveTargetServices resolves SSOT keys first, so this dispatch_name would silently misroute to "${dn}"`,
);
}
}
if (problems.length > 0) {
throw new Error(
`railway-envs SSOT invariant violated:\n${problems.join("\n")}\n` +
`Fix: each Railway service must have a unique dispatchName that ` +
`does not collide with another entry's SSOT key ` +
`(or no dispatchName at all for out-of-band services).`,
);
}
}
/**
* Throw on SSOT load if any `imageOf` is mis-wired. A dangling target,
* a target without a build slot, an imageOf on a build slot, or a consumer
* env the producer never builds for would each silently break the
* "rebuilt image redeploys ALL its consumers" contract in
* `redeploy-env.ts` — fail loud at module load instead (same style as
* `assertDispatchNamesUnique`).
*
* Accepts an injected map for testing; defaults to the real SERVICES map.
*/
export function assertImageConsumersValid(
services: Record<
string,
{
ciBuilt: boolean;
imageOf?: string;
environments?: Record<string, unknown>;
}
> = SERVICES,
): void {
const problems: string[] = [];
for (const [key, entry] of Object.entries(services)) {
const target = entry.imageOf;
if (target === undefined) continue;
if (entry.ciBuilt) {
problems.push(
` - "${key}" is ciBuilt but declares imageOf "${target}" — a build slot is its own image producer; drop one of the two`,
);
continue;
}
// Own-property lookup: a bare `services[target]` truthiness check
// would resolve inherited Object.prototype keys (e.g. imageOf:
// "toString") to a truthy non-entry and misreport the dangling target.
if (!Object.hasOwn(services, target)) {
problems.push(
` - imageOf "${target}" on "${key}" is not an SSOT key in SERVICES`,
);
continue;
}
const targetEntry = services[target];
if (!targetEntry.ciBuilt) {
problems.push(
` - imageOf "${target}" on "${key}" points at a service that is not ciBuilt — only showcase_build.yml build slots can have image consumers`,
);
continue;
}
// A consumer with ZERO declared environments passes the env-subset
// check below vacuously — but a consumer that exists in no env is a
// service the redeploy expansion can never reach (expandImageConsumers
// filters on `environments[env]`), i.e. a silently never-redeployed
// service. Reject it loudly.
const consumerEnvs = Object.keys(entry.environments ?? {});
if (consumerEnvs.length === 0) {
problems.push(
` - "${key}" declares imageOf "${target}" but ZERO environments — the env-subset check passes vacuously and the consumer would never be redeployed in any env`,
);
continue;
}
// Env overlap: every env the consumer declares must also be one the
// producer builds for. A consumer-only env would run an image that no
// CI build ever refreshes there — a silently never-updating service,
// the exact stale-image failure this invariant exists to prevent.
const producerEnvs = targetEntry.environments ?? {};
for (const env of consumerEnvs) {
if (!Object.hasOwn(producerEnvs, env)) {
problems.push(
` - "${key}" declares env "${env}" but its imageOf "${target}" has no "${env}" environment — "${key}" would run a never-rebuilt image there`,
);
}
}
}
if (problems.length > 0) {
throw new Error(
`railway-envs SSOT invariant violated:\n${problems.join("\n")}\n` +
`Fix: imageOf must name an existing, ciBuilt SSOT key, may only ` +
`appear on a non-ciBuilt consumer, and the consumer's declared ` +
`environments must be a non-empty subset of its producer's.`,
);
}
}
/**
* Throw on SSOT load if the env registries and the per-service
* `environments` maps disagree (same style as `assertDispatchNamesUnique`):
*
* (i) every key of every `entry.environments` must be a canonical env
* name registered in ENV_ID_BY_NAME — an unregistered key is an env
* no accessor or redeploy path can ever resolve (a silently
* unreachable env config);
* (ii) ENV_ID_BY_NAME env-ids must be unique — resolveEnv resolves the
* FIRST canonical name carrying an env-id, so a duplicate silently
* shadows the second name;
* (iii) every ENV_ID_BY_NAME canonical name must have at least one
* ENV_IDS spelling — a canonical name no spelling maps to can never
* be produced by resolveEnv (a registered-but-unnameable env);
* (iv) a key present in BOTH registries must carry the SAME env-id in
* both — without this, ENV_IDS.prod drifting to the staging id
* passes every other clause while resolveEnv("prod") silently
* returns staging (a cross-wired redeploy target);
* (v) every ENV_IDS env-id must be carried by some ENV_ID_BY_NAME
* canonical name — an orphan spelling is otherwise rejected only
* lazily inside resolveEnv, at call time, for the one spelling an
* operator happens to type;
* (vi) every key of both registries must equal its own
* trim().toLowerCase() — resolveEnv lowercases its input before
* the own-key lookup, so a non-normalized spelling is registered
* but unreachable;
* (vii) no key of either registry may be an Object.prototype property
* name ("constructor", "toString", …) — a prototype-named env
* defeats the own-property lookup discipline used throughout this
* file and redeploy-env.ts.
*
* Accepts injected maps for testing; defaults to the real registries.
*/
export function assertEnvRegistryConsistent(
services: Record<
string,
{ environments?: Record<string, unknown> }
> = SERVICES,
envIdByName: Record<string, string> = ENV_ID_BY_NAME,
envIds: Record<string, string> = ENV_IDS,
): void {
const problems: string[] = [];
for (const [key, entry] of Object.entries(services)) {
for (const env of Object.keys(entry.environments ?? {})) {
if (!Object.hasOwn(envIdByName, env)) {
problems.push(
` - service "${key}" declares env "${env}", which is not a canonical env name in ENV_ID_BY_NAME — no accessor or redeploy path could ever resolve it`,
);
}
}
}
const byId = new Map<string, string>(); // env-id -> first canonical name
for (const [name, id] of Object.entries(envIdByName)) {
const prior = byId.get(id);
if (prior !== undefined) {
problems.push(
` - duplicate env-id "${id}" in ENV_ID_BY_NAME (canonical names: ${prior}, ${name}) — resolveEnv resolves the FIRST name, silently shadowing the second`,
);
} else {
byId.set(id, name);
}
}
const spelledIds = new Set(Object.values(envIds));
for (const [name, id] of Object.entries(envIdByName)) {
if (!spelledIds.has(id)) {
problems.push(
` - canonical env "${name}" (env-id "${id}") has no ENV_IDS spelling — resolveEnv can never produce it; register at least its own name in ENV_IDS`,
);
}
}
// (iv) Cross-wire: a key in BOTH registries must carry the SAME env-id.
for (const [key, id] of Object.entries(envIds)) {
if (Object.hasOwn(envIdByName, key) && envIdByName[key] !== id) {
problems.push(
` - cross-wired env "${key}": ENV_IDS maps it to "${id}" but ENV_ID_BY_NAME maps it to "${envIdByName[key]}" — resolveEnv("${key}") would silently resolve to the OTHER env`,
);
}
}
// (v) Orphan spelling: every ENV_IDS env-id must have a canonical name.
const canonicalIds = new Set(Object.values(envIdByName));
for (const [spelling, id] of Object.entries(envIds)) {
if (!canonicalIds.has(id)) {
problems.push(
` - orphan ENV_IDS spelling "${spelling}": its env-id "${id}" is carried by no ENV_ID_BY_NAME canonical name — resolveEnv would reject it only lazily at call time`,
);
}
}
// (vi)+(vii) Key hygiene on both registries: lowercase-normalized and
// never an Object.prototype property name.
const protoNames = new Set(Object.getOwnPropertyNames(Object.prototype));
const registries: Array<[string, Record<string, string>]> = [
["ENV_IDS", envIds],
["ENV_ID_BY_NAME", envIdByName],
];
for (const [registryName, registry] of registries) {
for (const key of Object.keys(registry)) {
if (key !== key.trim().toLowerCase()) {
problems.push(
` - ${registryName} key "${key}" is not trim().toLowerCase()-normalized — resolveEnv lowercases its input, so this spelling is registered but unreachable`,
);
}
if (protoNames.has(key)) {
problems.push(
` - ${registryName} key "${key}" is an Object.prototype property name — a prototype-named env defeats own-property lookups; pick a different name`,
);
}
}
}
if (problems.length > 0) {
throw new Error(
`railway-envs SSOT invariant violated:\n${problems.join("\n")}\n` +
`Fix: keep ENV_IDS (accepted spellings), ENV_ID_BY_NAME (canonical ` +
`names) and each service's environments keys mutually consistent.`,
);
}
}
/**
* Throw on SSOT load if any Railway ID is duplicated: a `serviceId` shared
* by two SSOT entries, or an env-scoped `instanceId` shared by any two env
* configs ANYWHERE in the map (instance IDs are globally unique on
* Railway). A duplicated ID means a redeploy/verify of one service would
* silently hit another — fail loud at module load instead (same style as
* `assertDispatchNamesUnique`).
*
* Accepts an injected map for testing; defaults to the real SERVICES map.
*/
export function assertServiceAndInstanceIdsUnique(
services: Record<
string,
{
serviceId: string;
environments?: Record<string, { instanceId?: string }>;
}
> = SERVICES,
): void {
const problems: string[] = [];
const serviceIdOwners = new Map<string, string>(); // serviceId -> ssotKey
const instanceIdOwners = new Map<string, string>(); // instanceId -> "key.env"
for (const [key, entry] of Object.entries(services)) {
const priorService = serviceIdOwners.get(entry.serviceId);
if (priorService !== undefined) {
problems.push(
` - duplicate serviceId "${entry.serviceId}" on SSOT keys: ${priorService}, ${key}`,
);
} else {
serviceIdOwners.set(entry.serviceId, key);
}
for (const [env, cfg] of Object.entries(entry.environments ?? {})) {
const instanceId = cfg?.instanceId;
if (instanceId === undefined) continue;
const where = `${key}.${env}`;
const prior = instanceIdOwners.get(instanceId);
if (prior !== undefined) {
problems.push(
` - duplicate instanceId "${instanceId}" (env configs: ${prior}, ${where}) — a redeploy/verify of one would silently hit the other`,
);
} else {
instanceIdOwners.set(instanceId, where);
}
}
}
if (problems.length > 0) {
throw new Error(
`railway-envs SSOT invariant violated:\n${problems.join("\n")}\n` +
`Fix: every Railway serviceId must appear on exactly one SSOT entry ` +
`and every env-scoped instanceId must be globally unique.`,
);
}
}
// Module-load assertions: fail any importer if the SSOT drifts into a
// collision, a mis-wired image consumer, an inconsistent env registry, or
// a duplicated Railway ID. Tests that exercise the invariants with
// synthetic input call the assert functions directly.
assertDispatchNamesUnique();
assertImageConsumersValid();
assertEnvRegistryConsistent();
assertServiceAndInstanceIdsUnique();
assertClosureValid();