@forgezero/agent 0.1.80 → 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/README.md CHANGED
@@ -1511,7 +1511,7 @@ export const selectedCapability = METAL_BOOTSTRAP_STATE_PATH;
1511
1511
  <a id="forgezero-agent-metal-provision"></a>
1512
1512
  ## @forgezero/agent/metal-provision
1513
1513
 
1514
- The single constrained compute materializer for platform genesis, platform scaling and tenant compute. It executes caller-supplied OS, CPU, memory, disk, bandwidth, network and confidentiality coordinates after capacity and image validation; it owns no environment or product-tier shape. This entry exposes 11 named value exports and 6 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1514
+ The single constrained compute materializer for platform genesis, platform scaling and tenant compute. It executes caller-supplied OS, CPU, memory, disk, bandwidth, network and confidentiality coordinates after capacity and image validation; it owns no environment or product-tier shape. This entry exposes 11 named value exports and 7 named type exports. The generated import block lists one name per line for scanning and copying; keep only the names used by your file.
1515
1515
 
1516
1516
  ```text
1517
1517
  import {
@@ -1531,6 +1531,7 @@ import {
1531
1531
  import type {
1532
1532
  GuestManifest,
1533
1533
  MetalCommandResult,
1534
+ MetalCpuAllocation,
1534
1535
  MetalCpuPool,
1535
1536
  MetalExec,
1536
1537
  MetalImage,
@@ -750,7 +750,7 @@ async function postSignedNode(options, path, body) {
750
750
  }
751
751
 
752
752
  // src/version.ts
753
- var VERSION3 = "0.1.80";
753
+ var VERSION3 = "0.1.81";
754
754
 
755
755
  // src/agent-heartbeat.ts
756
756
  function readAgentHostMetrics() {
package/dist/bootstrap.js CHANGED
@@ -1416,7 +1416,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
1416
1416
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
1417
1417
 
1418
1418
  // src/version.ts
1419
- var VERSION = "0.1.80";
1419
+ var VERSION = "0.1.81";
1420
1420
 
1421
1421
  // src/software.ts
1422
1422
  var PINNED_BUN_VERSION = "1.3.14";
@@ -0,0 +1,10 @@
1
+ export interface AppBuildPlan {
2
+ cwd: string;
3
+ command: readonly string[];
4
+ }
5
+ /** Resolve a checkout without teaching the globally installed CLI any App internals. */
6
+ export declare function planAppBuild(input: {
7
+ root: string;
8
+ profile?: string;
9
+ api?: string;
10
+ }): AppBuildPlan;
package/dist/fz-agent.js CHANGED
@@ -9331,7 +9331,7 @@ async function writeAndCloseProcessInput(input, value) {
9331
9331
  }
9332
9332
 
9333
9333
  // src/version.ts
9334
- var VERSION2 = "0.1.80";
9334
+ var VERSION2 = "0.1.81";
9335
9335
 
9336
9336
  // src/ssh-bootstrap.ts
9337
9337
  class SshBootstrapError extends Error {
@@ -10208,6 +10208,32 @@ function membersOfLinuxList(value, label) {
10208
10208
  throw new MetalProvisionError(`${label} list overlaps itself`);
10209
10209
  return members;
10210
10210
  }
10211
+ function linuxList(members) {
10212
+ const sorted = [...new Set(members)].sort((left, right) => left - right);
10213
+ const ranges = [];
10214
+ for (let index = 0;index < sorted.length; ) {
10215
+ const start = sorted[index];
10216
+ let end = start;
10217
+ while (index + 1 < sorted.length && sorted[index + 1] === end + 1) {
10218
+ index += 1;
10219
+ end = sorted[index];
10220
+ }
10221
+ ranges.push(start === end ? String(start) : `${start}-${end}`);
10222
+ index += 1;
10223
+ }
10224
+ return ranges.join(",");
10225
+ }
10226
+ function physicalCoreGroups(pool) {
10227
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
10228
+ if (cpus.length % pool.physicalCores !== 0) {
10229
+ throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
10230
+ }
10231
+ const threadsPerCore = cpus.length / pool.physicalCores;
10232
+ if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
10233
+ throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
10234
+ }
10235
+ return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
10236
+ }
10211
10237
  var guestNameFor = (computeKey) => `fzg-${createHash7("sha256").update(computeKey).digest("hex").slice(0, 16)}`;
10212
10238
  var tapNameFor = (computeKey) => `fzt${createHash7("sha256").update(computeKey).digest("hex").slice(0, 12)}`;
10213
10239
  var macForAddress = (address) => {
@@ -10265,6 +10291,7 @@ function validateMetalProfile(profile) {
10265
10291
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
10266
10292
  throw new MetalProvisionError("invalid CPU pool physical-core count");
10267
10293
  }
10294
+ physicalCoreGroups(pool);
10268
10295
  for (const cpu of cpus) {
10269
10296
  if (assigned.has(cpu))
10270
10297
  throw new MetalProvisionError("CPU pools overlap");
@@ -10334,26 +10361,38 @@ function allocateCpuPool(profile, claim, rows) {
10334
10361
  const prior = rows.find((row2) => row2.computeKey === claim.computeKey);
10335
10362
  if (prior) {
10336
10363
  const retained = profile.cpuPools.find((pool) => pool.key === prior.cpuPoolKey);
10337
- if (!retained || retained.cpus !== prior.allowedCpus || retained.memoryNodes !== prior.allowedMemoryNodes) {
10364
+ const groups2 = retained ? physicalCoreGroups(retained) : [];
10365
+ const retainedCpus = new Set(membersOfLinuxList(prior.allowedCpus, "persisted guest CPU"));
10366
+ const matchedGroups = groups2.filter((group) => group.every((cpu) => retainedCpus.has(cpu)));
10367
+ 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) {
10338
10368
  throw new MetalProvisionError("persisted guest CPU pool no longer matches the host profile");
10339
10369
  }
10340
- return retained;
10341
- }
10342
- const used = new Set(rows.map((row2) => row2.cpuPoolKey));
10343
- if (claim.spec.cpuPoolKey) {
10344
- const requested = profile.cpuPools.find((pool) => pool.key === claim.spec.cpuPoolKey);
10345
- if (!requested || used.has(requested.key)) {
10346
- throw new MetalProvisionError("requested CPU pool is unavailable");
10347
- }
10348
- if (requested.physicalCores < claim.spec.physicalCores || membersOfLinuxList(requested.cpus, "CPU").length < claim.spec.vcpu)
10349
- throw new MetalProvisionError("requested CPU pool cannot satisfy this guest");
10350
- return requested;
10370
+ return {
10371
+ key: retained.key,
10372
+ cpus: prior.allowedCpus,
10373
+ physicalCores: matchedGroups.length,
10374
+ memoryNodes: prior.allowedMemoryNodes
10375
+ };
10351
10376
  }
10352
- 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));
10377
+ const usedCpus = new Set(rows.flatMap((row2) => membersOfLinuxList(row2.allowedCpus, "persisted guest CPU")));
10378
+ const candidates = profile.cpuPools.filter((pool) => !claim.spec.cpuPoolKey || pool.key === claim.spec.cpuPoolKey).map((pool) => ({
10379
+ pool,
10380
+ available: physicalCoreGroups(pool).filter((group) => group.every((cpu) => !usedCpus.has(cpu)))
10381
+ })).filter(({ available }) => {
10382
+ const selected2 = available.slice(0, claim.spec.physicalCores);
10383
+ return selected2.length === claim.spec.physicalCores && selected2.flat().length >= claim.spec.vcpu;
10384
+ }).sort((left, right) => left.available.length - right.available.length || left.pool.key.localeCompare(right.pool.key));
10353
10385
  const selected = candidates[0];
10354
- if (!selected)
10355
- throw new MetalProvisionError("no exclusive CPU pool can satisfy this guest");
10356
- return selected;
10386
+ if (!selected) {
10387
+ throw new MetalProvisionError(claim.spec.cpuPoolKey ? "requested CPU pool cannot satisfy this guest" : "no exclusive CPU capacity can satisfy this guest");
10388
+ }
10389
+ const groups = selected.available.slice(0, claim.spec.physicalCores);
10390
+ return {
10391
+ key: selected.pool.key,
10392
+ cpus: linuxList(groups.flat()),
10393
+ physicalCores: groups.length,
10394
+ memoryNodes: selected.pool.memoryNodes
10395
+ };
10357
10396
  }
10358
10397
  var base64 = (value) => Buffer.from(value).toString("base64");
10359
10398
  var yamlFile = (path2, content, permissions) => ` - path: ${JSON.stringify(path2)}
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.80";
4840
+ var VERSION2 = "0.1.81";
4841
4841
 
