@forgezero/agent 0.1.79 → 0.1.81

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/fz.js CHANGED
@@ -4810,8 +4810,8 @@ async function spawnWith(command, env, report = () => {}, options = {}) {
4810
4810
  }
4811
4811
 
4812
4812
  // src/cli/index.ts
4813
- import { existsSync as existsSync12, lstatSync as lstatSync10, mkdirSync as mkdirSync13, readFileSync as readFileSync15, writeFileSync as writeFileSync13 } from "fs";
4814
- import { basename as basename3, dirname as dirname14, isAbsolute as isAbsolute7, join as join12, resolve as resolve11 } from "path";
4813
+ import { existsSync as existsSync12, lstatSync as lstatSync10, mkdirSync as mkdirSync13, readFileSync as readFileSync16, writeFileSync as writeFileSync13 } from "fs";
4814
+ import { basename as basename3, dirname as dirname14, isAbsolute as isAbsolute7, join as join12, resolve as resolve12 } from "path";
4815
4815
 
4816
4816
  // src/process-input.ts
4817
4817
  async function writeAndCloseProcessInput(input, value) {
@@ -4837,7 +4837,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
4837
4837
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
4838
4838
 
4839
4839
  // src/version.ts
4840
- var VERSION2 = "0.1.79";
4840
+ var VERSION2 = "0.1.81";
4841
4841
 
4842
4842
  // src/software.ts
4843
4843
  var PINNED_BUN_VERSION = "1.3.14";
@@ -10573,7 +10573,7 @@ function reference(value, where) {
10573
10573
  const row2 = object(value, where);
10574
10574
  exact3(row2, ["$ref"], where);
10575
10575
  const coordinate = boundedString(row2.$ref, `${where}.$ref`, 256);
10576
- if (!/^(inputs\.[a-z][A-Za-z0-9-]{0,62}|steps\.[a-z][A-Za-z0-9-]{0,62}\.(status|outputs\.[a-z][A-Za-z0-9-]{0,62})|components\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|targets\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|deployment\.[a-z][A-Za-z0-9-]{0,62}|execution\.(targetName|slot|releaseExecutor|runtime|cpuCores|memoryMiB|storageGiB))$/.test(coordinate)) {
10576
+ if (!/^(inputs\.[a-z][A-Za-z0-9-]{0,62}|steps\.[a-z][A-Za-z0-9-]{0,62}\.(status|outputs\.[a-z][A-Za-z0-9-]{0,62})|components\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|targets\.[a-z][A-Za-z0-9-]{0,62}\.[a-z][A-Za-z0-9-]{0,62}|deployment\.[a-z][A-Za-z0-9-]{0,62}|execution\.(targetName|slot|releaseExecutor|runtime|cpuCores|memoryMiB|storageGiB|site|topologyRole|topologyTransport|localRelayAddress))$/.test(coordinate)) {
10577
10577
  fail2(where, "contains an unsupported reference.");
10578
10578
  }
10579
10579
  return { $ref: coordinate };
@@ -11005,6 +11005,12 @@ function validateStep(value, where, targets, context) {
11005
11005
  if (!targets.has(String(scope.target)))
11006
11006
  fail2(`${where}.scope.target`, "must name an existing target.");
11007
11007
  integer(scope.size, `${where}.scope.size`, 1, 32);
11008
+ } else if (scope.kind === "topology-role") {
11009
+ exact3(scope, ["kind", "target", "role"], `${where}.scope`);
11010
+ if (!targets.has(String(scope.target)))
11011
+ fail2(`${where}.scope.target`, "must name an existing target.");
11012
+ if (!["relay", "standby", "member"].includes(String(scope.role)))
11013
+ fail2(`${where}.scope.role`, "is unsupported.");
11008
11014
  } else
11009
11015
  fail2(`${where}.scope.kind`, "is unsupported.");
11010
11016
  if (row2.timeoutMs !== undefined)
@@ -11159,7 +11165,7 @@ function compileDeployment(sourceValue) {
11159
11165
  const targetDefinitions = new Map;
11160
11166
  for (const [targetName, value] of targetEntries) {
11161
11167
  const target = object(value, `deployment.spec.targets.${targetName}`);
11162
- exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "placement"], `deployment.spec.targets.${targetName}`);
11168
+ exact3(target, ["kind", "selector", "cardinality", "allocation", "provisioning", "connectivity", "topology", "placement"], `deployment.spec.targets.${targetName}`);
11163
11169
  if (target.kind !== "compute")
11164
11170
  fail2(`deployment.spec.targets.${targetName}.kind`, "must be compute.");
11165
11171
  const selector = object(target.selector, `deployment.spec.targets.${targetName}.selector`);
@@ -11281,6 +11287,45 @@ function compileDeployment(sourceValue) {
11281
11287
  }
11282
11288
  }
11283
11289
  }
11290
+ if (target.topology !== undefined) {
11291
+ const topology = object(target.topology, `deployment.spec.targets.${targetName}.topology`);
11292
+ exact3(topology, ["groupBy", "relays", "crossMetal"], `deployment.spec.targets.${targetName}.topology`);
11293
+ if (topology.groupBy !== "metal")
11294
+ fail2(`deployment.spec.targets.${targetName}.topology.groupBy`, "must be metal.");
11295
+ const relays = object(topology.relays, `deployment.spec.targets.${targetName}.topology.relays`);
11296
+ exact3(relays, ["activePerMetal", "standbyPerMetal", "healthPort", "routedTcpPorts"], `deployment.spec.targets.${targetName}.topology.relays`);
11297
+ if (relays.activePerMetal !== 1 || ![0, 1].includes(Number(relays.standbyPerMetal)))
11298
+ fail2(`deployment.spec.targets.${targetName}.topology.relays`, "requires one active relay and zero or one standby per metal.");
11299
+ integer(relays.healthPort, `deployment.spec.targets.${targetName}.topology.relays.healthPort`, 1, 65535);
11300
+ if (!Array.isArray(relays.routedTcpPorts) || relays.routedTcpPorts.length < 1 || relays.routedTcpPorts.length > 64 || new Set(relays.routedTcpPorts).size !== relays.routedTcpPorts.length) {
11301
+ fail2(`deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts`, "must contain 1 to 64 unique ports.");
11302
+ }
11303
+ for (const [index, port] of relays.routedTcpPorts.entries()) {
11304
+ integer(port, `deployment.spec.targets.${targetName}.topology.relays.routedTcpPorts.${index}`, 1, 65535);
11305
+ }
11306
+ if (!relays.routedTcpPorts.includes(relays.healthPort)) {
11307
+ fail2(`deployment.spec.targets.${targetName}.topology.relays`, "routedTcpPorts must include healthPort.");
11308
+ }
11309
+ const crossMetal = object(topology.crossMetal, `deployment.spec.targets.${targetName}.topology.crossMetal`);
11310
+ if (crossMetal.mode === "private-lan")
11311
+ exact3(crossMetal, ["mode"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
11312
+ else if (crossMetal.mode === "cloudflare-warp" || crossMetal.mode === "auto") {
11313
+ exact3(crossMetal, crossMetal.mode === "auto" ? ["mode", "directPrivateMaximumMetals", "credential", "network"] : ["mode", "credential", "network"], `deployment.spec.targets.${targetName}.topology.crossMetal`);
11314
+ if (crossMetal.mode === "auto" && crossMetal.directPrivateMaximumMetals !== 2)
11315
+ fail2(`deployment.spec.targets.${targetName}.topology.crossMetal.directPrivateMaximumMetals`, "must be 2.");
11316
+ if (!credentials[named(crossMetal.credential, `deployment.spec.targets.${targetName}.topology.crossMetal.credential`)])
11317
+ fail2(`deployment.spec.targets.${targetName}.topology.crossMetal.credential`, "must name a declared credential.");
11318
+ if (typeof crossMetal.network === "string")
11319
+ named(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
11320
+ else
11321
+ reference(crossMetal.network, `deployment.spec.targets.${targetName}.topology.crossMetal.network`);
11322
+ } else
11323
+ fail2(`deployment.spec.targets.${targetName}.topology.crossMetal.mode`, "is unsupported.");
11324
+ if (allocation.sharing !== "exclusive")
11325
+ fail2(`deployment.spec.targets.${targetName}.allocation.sharing`, "must be exclusive for a per-metal relay topology.");
11326
+ if (minimum < 1 + Number(relays.standbyPerMetal))
11327
+ fail2(`deployment.spec.targets.${targetName}.cardinality.minimum`, "must allow the declared relay and standby.");
11328
+ }
11284
11329
  if (target.placement !== undefined)
11285
11330
  jsonValue(target.placement, `deployment.spec.targets.${targetName}.placement`);
11286
11331
  targetDefinitions.set(targetName, target);
@@ -11304,6 +11349,9 @@ function compileDeployment(sourceValue) {
11304
11349
  if (target.connectivity?.private?.mode === "cloudflare-warp" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
11305
11350
  fail2(`deployment.spec.targets.${targetName}.connectivity.private`, "requires one network.private provider requirement.");
11306
11351
  }
11352
+ if (target.topology && target.topology.crossMetal.mode !== "private-lan" && ![...requirementDefinitions.values()].some(({ capability }) => capability === "network.private")) {
11353
+ fail2(`deployment.spec.targets.${targetName}.topology.crossMetal`, "requires one network.private provider requirement.");
11354
+ }
11307
11355
  }
11308
11356
  const components = object(spec.components, "deployment.spec.components");
11309
11357
  const componentEntries = Object.entries(components);
@@ -12058,7 +12106,14 @@ function validatePlatformSharedEnvironment(input) {
12058
12106
  throw new Error("Bootstrap email provider must be smtp or jetemail.");
12059
12107
  }
12060
12108
  boundedInteger("publicApiPort", input.publicApiPort, 1024, 65533);
12061
- boundedInteger("seedSyncMembers", input.seedSyncMembers, 1, 64);
12109
+ if (!Array.isArray(input.seedSyncMembers) || input.seedSyncMembers.length < 3 || input.seedSyncMembers.length > 64 || new Set(input.seedSyncMembers).size !== input.seedSyncMembers.length) {
12110
+ throw new Error("seedSyncMembers must contain 3 to 64 unique physical host identities.");
12111
+ }
12112
+ for (const member of input.seedSyncMembers)
12113
+ safeAtom("seedSyncMembers item", member);
12114
+ if (!input.seedSyncMembers.includes(input.nodeHostname)) {
12115
+ throw new Error("seedSyncMembers must include this nodeHostname.");
12116
+ }
12062
12117
  boundedInteger("concurrencyLimit", input.concurrencyLimit, 1, 1e6);
12063
12118
  boundedInteger("drainDeadlineMs", input.drainDeadlineMs, 1000, 300000);
12064
12119
  boundedInteger("otlpFlushIntervalMs", input.otlpFlushIntervalMs, 1000, 300000);
@@ -12138,7 +12193,7 @@ function renderPlatformSharedEnvironment(input) {
12138
12193
  FZ_DB_MASTER: value.databaseMaster ?? "",
12139
12194
  FZ_DB_NETWORK_MODE: value.databaseNetworkMode,
12140
12195
  FZ_SEED_SYNC_PEERS: value.seedSyncPeers.join(","),
12141
- FZ_SEED_SYNC_MEMBERS: String(value.seedSyncMembers),
12196
+ FZ_SEED_SYNC_MEMBERS: value.seedSyncMembers.join(","),
12142
12197
  FZ_SEED_SYNC_EPOCH: value.seedSyncEpoch,
12143
12198
  FZ_SEED_SYNC_CREDENTIAL: "seed-sync-root",
12144
12199
  FZ_SHARED_DIR: value.sharedDirectory,
@@ -12207,7 +12262,12 @@ function platformApiCredentialSpecs(options) {
12207
12262
  ];
12208
12263
  }
12209
12264
  function renderPlatformApiUnits(input) {
12210
- for (const path of [input.sharedDirectory, input.sharedEnvironmentFile, input.slotsDirectory]) {
12265
+ for (const path of [
12266
+ input.sharedDirectory,
12267
+ input.sharedEnvironmentFile,
12268
+ input.slotsDirectory,
12269
+ ...input.topologyEnvironmentFile ? [input.topologyEnvironmentFile] : []
12270
+ ]) {
12211
12271
  if (!path.startsWith("/") || /[\r\n]/.test(path))
12212
12272
  throw new Error("Runtime paths must be absolute and single-line.");
12213
12273
  }
@@ -12221,6 +12281,8 @@ function renderPlatformApiUnits(input) {
12221
12281
  const credentials = input.credentials.map((credential) => `LoadCredentialEncrypted=${credential.name}:${credential.encryptedPath}`).join(`
12222
12282
  `);
12223
12283
  const capacityEnvironment = input.capacityEnvironmentFile ? `EnvironmentFile=-${input.capacityEnvironmentFile}
12284
+ ` : "";
12285
+ const topologyEnvironment = input.topologyEnvironmentFile ? `EnvironmentFile=-${input.topologyEnvironmentFile}
12224
12286
  ` : "";
12225
12287
  const template = `[Unit]
12226
12288
  Description=ForgeZero (%i slot)
@@ -12234,7 +12296,7 @@ WorkingDirectory=${input.slotsDirectory}/%i
12234
12296
  Environment=NODE_ENV=production
12235
12297
  Environment=FZ_SLOT=%i
12236
12298
  EnvironmentFile=${input.sharedEnvironmentFile}
12237
- ${capacityEnvironment}${credentials}
12299
+ ${capacityEnvironment}${topologyEnvironment}${credentials}
12238
12300
  ExecStart=/usr/local/bin/bun run ${input.slotsDirectory}/%i/src/index.ts
12239
12301
  Restart=always
12240
12302
  RestartSec=2
@@ -14722,7 +14784,8 @@ async function applyBootstrap(input, host = localBootstrapHost(), secrets, depen
14722
14784
  greenPort: runtime.greenPort,
14723
14785
  collectorUnit: runtime.environment.otlpCollectorUnit,
14724
14786
  credentials,
14725
- capacityEnvironmentFile: "/etc/forgezero/capacity.env"
14787
+ capacityEnvironmentFile: "/etc/forgezero/capacity.env",
14788
+ topologyEnvironmentFile: "/etc/forgezero/deployment-topology.env"
14726
14789
  });
14727
14790
  const edge = renderPlatformNginx({
14728
14791
  publicPort: runtime.environment.publicApiPort,
@@ -15487,6 +15550,17 @@ function membersOfLinuxList(value, label) {
15487
15550
  throw new MetalProvisionError(`${label} list overlaps itself`);
15488
15551
  return members;
15489
15552
  }
15553
+ function physicalCoreGroups(pool) {
15554
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
15555
+ if (cpus.length % pool.physicalCores !== 0) {
15556
+ throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
15557
+ }
15558
+ const threadsPerCore = cpus.length / pool.physicalCores;
15559
+ if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
15560
+ throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
15561
+ }
15562
+ return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
15563
+ }
15490
15564
  function validateMetalProfile(profile) {
15491
15565
  if (!SAFE_NAME2.test(profile.volumeGroup))
15492
15566
  throw new MetalProvisionError("invalid volume group");
@@ -15535,6 +15609,7 @@ function validateMetalProfile(profile) {
15535
15609
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
15536
15610
  throw new MetalProvisionError("invalid CPU pool physical-core count");
15537
15611
  }
15612
+ physicalCoreGroups(pool);
15538
15613
  for (const cpu of cpus) {
15539
15614
  if (assigned.has(cpu))
15540
15615
  throw new MetalProvisionError("CPU pools overlap");
@@ -17298,8 +17373,8 @@ function platformGenesisBootstrapConfigs(template, guests, nodes, metalHostname,
17298
17373
  databaseAddress: guest.address,
17299
17374
  databaseMaster: role === "joiner" ? master : undefined,
17300
17375
  nodeHostname: node.nodeHostname,
17301
- seedSyncPeers: fleet.filter((_, peer) => peer !== index).map((peer) => `ws://${peer.address}:${config.runtime.bluePort}/internal/seed`),
17302
- seedSyncMembers: 3,
17376
+ seedSyncPeers: fleet.filter((_, peer) => peer !== index).flatMap((peer) => [config.runtime.bluePort, config.runtime.greenPort].map((port) => `ws://${peer.address}:${port}/_internal/forgezero/seed-mesh/v2`)),
17377
+ seedSyncMembers: nodes.map(({ nodeHostname }) => nodeHostname),
17303
17378
  initialInventory,
17304
17379
  custodianEmail: index === 0 ? template.runtime.environment.custodianEmail : undefined
17305
17380
  };
@@ -17615,6 +17690,38 @@ async function runRepositoryOperation(operation, requestedRoot, args = []) {
17615
17690
  `), { cwd: resolved.cwd });
17616
17691
  }
17617
17692
 
17693
+ // src/cli/app-build.ts
17694
+ import { readFileSync as readFileSync15 } from "fs";
17695
+ import { resolve as resolve11 } from "path";
17696
+ function planAppBuild(input) {
17697
+ const cwd = resolve11(input.root);
17698
+ let manifest;
17699
+ try {
17700
+ manifest = JSON.parse(readFileSync15(resolve11(cwd, "package.json"), "utf8"));
17701
+ } catch {
17702
+ throw new Error(`--root must name a ForgeZero App checkout: ${cwd}`);
17703
+ }
17704
+ if (manifest.name !== "@forgezero/app") {
17705
+ throw new Error(`--root must name @forgezero/app, not ${manifest.name ?? "an unnamed package"}.`);
17706
+ }
17707
+ if (input.profile && !["local", "development", "production"].includes(input.profile)) {
17708
+ throw new Error("--profile must be local, development, or production.");
17709
+ }
17710
+ if (input.profile && input.api)
17711
+ throw new Error("Choose --profile or --api, not both.");
17712
+ return {
17713
+ cwd,
17714
+ command: [
17715
+ "bun",
17716
+ "run",
17717
+ "build",
17718
+ "--",
17719
+ ...input.profile ? ["--profile", input.profile] : [],
17720
+ ...input.api ? ["--api", input.api] : []
17721
+ ]
17722
+ };
17723
+ }
17724
+
17618
17725
  // src/cli/index.ts
17619
17726
  var DEFAULT_MODE = (THRESHOLD_MODES.find((mode) => mode.threshold === 1 && mode.total === 1) ?? THRESHOLD_MODES[0]).id;
17620
17727
  var PACKAGED_AGENT_BIN2 = fileURLToPath3(new URL("./fz-agent.js", import.meta.url));
@@ -17844,7 +17951,7 @@ async function api(options, path, init) {
17844
17951
  return { status: response.status, body };
17845
17952
  }
17846
17953
  function sleep(ms) {
17847
- return new Promise((resolve12) => setTimeout(resolve12, ms));
17954
+ return new Promise((resolve13) => setTimeout(resolve13, ms));
17848
17955
  }
17849
17956
  function browserCommand(url) {
17850
17957
  if (process.platform === "darwin")
@@ -17943,7 +18050,7 @@ function readOwnerOnlySecret(path, label) {
17943
18050
  if (uid !== undefined && uid !== 0 && metadata.uid !== uid) {
17944
18051
  throw new Error(`The ${label} file ${path} is not owned by the current user.`);
17945
18052
  }
17946
- const value = readFileSync15(path, "utf8").trim();
18053
+ const value = readFileSync16(path, "utf8").trim();
17947
18054
  if (!value)
17948
18055
  throw new Error(`The ${label} file ${path} is empty.`);
17949
18056
  return value;
@@ -18124,7 +18231,7 @@ async function cmdApi(options, args) {
18124
18231
  let body = undefined;
18125
18232
  if (options.data !== undefined && options.dataFile)
18126
18233
  throw new Error("Use only one of --data or --data-file.");
18127
- const encoded = options.data === "-" ? await Bun.stdin.text() : options.dataFile ? readFileSync15(options.dataFile, "utf8") : options.data;
18234
+ const encoded = options.data === "-" ? await Bun.stdin.text() : options.dataFile ? readFileSync16(options.dataFile, "utf8") : options.data;
18128
18235
  if (encoded !== undefined)
18129
18236
  body = JSON.parse(encoded);
18130
18237
  const result = await api(options, `${url.pathname}${url.search}`, { method, body });
@@ -18303,7 +18410,7 @@ async function cmdAgent(options, args) {
18303
18410
  out.line();
18304
18411
  out.line(" Compatibility SSH deploy public key:");
18305
18412
  out.line();
18306
- out.line(` ${readFileSync15(gitIdentity.gitPublicKeyPath, "utf8").trim()}`);
18413
+ out.line(` ${readFileSync16(gitIdentity.gitPublicKeyPath, "utf8").trim()}`);
18307
18414
  out.line();
18308
18415
  }
18309
18416
  return 0;
@@ -18413,7 +18520,7 @@ function interactiveMetalBootstrap() {
18413
18520
  });
18414
18521
  }
18415
18522
  function writeBootstrapConfig(path, config) {
18416
- if (!isAbsolute7(path) || resolve11(path) !== path)
18523
+ if (!isAbsolute7(path) || resolve12(path) !== path)
18417
18524
  throw new Error("--output must be a canonical absolute path");
18418
18525
  mkdirSync13(dirname14(path), { recursive: true, mode: 448 });
18419
18526
  writeFileSync13(path, `${JSON.stringify(config, null, 2)}
@@ -18477,7 +18584,7 @@ async function interactiveCloudflareBootstrap() {
18477
18584
  });
18478
18585
  }
18479
18586
  function genesisOutputDirectory(path) {
18480
- if (!isAbsolute7(path) || resolve11(path) !== path)
18587
+ if (!isAbsolute7(path) || resolve12(path) !== path)
18481
18588
  throw new Error("--output must be a canonical absolute directory");
18482
18589
  if (!existsSync12(path))
18483
18590
  mkdirSync13(path, { recursive: true, mode: 448 });
@@ -18642,7 +18749,7 @@ function interactiveBootstrap(kind, genesisGuests, genesisCloudflareCheckpoint)
18642
18749
  const collectorUnit = FORGEZERO_OTEL_COLLECTOR_UNIT;
18643
18750
  const replicationFactor = Number(bootstrapAnswer("Database replication factor", "3"));
18644
18751
  const writeConcern = Number(bootstrapAnswer("Database write concern", "2"));
18645
- const seedPeers = genesis ? fleet.slice(1).map((peer) => `ws://${peer.address}:3001/internal/seed`) : bootstrapAnswer("Seed WebSocket peers (comma-separated)").split(",").map((item) => item.trim()).filter(Boolean);
18752
+ const seedPeers = genesis ? fleet.slice(1).flatMap((peer) => [3001, 3002].map((port) => `ws://${peer.address}:${port}/_internal/forgezero/seed-mesh/v2`)) : bootstrapAnswer("Seed WebSocket peers (comma-separated)").split(",").map((item) => item.trim()).filter(Boolean);
18646
18753
  const emailProvider = bootstrapAnswer("Bootstrap email provider (smtp/jetemail)", "smtp");
18647
18754
  if (!["smtp", "jetemail"].includes(emailProvider))
18648
18755
  throw new Error("Bootstrap email provider must be smtp or jetemail");
@@ -18698,7 +18805,7 @@ function interactiveBootstrap(kind, genesisGuests, genesisCloudflareCheckpoint)
18698
18805
  publicApiPort: Number(bootstrapAnswer("Loopback public API port", "3000")),
18699
18806
  sharedDirectory: "/opt/forgezero/shared",
18700
18807
  seedSyncPeers: seedPeers,
18701
- seedSyncMembers: Number(bootstrapAnswer("Seed quorum members", "3")),
18808
+ seedSyncMembers: bootstrapList("Seed member hostnames (comma-separated)"),
18702
18809
  seedSyncEpoch: bootstrapAnswer("Seed epoch"),
18703
18810
  concurrencyLimit: Number(bootstrapAnswer("Initial concurrency limit", "128")),
18704
18811
  drainDeadlineMs: Number(bootstrapAnswer("Drain deadline milliseconds", "30000")),
@@ -19069,7 +19176,7 @@ async function cmdUnlock(options) {
19069
19176
  if (options.key || options.userExplicit) {
19070
19177
  throw new Error("SSH/user unlock coordinates are not accepted. SSH keys cannot open a WebAuthn-PRF custody envelope; use --phrase-file or --phrase-stdin.");
19071
19178
  }
19072
- const phraseText = options.phraseFile ? readOwnerOnlySecret(options.phraseFile, "recovery phrase") : options.phraseStdin ? readFileSync15(0, "utf8").trim() : "";
19179
+ const phraseText = options.phraseFile ? readOwnerOnlySecret(options.phraseFile, "recovery phrase") : options.phraseStdin ? readFileSync16(0, "utf8").trim() : "";
19073
19180
  const phrase = phraseText ? phraseText.split(/\s+/) : [];
19074
19181
  if (phrase.length !== 24) {
19075
19182
  throw new Error(`Recovery phrase must contain exactly 24 words; received ${phrase.length}. Use --phrase-file or --phrase-stdin.`);
@@ -19166,6 +19273,23 @@ function cmdProject(options, args) {
19166
19273
  return 1;
19167
19274
  }
19168
19275
  }
19276
+ async function cmdApp(options, args) {
19277
+ if ((args[0] ?? "") !== "build" || args.length !== 1) {
19278
+ out.fail("Usage: fz app build [--root <app>] [--profile local|development|production | --api <origin>]");
19279
+ return 2;
19280
+ }
19281
+ try {
19282
+ const plan = planAppBuild({
19283
+ root: options.projectRoot,
19284
+ ...options.deployProfile !== "app" ? { profile: options.deployProfile } : {},
19285
+ ...options.apiExplicit ? { api: options.api } : {}
19286
+ });
19287
+ return await spawnWith(plan.command, Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] !== undefined)), (line) => out.line(line), { cwd: plan.cwd });
19288
+ } catch (cause) {
19289
+ out.fail(cause instanceof Error ? cause.message : String(cause));
19290
+ return 1;
19291
+ }
19292
+ }
19169
19293
  function repositoryOperationArguments(options, args) {
19170
19294
  const unsupported = unsupportedRepositoryCliOption(process.argv.slice(2));
19171
19295
  if (unsupported === "--root")
@@ -19285,7 +19409,7 @@ function projectFromCheckout(options) {
19285
19409
  try {
19286
19410
  return loadConfig({
19287
19411
  cwd: options.projectRoot,
19288
- readFile: (path) => existsSync12(path) ? readFileSync15(path, "utf8") : undefined
19412
+ readFile: (path) => existsSync12(path) ? readFileSync16(path, "utf8") : undefined
19289
19413
  }).config.project;
19290
19414
  } catch (cause) {
19291
19415
  throw new Error(`No --project was given and the checkout has no usable .fz/config.json: ${cause instanceof Error ? cause.message : String(cause)}`);
@@ -19348,7 +19472,7 @@ async function cmdDeploy(options, args) {
19348
19472
  out.ok(`Initialized legacy .fz/deploy.json (${created.summary.digest}).`);
19349
19473
  return 0;
19350
19474
  }
19351
- initializeTypeScriptDeployment(options.projectRoot, { name: options.projectName ?? basename3(resolve11(options.projectRoot)), force: options.force });
19475
+ initializeTypeScriptDeployment(options.projectRoot, { name: options.projectName ?? basename3(resolve12(options.projectRoot)), force: options.force });
19352
19476
  const compiled = await compileDeploymentProject(options.projectRoot);
19353
19477
  if (options.json)
19354
19478
  out.line(JSON.stringify({ source: compiled.source, output: compiled.output, digest: compiled.digest }, null, 2));
@@ -19373,7 +19497,7 @@ async function cmdDeploy(options, args) {
19373
19497
  return problems.length === 0 ? 0 : 1;
19374
19498
  }
19375
19499
  if (operation === "check" || operation === "sync") {
19376
- if (existsSync12(resolve11(options.projectRoot, DEPLOY_SOURCE_FILE))) {
19500
+ if (existsSync12(resolve12(options.projectRoot, DEPLOY_SOURCE_FILE))) {
19377
19501
  const expected = await compileDeploymentProject(options.projectRoot, { write: false });
19378
19502
  const actual = inspectCompiledDeployment(options.projectRoot);
19379
19503
  const current2 = canonicalJson(expected.plan) === canonicalJson(actual.plan);
@@ -19449,7 +19573,7 @@ async function cmdDeploy(options, args) {
19449
19573
  branch: options.branch,
19450
19574
  cloneUrl: required(options.cloneUrl, "--clone-url"),
19451
19575
  sourceAuth,
19452
- ...options.knownHostsFile ? { knownHosts: readFileSync15(options.knownHostsFile, "utf8") } : {},
19576
+ ...options.knownHostsFile ? { knownHosts: readFileSync16(options.knownHostsFile, "utf8") } : {},
19453
19577
  projectKey
19454
19578
  } });
19455
19579
  const pipelineKey2 = String(created.pipelineKey);
@@ -19597,7 +19721,9 @@ function usage() {
19597
19721
  fz bootstrap repair Reapply an explicitly supplied reviewed config
19598
19722
  fz project init Create vendor-neutral, Git-persisted AI context
19599
19723
  fz project sync Regenerate Claude/Codex/Gemini/Copilot/Cursor adapters
19600
- fz project check Fail when truth sources or generated adapters drift
19724
+ fz project check Fail when truth sources or generated adapters drift
19725
+ fz app build Build the static App for a numbered local/development/
19726
+ production choice, --profile, or an explicit --api origin
19601
19727
  fz deploy init Create forgezero.deploy.ts and its fail-safe canonical plan
19602
19728
  fz deploy compile Compile TypeScript into .fz/deploy.plan.json
19603
19729
  fz deploy check Validate source, plan, actions, providers and topology
@@ -19747,6 +19873,9 @@ async function runCli() {
19747
19873
  case "project":
19748
19874
  code = cmdProject(options, args);
19749
19875
  break;
19876
+ case "app":
19877
+ code = await cmdApp(options, args);
19878
+ break;
19750
19879
  case "deploy":
19751
19880
  code = await cmdDeploy(options, args);
19752
19881
  break;
package/dist/index.d.ts CHANGED
@@ -14,6 +14,7 @@ export type { AgentCredentialLocation, DeploymentCredentialBinding, DeploymentCr
14
14
  export { createDeploymentManager, DeploymentError } from './deployment';
15
15
  export type { DeploymentManager, DeploymentOptions, DeploymentRequest, DeploymentResult } from './deployment';
16
16
  export { DeploymentInputError, resolveDeploymentInputs, resolveDeploymentTargets } from './deployment-targets';
17
+ export { DeploymentTopologyError, planDeploymentTopology, type DeploymentTopologyAssignment, type DeploymentTopologyPlacement, type DeploymentTopologyRole, type DeploymentTopologyTransport } from './deployment-topology';
17
18
  export { DEFAULT_CONTROL_SOCKET, requestControl, startControlServer } from './control';
18
19
  export { pullDeploymentOnce, startDeploymentPull } from './deployment-pull';
19
20
  export type { DeploymentPullOptions, RemoteDeploymentClaim, PullResult } from './deployment-pull';
@@ -118,6 +118,17 @@ function membersOfLinuxList(value, label) {
118
118
  throw new MetalProvisionError(`${label} list overlaps itself`);
119
119
  return members;
120
120
  }
121
+ function physicalCoreGroups(pool) {
122
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
123
+ if (cpus.length % pool.physicalCores !== 0) {
124
+ throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
125
+ }
126
+ const threadsPerCore = cpus.length / pool.physicalCores;
127
+ if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
128
+ throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
129
+ }
130
+ return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
131
+ }
121
132
  function validateMetalProfile(profile) {
122
133
  if (!SAFE_NAME.test(profile.volumeGroup))
123
134
  throw new MetalProvisionError("invalid volume group");
@@ -166,6 +177,7 @@ function validateMetalProfile(profile) {
166
177
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
167
178
  throw new MetalProvisionError("invalid CPU pool physical-core count");
168
179
  }
180
+ physicalCoreGroups(pool);
169
181
  for (const cpu of cpus) {
170
182
  if (assigned.has(cpu))
171
183
  throw new MetalProvisionError("CPU pools overlap");
@@ -354,7 +366,7 @@ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
354
366
  }
355
367
 
356
368
  // src/version.ts
357
- var VERSION = "0.1.79";
369
+ var VERSION = "0.1.81";
358
370
 
359
371
  // src/otel-collector.ts
360
372
  var FORGEZERO_OTEL_COLLECTOR_UNIT = "forgezero-otel-collector.service";
@@ -748,6 +748,32 @@ function membersOfLinuxList(value, label) {
748
748
  throw new MetalProvisionError(`${label} list overlaps itself`);
749
749
  return members;
750
750
  }
751
+ function linuxList(members) {
752
+ const sorted = [...new Set(members)].sort((left, right) => left - right);
753
+ const ranges = [];
754
+ for (let index = 0;index < sorted.length; ) {
755
+ const start = sorted[index];
756
+ let end = start;
757
+ while (index + 1 < sorted.length && sorted[index + 1] === end + 1) {
758
+ index += 1;
759
+ end = sorted[index];
760
+ }
761
+ ranges.push(start === end ? String(start) : `${start}-${end}`);
762
+ index += 1;
763
+ }
764
+ return ranges.join(",");
765
+ }
766
+ function physicalCoreGroups(pool) {
767
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
768
+ if (cpus.length % pool.physicalCores !== 0) {
769
+ throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
770
+ }
771
+ const threadsPerCore = cpus.length / pool.physicalCores;
772
+ if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
773
+ throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
774
+ }
775
+ return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
776
+ }
751
777
  var guestNameFor = (computeKey) => `fzg-${createHash2("sha256").update(computeKey).digest("hex").slice(0, 16)}`;
752
778
  var tapNameFor = (computeKey) => `fzt${createHash2("sha256").update(computeKey).digest("hex").slice(0, 12)}`;
753
779
  var macForAddress = (address) => {
@@ -805,6 +831,7 @@ function validateMetalProfile(profile) {
805
831
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
806
832
  throw new MetalProvisionError("invalid CPU pool physical-core count");
807
833
  }
834
+ physicalCoreGroups(pool);
808
835
  for (const cpu of cpus) {
809
836
  if (assigned.has(cpu))
810
837
  throw new MetalProvisionError("CPU pools overlap");
@@ -874,26 +901,38 @@ function allocateCpuPool(profile, claim, rows) {
874
901
  const prior = rows.find((row) => row.computeKey === claim.computeKey);
875
902
  if (prior) {
876
903
  const retained = profile.cpuPools.find((pool) => pool.key === prior.cpuPoolKey);
877
- if (!retained || retained.cpus !== prior.allowedCpus || retained.memoryNodes !== prior.allowedMemoryNodes) {
904
+ const groups2 = retained ? physicalCoreGroups(retained) : [];
905
+ const retainedCpus = new Set(membersOfLinuxList(prior.allowedCpus, "persisted guest CPU"));
906
+ const matchedGroups = groups2.filter((group) => group.every((cpu) => retainedCpus.has(cpu)));
907
+ if (!retained || retained.memoryNodes !== prior.allowedMemoryNodes || matchedGroups.length !== claim.spec.physicalCores || matchedGroups.flat().length !== retainedCpus.size || retainedCpus.size < claim.spec.vcpu || claim.spec.cpuPoolKey !== undefined && claim.spec.cpuPoolKey !== retained.key) {
878
908
  throw new MetalProvisionError("persisted guest CPU pool no longer matches the host profile");
879
909
  }
880
- return retained;
910
+ return {
911
+ key: retained.key,
912
+ cpus: prior.allowedCpus,
913
+ physicalCores: matchedGroups.length,
914
+ memoryNodes: prior.allowedMemoryNodes
915
+ };
881
916
  }
882
- const used = new Set(rows.map((row) => row.cpuPoolKey));
883
- if (claim.spec.cpuPoolKey) {
884
- const requested = profile.cpuPools.find((pool) => pool.key === claim.spec.cpuPoolKey);
885
- if (!requested || used.has(requested.key)) {
886
- throw new MetalProvisionError("requested CPU pool is unavailable");
887
- }
888
- if (requested.physicalCores < claim.spec.physicalCores || membersOfLinuxList(requested.cpus, "CPU").length < claim.spec.vcpu)
889
- throw new MetalProvisionError("requested CPU pool cannot satisfy this guest");
890
- return requested;
891
- }
892
- const candidates = profile.cpuPools.filter((pool) => !used.has(pool.key) && pool.physicalCores >= claim.spec.physicalCores && membersOfLinuxList(pool.cpus, "CPU").length >= claim.spec.vcpu).sort((left, right) => left.physicalCores - right.physicalCores || membersOfLinuxList(left.cpus, "CPU").length - membersOfLinuxList(right.cpus, "CPU").length || left.key.localeCompare(right.key));
917
+ const usedCpus = new Set(rows.flatMap((row) => membersOfLinuxList(row.allowedCpus, "persisted guest CPU")));
918
+ const candidates = profile.cpuPools.filter((pool) => !claim.spec.cpuPoolKey || pool.key === claim.spec.cpuPoolKey).map((pool) => ({
919
+ pool,
920
+ available: physicalCoreGroups(pool).filter((group) => group.every((cpu) => !usedCpus.has(cpu)))
921
+ })).filter(({ available }) => {
922
+ const selected2 = available.slice(0, claim.spec.physicalCores);
923
+ return selected2.length === claim.spec.physicalCores && selected2.flat().length >= claim.spec.vcpu;
924
+ }).sort((left, right) => left.available.length - right.available.length || left.pool.key.localeCompare(right.pool.key));
893
925
  const selected = candidates[0];
894
- if (!selected)
895
- throw new MetalProvisionError("no exclusive CPU pool can satisfy this guest");
896
- return selected;
926
+ if (!selected) {
927
+ throw new MetalProvisionError(claim.spec.cpuPoolKey ? "requested CPU pool cannot satisfy this guest" : "no exclusive CPU capacity can satisfy this guest");
928
+ }
929
+ const groups = selected.available.slice(0, claim.spec.physicalCores);
930
+ return {
931
+ key: selected.pool.key,
932
+ cpus: linuxList(groups.flat()),
933
+ physicalCores: groups.length,
934
+ memoryNodes: selected.pool.memoryNodes
935
+ };
897
936
  }
898
937
  var base64 = (value) => Buffer.from(value).toString("base64");
899
938
  var yamlFile = (path2, content, permissions) => ` - path: ${JSON.stringify(path2)}
@@ -3,7 +3,7 @@ export interface MetalImage {
3
3
  path: string;
4
4
  sha256: string;
5
5
  }
6
- /** One non-overlapping physical isolation boundary available to one guest. */
6
+ /** One non-overlapping NUMA-local CPU capacity pool that may serve several guests. */
7
7
  export interface MetalCpuPool {
8
8
  key: string;
9
9
  /** Linux CPU-list syntax, including every SMT sibling in this pool. */
@@ -13,6 +13,9 @@ export interface MetalCpuPool {
13
13
  /** Linux NUMA node-list syntax. Optional on a non-NUMA machine. */
14
14
  memoryNodes?: string;
15
15
  }
16
+ /** Exact non-overlapping physical-core slice reserved for one guest. */
17
+ export interface MetalCpuAllocation extends MetalCpuPool {
18
+ }
16
19
  export interface MetalProvisionProfile {
17
20
  volumeGroup: string;
18
21
  bridge: string;
@@ -70,8 +73,8 @@ export declare const tapNameFor: (computeKey: string) => string;
70
73
  export declare const macForAddress: (address: string) => string;
71
74
  export declare function validateMetalProfile(profile: MetalProvisionProfile): void;
72
75
  export declare function allocateAddress(profile: MetalProvisionProfile, computeKey: string, rows: GuestManifest[]): string;
73
- /** Stable tight-fit allocation of one exclusive CPU/NUMA pool. */
74
- export declare function allocateCpuPool(profile: MetalProvisionProfile, claim: Pick<RemoteProvisionClaim, 'computeKey' | 'spec'>, rows: GuestManifest[]): MetalCpuPool;
76
+ /** Stable tight-fit allocation of exact physical cores inside one NUMA-local pool. */
77
+ export declare function allocateCpuPool(profile: MetalProvisionProfile, claim: Pick<RemoteProvisionClaim, 'computeKey' | 'spec'>, rows: GuestManifest[]): MetalCpuAllocation;
75
78
  export declare function guestBootstrapOperations(profile: MetalProvisionProfile, attested?: boolean, hasEnrolment?: boolean, nodeLabel?: string): readonly (readonly string[])[];
76
79
  export declare function cloudInit(profile: MetalProvisionProfile, claim: CreateRemoteProvisionClaim, manifest: GuestManifest): {
77
80
  userData: string;