@rizom/ops 0.2.0-alpha.350 → 0.2.0-alpha.351

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/schema.d.ts CHANGED
@@ -4,6 +4,13 @@ export declare const handleSchema: z.ZodString;
4
4
  export declare const secretNameSchema: z.ZodString;
5
5
  export declare const agePublicKeySchema: z.ZodString;
6
6
  export declare const CAPABILITY_BUNDLE_CONTRACT: "capability-bundles-v1";
7
+ export declare const SHARED_FLEET_IMAGE_CONTRACT: "shared-fleet-v1";
8
+ export declare const ISOLATED_SITE_IMAGE_CONTRACT: "isolated-sites-v1";
9
+ export declare const imageContractSchema: z.ZodEnum<{
10
+ "shared-fleet-v1": "shared-fleet-v1";
11
+ "isolated-sites-v1": "isolated-sites-v1";
12
+ }>;
13
+ export type ImageContract = z.output<typeof imageContractSchema>;
7
14
  export declare const canonicalBundleIdSchema: z.ZodEnum<{
8
15
  core: "core";
9
16
  media: "media";
@@ -31,6 +38,7 @@ export declare const profileKindSchema: z.ZodEnum<{
31
38
  export interface PilotConfig {
32
39
  brainVersion: string;
33
40
  bundleContract: typeof CAPABILITY_BUNDLE_CONTRACT;
41
+ imageContract: ImageContract;
34
42
  bundles: CanonicalBundleId[];
35
43
  add?: string[] | undefined;
36
44
  remove?: string[] | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizom/ops",
3
- "version": "0.2.0-alpha.350",
3
+ "version": "0.2.0-alpha.351",
4
4
  "description": "Operator CLI for managing private brain fleet registry repos",
5
5
  "keywords": [
6
6
  "brains",
@@ -8,9 +8,17 @@ on:
8
8
  required: false
9
9
  type: string
10
10
  site_packages:
11
- description: 'Space-separated site packages for a per-instance site image (e.g. "@rizom/site-rizom-ai@0.2.0-alpha.148"). Empty builds the default fleet image.'
11
+ description: "Space-separated exact site/theme packages for one explicit image build. Shared-fleet automatic builds derive the version-wide package union from desired state."
12
12
  required: false
13
13
  type: string
14
+ image_contract:
15
+ description: Image contract for an explicit version build
16
+ required: false
17
+ type: choice
18
+ options:
19
+ - isolated-sites-v1
20
+ - shared-fleet-v1
21
+ default: isolated-sites-v1
14
22
  overwrite:
15
23
  description: Rebuild an existing tag in place. Published tags are immutable; confirm only when replacing a tag whose containers were never deployed.
16
24
  required: false
@@ -59,6 +67,7 @@ jobs:
59
67
  env:
60
68
  BRAIN_VERSION_INPUT: ${{ inputs.brain_version || '' }}
61
69
  SITE_PACKAGES_INPUT: ${{ inputs.site_packages || '' }}
70
+ IMAGE_CONTRACT_INPUT: ${{ inputs.image_contract || 'isolated-sites-v1' }}
62
71
  ALLOW_TAG_OVERWRITE: ${{ inputs.overwrite && 'true' || 'false' }}
63
72
  run: bun deploy/scripts/resolve-missing-images.ts
64
73
 
@@ -30,8 +30,8 @@ The repo also checks in its deploy contract:
30
30
 
31
31
  `.env.schema` is the single source of truth for required and sensitive deploy vars.
32
32
  Use separate GitHub tokens: `CONTENT_REPO_ADMIN_TOKEN` for operator-side content repo creation/checks, and `GIT_SYNC_TOKEN` for runtime directory-sync git access.
33
- The default pilot image tag is `brain-${brainVersion}` end to end. A user with `siteOverride` gets an isolated `brain-${brainVersion}-sites-${packageHash}` image instead.
34
- When the effective brain version (`pilot.yaml.brainVersion`, or a cohort override) changes and you push, CI rebuilds the required default/site tags, refreshes generated user env files, and redeploys affected users. Every external site and theme package uses its own required exact version pin; ops never infers either version from the brain or from another package.
33
+ `pilot.yaml.imageContract` makes image topology explicit. The default `shared-fleet-v1` contract publishes one `brain-${brainVersion}` image per effective Brain version and installs the union of exact site/theme package pins required by instances on that version. `isolated-sites-v1` remains available only for fleets that deliberately require package-isolated images.
34
+ When an effective brain version (`pilot.yaml.brainVersion`, or a cohort override) changes and you push, CI builds every missing shared version image, refreshes generated user env files, and redeploys affected users. Every external site and theme package keeps its own exact version pin; under the shared contract, change that package set only together with a fresh Brain version so published tags remain immutable.
35
35
  When a push changes only deploy contract files, CI prints `No affected user configs; skipping deploy.` and stops before Kamal.
36
36
 
37
37
  ## Commands
@@ -1,6 +1,22 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { Buffer } from "node:buffer";
3
3
  import { appendFileSync } from "node:fs";
4
+ import { isPlainRecord } from "@brains/utils/predicates";
5
+
6
+ /**
7
+ * The slice of `@libsql/client` this script uses.
8
+ *
9
+ * Declared here because the import is dynamic — the package is optional, and
10
+ * only the libsql quick-check driver needs it.
11
+ */
12
+ interface LibsqlModule {
13
+ createClient(options: { url: string }): {
14
+ execute(sql: string): Promise<{
15
+ rows: Array<Record<string | number, unknown>>;
16
+ }>;
17
+ close(): void;
18
+ };
19
+ }
4
20
 
5
21
  export const DEFAULT_PREDEPLOY_BACKUP_RETENTION_COUNT = 5;
6
22
  export const PREDEPLOY_BACKUP_TOOL_VERSION = "brains-predeploy-backup-v1";
@@ -157,10 +173,8 @@ async function quickCheck(
157
173
  if (driver === "bun") {
158
174
  const database = new Database(path, { readonly: true });
159
175
  try {
160
- const row = database.query("PRAGMA quick_check").get() as Record<
161
- string,
162
- unknown
163
- > | null;
176
+ const result: unknown = database.query("PRAGMA quick_check").get();
177
+ const row = isPlainRecord(result) ? result : null;
164
178
  return String(row?.["quick_check"] ?? row?.["0"] ?? "");
165
179
  } finally {
166
180
  database.close(false);
@@ -168,14 +182,10 @@ async function quickCheck(
168
182
  }
169
183
 
170
184
  const moduleName = "@libsql/client";
171
- const libsql = (await import(moduleName)) as {
172
- createClient(options: { url: string }): {
173
- execute(sql: string): Promise<{
174
- rows: Array<Record<string | number, unknown>>;
175
- }>;
176
- close(): void;
177
- };
178
- };
185
+ // Annotated rather than asserted: a dynamic import of an optional dependency
186
+ // resolves to `any`, so naming the slice this script uses is a checked
187
+ // assignment instead of a claim about the whole module.
188
+ const libsql: LibsqlModule = await import(moduleName);
179
189
  const client = libsql.createClient({ url: `file:${path}` });
180
190
  try {
181
191
  const result = await client.execute("PRAGMA quick_check");
@@ -198,9 +208,10 @@ function vectorDigestDatabase(database: Database): {
198
208
  const counts: Record<string, number> = {};
199
209
  for (const [table, order] of tables) {
200
210
  hasher.update(`table:${table}\n`);
201
- const rows = database
211
+ const selected: unknown[] = database
202
212
  .query(`SELECT * FROM ${table} ORDER BY ${order}`)
203
- .all() as Array<Record<string, unknown>>;
213
+ .all();
214
+ const rows = selected.filter(isPlainRecord);
204
215
  counts[table] = rows.length;
205
216
  for (const row of rows) {
206
217
  for (const [key, value] of Object.entries(row)) {
@@ -7,6 +7,7 @@ export {
7
7
  writeGitHubOutput,
8
8
  writeGitHubEnv,
9
9
  siteImageTag,
10
+ runtimeImageTag,
10
11
  sitePackagesFor,
11
12
  runResolveMissingImages,
12
13
  } from "@rizom/ops/deploy";
@@ -30,19 +30,54 @@ function sleep(ms: number): Promise<void> {
30
30
  return new Promise((resolve) => setTimeout(resolve, ms));
31
31
  }
32
32
 
33
+ /**
34
+ * These scripts are copied verbatim into generated projects, so they stay
35
+ * dependency-free: the API payloads are checked by hand rather than with a
36
+ * schema library. readJsonResponse returns unknown; asserting the Hetzner
37
+ * response shape onto it meant a changed or error payload surfaced as an
38
+ * undefined field later instead of a failed lookup here.
39
+ */
40
+ function isRecord(value: unknown): value is Record<string, unknown> {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ function isHetznerServer(value: unknown): value is HetznerServer {
45
+ if (!isRecord(value)) return false;
46
+ if (typeof value["id"] !== "number") return false;
47
+ if (typeof value["status"] !== "string") return false;
48
+ const publicNet = value["public_net"];
49
+ if (publicNet === undefined) return true;
50
+ if (!isRecord(publicNet)) return false;
51
+ const ipv4 = publicNet["ipv4"];
52
+ if (ipv4 === undefined) return true;
53
+ if (!isRecord(ipv4)) return false;
54
+ const ip = ipv4["ip"];
55
+ return ip === undefined || typeof ip === "string";
56
+ }
57
+
58
+ function readServer(payload: unknown): HetznerServer | undefined {
59
+ if (!isRecord(payload)) return undefined;
60
+ const server = payload["server"];
61
+ return isHetznerServer(server) ? server : undefined;
62
+ }
63
+
64
+ function readServers(payload: unknown): HetznerServer[] | undefined {
65
+ if (!isRecord(payload)) return undefined;
66
+ const servers = payload["servers"];
67
+ return Array.isArray(servers) && servers.every(isHetznerServer)
68
+ ? servers
69
+ : undefined;
70
+ }
71
+
33
72
  async function listServers(): Promise<HetznerServer[]> {
34
73
  const url = `${baseUrl}/servers?label_selector=${encodeURIComponent(labelSelector)}`;
35
74
  const response = await fetch(url, { headers });
36
- const payload = (await readJsonResponse(
37
- response,
38
- "Hetzner server lookup",
39
- )) as {
40
- servers?: HetznerServer[];
41
- };
42
- if (!response.ok || !payload.servers) {
75
+ const payload = await readJsonResponse(response, "Hetzner server lookup");
76
+ const servers = readServers(payload);
77
+ if (!response.ok || !servers) {
43
78
  throw new Error(`Hetzner server lookup failed: ${JSON.stringify(payload)}`);
44
79
  }
45
- return payload.servers;
80
+ return servers;
46
81
  }
47
82
 
48
83
  async function createServer(): Promise<HetznerServer> {
@@ -58,27 +93,22 @@ async function createServer(): Promise<HetznerServer> {
58
93
  labels: { brain: instanceName },
59
94
  }),
60
95
  });
61
- const payload = (await readJsonResponse(
62
- response,
63
- "Hetzner server create",
64
- )) as {
65
- server?: HetznerServer;
66
- };
67
- if (!response.ok || !payload.server) {
96
+ const payload = await readJsonResponse(response, "Hetzner server create");
97
+ const server = readServer(payload);
98
+ if (!response.ok || !server) {
68
99
  throw new Error(`Hetzner server create failed: ${JSON.stringify(payload)}`);
69
100
  }
70
- return payload.server;
101
+ return server;
71
102
  }
72
103
 
73
104
  async function getServer(id: number): Promise<HetznerServer> {
74
105
  const response = await fetch(`${baseUrl}/servers/${id}`, { headers });
75
- const payload = (await readJsonResponse(response, "Hetzner server poll")) as {
76
- server?: HetznerServer;
77
- };
78
- if (!response.ok || !payload.server) {
106
+ const payload = await readJsonResponse(response, "Hetzner server poll");
107
+ const server = readServer(payload);
108
+ if (!response.ok || !server) {
79
109
  throw new Error(`Hetzner server poll failed: ${JSON.stringify(payload)}`);
80
110
  }
81
- return payload.server;
111
+ return server;
82
112
  }
83
113
 
84
114
  let server: HetznerServer | undefined = (await listServers())[0];
@@ -5,8 +5,8 @@ import { derivePreviewDomain, loadPilotRegistry } from "@rizom/ops";
5
5
  import {
6
6
  parseEnvFile,
7
7
  requireEnv,
8
+ runtimeImageTag,
8
9
  sitePackagesFor,
9
- siteImageTag,
10
10
  writeGitHubOutput,
11
11
  } from "./helpers";
12
12
 
@@ -43,12 +43,15 @@ const wwwDomain = isFleetDomain(
43
43
 
44
44
  const brainVersion = envEntries["BRAIN_VERSION"] ?? "";
45
45
 
46
- // The image tag is a pure function of this instance's own config: plain
47
- // `brain-{version}` for a default instance, or its own `brain-{version}-sites-
48
- // {hash}` when it declares a siteOverride. Resolved through the same helper the
49
- // build uses so the tag we wait for and run matches exactly what was pushed.
46
+ // Build and deploy share one tag function. A shared fleet always resolves to
47
+ // `brain-{version}`; an explicitly isolated fleet may include the instance's
48
+ // exact site package set in its tag.
50
49
  const sitePackages = sitePackagesFor(user.siteOverride);
51
- const imageTag = siteImageTag(brainVersion, sitePackages);
50
+ const imageTag = runtimeImageTag(
51
+ registry.pilot.imageContract,
52
+ brainVersion,
53
+ sitePackages,
54
+ );
52
55
 
53
56
  const outputs: Record<string, string> = {
54
57
  brain_version: brainVersion,
@@ -16,18 +16,43 @@ interface CloudflareResult {
16
16
  result?: Array<{ id: string }>;
17
17
  }
18
18
 
19
+ /**
20
+ * Copied verbatim into generated projects, so this stays dependency-free and
21
+ * checks the payload by hand. readJsonResponse returns unknown; asserting
22
+ * CloudflareResult onto it meant an error body read as `success: undefined`
23
+ * rather than failing here.
24
+ */
25
+ function isRecord(value: unknown): value is Record<string, unknown> {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+
29
+ function readCloudflareResult(payload: unknown): CloudflareResult | undefined {
30
+ if (!isRecord(payload)) return undefined;
31
+ if (typeof payload["success"] !== "boolean") return undefined;
32
+ const result = payload["result"];
33
+ if (result === undefined) return { success: payload["success"] };
34
+ if (
35
+ !Array.isArray(result) ||
36
+ !result.every(
37
+ (entry): entry is { id: string } =>
38
+ isRecord(entry) && typeof entry["id"] === "string",
39
+ )
40
+ ) {
41
+ return undefined;
42
+ }
43
+ return { success: payload["success"], result };
44
+ }
45
+
19
46
  async function findRecordId(
20
47
  name: string,
21
48
  type: "A" | "CNAME",
22
49
  ): Promise<string | undefined> {
23
50
  const lookupUrl = `${baseUrl}/zones/${zoneId}/dns_records?type=${type}&name=${encodeURIComponent(name)}`;
24
51
  const lookup = await fetch(lookupUrl, { headers });
25
- const payload = (await readJsonResponse(
26
- lookup,
27
- "Cloudflare DNS lookup",
28
- )) as CloudflareResult;
29
- if (!lookup.ok || !payload.success) {
30
- throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(payload)}`);
52
+ const raw = await readJsonResponse(lookup, "Cloudflare DNS lookup");
53
+ const payload = readCloudflareResult(raw);
54
+ if (!lookup.ok || !payload?.success) {
55
+ throw new Error(`Cloudflare DNS lookup failed: ${JSON.stringify(raw)}`);
31
56
  }
32
57
 
33
58
  return payload.result?.[0]?.id;
@@ -53,12 +78,10 @@ async function upsertRecord(name: string): Promise<void> {
53
78
  proxied: true,
54
79
  }),
55
80
  });
56
- const result = (await readJsonResponse(
57
- response,
58
- "Cloudflare DNS upsert",
59
- )) as CloudflareResult;
60
- if (!response.ok || !result.success) {
61
- throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(result)}`);
81
+ const raw = await readJsonResponse(response, "Cloudflare DNS upsert");
82
+ const result = readCloudflareResult(raw);
83
+ if (!response.ok || !result?.success) {
84
+ throw new Error(`Cloudflare DNS upsert failed: ${JSON.stringify(raw)}`);
62
85
  }
63
86
  }
64
87
 
@@ -16,10 +16,12 @@ Treat these as checked-in deploy artifacts in the pilot repo:
16
16
  `.env.schema` is the single source of truth for required and sensitive deploy vars.
17
17
  The deploy scripts and workflows should read from that contract instead of inventing a second list.
18
18
 
19
- The default pilot image tag is `brain-${brainVersion}`:
19
+ The pilot declares its image topology with `pilot.yaml.imageContract`:
20
20
 
21
- - build publishes `brain-${brainVersion}` for users without a site override
22
- - a site override gets an isolated `brain-${brainVersion}-sites-${packageHash}` image
21
+ - `shared-fleet-v1` publishes one `brain-${brainVersion}` image for each effective Brain version
22
+ - each shared image contains the union of exact site/theme package pins required by instances on that version
23
+ - conflicting versions of one package fail image resolution before build
24
+ - `isolated-sites-v1` is an explicit alternative for fleets that require package-isolated tags
23
25
  - generated `users/<handle>/.env` carries `BRAIN_VERSION=<brainVersion>`
24
26
  - build and deploy derive the same effective image tag from the resolved registry
25
27
 
@@ -27,7 +29,7 @@ The default pilot image tag is `brain-${brainVersion}`:
27
29
 
28
30
  When `pilot.yaml.brainVersion` changes and you push:
29
31
 
30
- 1. build publishes the new default image and any required site images
32
+ 1. build publishes each missing version image with its declared package union
31
33
  2. reconcile refreshes generated `users/<handle>/.env`
32
34
  3. deploy runs for handles whose generated config changed
33
35
  4. generated file commits happen once in a final aggregation step after the deploy matrix finishes
@@ -260,16 +262,18 @@ siteOverride:
260
262
  themeVersion: <exact-theme-version>
261
263
  ```
262
264
 
263
- Missing external package versions fail desired-state validation. A site override
264
- produces an isolated per-instance image; it never changes the fleet's shared default
265
- image. Bundled `@brains/*` themes omit `themeVersion` because they are not installed as
266
- separate packages.
265
+ Missing external package versions fail desired-state validation. Under
266
+ `shared-fleet-v1`, every instance on one Brain version uses the same image and exact
267
+ package union. Change a site/theme package pin only together with a fresh Brain version;
268
+ published `brain-${brainVersion}` tags remain immutable. Conflicting pins for one package
269
+ on the same Brain version fail before build. Bundled `@brains/*` themes omit
270
+ `themeVersion` because they are not installed as separate packages.
267
271
 
268
272
  ### Custom-package canary and rollback
269
273
 
270
274
  1. Confirm the exact site/theme versions are public-installable without npm credentials.
271
- 2. Apply the exact package names and versions to one healthy canonical site canary.
272
- 3. Reconcile the canary, push the generated output, and let build/deploy create its site image.
275
+ 2. Apply the exact package names and versions to one healthy canonical site canary and select a fresh Brain version for that package set.
276
+ 3. Push desired state and let the normal Build → Reconcile → Deploy chain create the shared version image and update the canary.
273
277
  4. Run `bunx brains-ops verify-user . <handle>`.
274
278
  5. Manually verify the site, theme, Studio, content sync, and passkey sign-in before adding more users.
275
279
 
@@ -1,5 +1,6 @@
1
1
  brainVersion: 0.1.1-alpha.14
2
2
  bundleContract: capability-bundles-v1
3
+ imageContract: shared-fleet-v1
3
4
  githubOrg: <github-org>
4
5
  contentRepoPrefix: rover-
5
6
  domainSuffix: .rizom.ai