4842
4842
  // src/software.ts
4843
4843
  var PINNED_BUN_VERSION = "1.3.14";
@@ -15550,6 +15550,17 @@ function membersOfLinuxList(value, label) {
15550
15550
  throw new MetalProvisionError(`${label} list overlaps itself`);
15551
15551
  return members;
15552
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
+ }
15553
15564
  function validateMetalProfile(profile) {
15554
15565
  if (!SAFE_NAME2.test(profile.volumeGroup))
15555
15566
  throw new MetalProvisionError("invalid volume group");
@@ -15598,6 +15609,7 @@ function validateMetalProfile(profile) {
15598
15609
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
15599
15610
  throw new MetalProvisionError("invalid CPU pool physical-core count");
15600
15611
  }
15612
+ physicalCoreGroups(pool);
15601
15613
  for (const cpu of cpus) {
15602
15614
  if (assigned.has(cpu))
15603
15615
  throw new MetalProvisionError("CPU pools overlap");
@@ -17678,6 +17690,38 @@ async function runRepositoryOperation(operation, requestedRoot, args = []) {
17678
17690
  `), { cwd: resolved.cwd });
17679
17691
  }
17680
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
+
17681
17725
  // src/cli/index.ts
17682
17726
  var DEFAULT_MODE = (THRESHOLD_MODES.find((mode) => mode.threshold === 1 && mode.total === 1) ?? THRESHOLD_MODES[0]).id;
17683
17727
  var PACKAGED_AGENT_BIN2 = fileURLToPath3(new URL("./fz-agent.js", import.meta.url));
@@ -17907,7 +17951,7 @@ async function api(options, path, init) {
17907
17951
  return { status: response.status, body };
17908
17952
  }
17909
17953
  function sleep(ms) {
17910
- return new Promise((resolve12) => setTimeout(resolve12, ms));
17954
+ return new Promise((resolve13) => setTimeout(resolve13, ms));
17911
17955
  }
17912
17956
  function browserCommand(url) {
17913
17957
  if (process.platform === "darwin")
@@ -18006,7 +18050,7 @@ function readOwnerOnlySecret(path, label) {
18006
18050
  if (uid !== undefined && uid !== 0 && metadata.uid !== uid) {
18007
18051
  throw new Error(`The ${label} file ${path} is not owned by the current user.`);
18008
18052
  }
18009
- const value = readFileSync15(path, "utf8").trim();
18053
+ const value = readFileSync16(path, "utf8").trim();
18010
18054
  if (!value)
18011
18055
  throw new Error(`The ${label} file ${path} is empty.`);
18012
18056
  return value;
@@ -18187,7 +18231,7 @@ async function cmdApi(options, args) {
18187
18231
  let body = undefined;
18188
18232
  if (options.data !== undefined && options.dataFile)
18189
18233
  throw new Error("Use only one of --data or --data-file.");
18190
- 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;
18191
18235
  if (encoded !== undefined)
18192
18236
  body = JSON.parse(encoded);
18193
18237
  const result = await api(options, `${url.pathname}${url.search}`, { method, body });
@@ -18366,7 +18410,7 @@ async function cmdAgent(options, args) {
18366
18410
  out.line();
18367
18411
  out.line(" Compatibility SSH deploy public key:");
18368
18412
  out.line();
18369
- out.line(` ${readFileSync15(gitIdentity.gitPublicKeyPath, "utf8").trim()}`);
18413
+ out.line(` ${readFileSync16(gitIdentity.gitPublicKeyPath, "utf8").trim()}`);
18370
18414
  out.line();
18371
18415
  }
18372
18416
  return 0;
@@ -18476,7 +18520,7 @@ function interactiveMetalBootstrap() {
18476
18520
  });
18477
18521
  }
18478
18522
  function writeBootstrapConfig(path, config) {
18479
- if (!isAbsolute7(path) || resolve11(path) !== path)
18523
+ if (!isAbsolute7(path) || resolve12(path) !== path)
18480
18524
  throw new Error("--output must be a canonical absolute path");
18481
18525
  mkdirSync13(dirname14(path), { recursive: true, mode: 448 });
18482
18526
  writeFileSync13(path, `${JSON.stringify(config, null, 2)}
@@ -18540,7 +18584,7 @@ async function interactiveCloudflareBootstrap() {
18540
18584
  });
18541
18585
  }
18542
18586
  function genesisOutputDirectory(path) {
18543
- if (!isAbsolute7(path) || resolve11(path) !== path)
18587
+ if (!isAbsolute7(path) || resolve12(path) !== path)
18544
18588
  throw new Error("--output must be a canonical absolute directory");
18545
18589
  if (!existsSync12(path))
18546
18590
  mkdirSync13(path, { recursive: true, mode: 448 });
@@ -19132,7 +19176,7 @@ async function cmdUnlock(options) {
19132
19176
  if (options.key || options.userExplicit) {
19133
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.");
19134
19178
  }
19135
- 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() : "";
19136
19180
  const phrase = phraseText ? phraseText.split(/\s+/) : [];
19137
19181
  if (phrase.length !== 24) {
19138
19182
  throw new Error(`Recovery phrase must contain exactly 24 words; received ${phrase.length}. Use --phrase-file or --phrase-stdin.`);
@@ -19229,6 +19273,23 @@ function cmdProject(options, args) {
19229
19273
  return 1;
19230
19274
  }
19231
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
+ }
19232
19293
  function repositoryOperationArguments(options, args) {
19233
19294
  const unsupported = unsupportedRepositoryCliOption(process.argv.slice(2));
19234
19295
  if (unsupported === "--root")
@@ -19348,7 +19409,7 @@ function projectFromCheckout(options) {
19348
19409
  try {
19349
19410
  return loadConfig({
19350
19411
  cwd: options.projectRoot,
19351
- readFile: (path) => existsSync12(path) ? readFileSync15(path, "utf8") : undefined
19412
+ readFile: (path) => existsSync12(path) ? readFileSync16(path, "utf8") : undefined
19352
19413
  }).config.project;
19353
19414
  } catch (cause) {
19354
19415
  throw new Error(`No --project was given and the checkout has no usable .fz/config.json: ${cause instanceof Error ? cause.message : String(cause)}`);
@@ -19411,7 +19472,7 @@ async function cmdDeploy(options, args) {
19411
19472
  out.ok(`Initialized legacy .fz/deploy.json (${created.summary.digest}).`);
19412
19473
  return 0;
19413
19474
  }
19414
- 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 });
19415
19476
  const compiled = await compileDeploymentProject(options.projectRoot);
19416
19477
  if (options.json)
19417
19478
  out.line(JSON.stringify({ source: compiled.source, output: compiled.output, digest: compiled.digest }, null, 2));
@@ -19436,7 +19497,7 @@ async function cmdDeploy(options, args) {
19436
19497
  return problems.length === 0 ? 0 : 1;
19437
19498
  }
19438
19499
  if (operation === "check" || operation === "sync") {
19439
- if (existsSync12(resolve11(options.projectRoot, DEPLOY_SOURCE_FILE))) {
19500
+ if (existsSync12(resolve12(options.projectRoot, DEPLOY_SOURCE_FILE))) {
19440
19501
  const expected = await compileDeploymentProject(options.projectRoot, { write: false });
19441
19502
  const actual = inspectCompiledDeployment(options.projectRoot);
19442
19503
  const current2 = canonicalJson(expected.plan) === canonicalJson(actual.plan);
@@ -19512,7 +19573,7 @@ async function cmdDeploy(options, args) {
19512
19573
  branch: options.branch,
19513
19574
  cloneUrl: required(options.cloneUrl, "--clone-url"),
19514
19575
  sourceAuth,
19515
- ...options.knownHostsFile ? { knownHosts: readFileSync15(options.knownHostsFile, "utf8") } : {},
19576
+ ...options.knownHostsFile ? { knownHosts: readFileSync16(options.knownHostsFile, "utf8") } : {},
19516
19577
  projectKey
19517
19578
  } });
19518
19579
  const pipelineKey2 = String(created.pipelineKey);
@@ -19660,7 +19721,9 @@ function usage() {
19660
19721
  fz bootstrap repair Reapply an explicitly supplied reviewed config
19661
19722
  fz project init Create vendor-neutral, Git-persisted AI context
19662
19723
  fz project sync Regenerate Claude/Codex/Gemini/Copilot/Cursor adapters
19663
- 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
19664
19727
  fz deploy init Create forgezero.deploy.ts and its fail-safe canonical plan
19665
19728
  fz deploy compile Compile TypeScript into .fz/deploy.plan.json
19666
19729
  fz deploy check Validate source, plan, actions, providers and topology
@@ -19810,6 +19873,9 @@ async function runCli() {
19810
19873
  case "project":
19811
19874
  code = cmdProject(options, args);
19812
19875
  break;
19876
+ case "app":
19877
+ code = await cmdApp(options, args);
19878
+ break;
19813
19879
  case "deploy":
19814
19880
  code = await cmdDeploy(options, args);
19815
19881
  break;
@@ -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.80";
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;
@@ -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)}
@@ -1416,7 +1416,7 @@ var UPDATE_RETRY_BASE_MS = 5 * 60000;
1416
1416
  var UPDATE_RETRY_MAX_MS = 24 * 60 * 60000;
1417
1417
 
1418
1418
  // src/version.ts
1419
- var VERSION = "0.1.80";
1419
+ var VERSION = "0.1.81";
1420
1420
 
1421
1421
  // src/software.ts
1422
1422
  var PINNED_BUN_VERSION = "1.3.14";
@@ -5027,6 +5027,17 @@ function membersOfLinuxList(value, label) {
5027
5027
  throw new MetalProvisionError(`${label} list overlaps itself`);
5028
5028
  return members;
5029
5029
  }
5030
+ function physicalCoreGroups(pool) {
5031
+ const cpus = membersOfLinuxList(pool.cpus, "CPU");
5032
+ if (cpus.length % pool.physicalCores !== 0) {
5033
+ throw new MetalProvisionError("CPU pool threads must divide evenly across physical cores");
5034
+ }
5035
+ const threadsPerCore = cpus.length / pool.physicalCores;
5036
+ if (!Number.isInteger(threadsPerCore) || threadsPerCore < 1 || threadsPerCore > 8) {
5037
+ throw new MetalProvisionError("invalid CPU pool threads-per-core topology");
5038
+ }
5039
+ return Array.from({ length: pool.physicalCores }, (_, core) => Array.from({ length: threadsPerCore }, (_2, thread) => cpus[core + thread * pool.physicalCores]));
5040
+ }
5030
5041
  function validateMetalProfile(profile) {
5031
5042
  if (!SAFE_NAME2.test(profile.volumeGroup))
5032
5043
  throw new MetalProvisionError("invalid volume group");
@@ -5075,6 +5086,7 @@ function validateMetalProfile(profile) {
5075
5086
  if (!Number.isInteger(pool.physicalCores) || pool.physicalCores < 1 || pool.physicalCores > cpus.length) {
5076
5087
  throw new MetalProvisionError("invalid CPU pool physical-core count");
5077
5088
  }
5089
+ physicalCoreGroups(pool);
5078
5090
  for (const cpu of cpus) {
5079
5091
  if (assigned.has(cpu))
5080
5092
  throw new MetalProvisionError("CPU pools overlap");
@@ -2647,7 +2647,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2647
2647
  }
2648
2648
 
2649
2649
  // src/version.ts
2650
- var VERSION3 = "0.1.80";
2650
+ var VERSION3 = "0.1.81";
2651
2651
 
2652
2652
  // src/egress-policy.ts
2653
2653
  import { realpathSync as realpathSync3 } from "node:fs";
package/dist/provision.js CHANGED
@@ -2647,7 +2647,7 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
2647
2647
  }
2648
2648
 
2649
2649
  // src/version.ts
2650
- var VERSION3 = "0.1.80";
2650
+ var VERSION3 = "0.1.81";
2651
2651
 
2652
2652
  // src/egress-policy.ts
2653
2653
  import { realpathSync as realpathSync3 } from "node:fs";
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** One package version shared by both public binaries. Pinned to package.json by tests. */
2
- export declare const VERSION = "0.1.80";
2
+ export declare const VERSION = "0.1.81";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/agent",
3
- "version": "0.1.80",
3
+ "version": "0.1.81",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "check": "tsc --noEmit",