@forgezero/agent 0.1.20 → 0.1.22

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
@@ -144,6 +144,12 @@ read Vault data, accept arbitrary commands, or retain tenant credentials. Root
144
144
  SSH is an operator-only platform-genesis/recovery path, not the normal tenant
145
145
  dispatch mechanism.
146
146
 
147
+ Shutdown has one process-wide deadline. New deployment claims and background
148
+ sync stop first, claimed work drains under its lease fence, and only then do the
149
+ queues and application socket close. Vault/attestation calls or a stale local
150
+ socket cannot consume systemd's longer stop timeout: exceeding the Agent deadline
151
+ is reported and exits non-zero instead of being silently killed midway by PID 1.
152
+
147
153
  Repository read authorization is explicit per pipeline: public HTTPS, the
148
154
  compute's systemd-sealed SSH deploy key, or a fine-grained HTTPS token selected
149
155
  by a project-vault secret name. A signed claim contains the mode and secret name
@@ -7,7 +7,6 @@ export interface SoftwareRequirement {
7
7
  }
8
8
  export interface PipelineRole {
9
9
  name: string;
10
- count: number;
11
10
  software: readonly SoftwareRequirement[];
12
11
  }
13
12
  export interface DeployStep extends PipelineStep {
@@ -50,17 +50,12 @@ function parseDeployDefinition(value) {
50
50
  }
51
51
  const roles = root.roles.map((raw, index) => {
52
52
  const role = record(raw, `roles[${index}]`);
53
- exactKeys(role, ["name", "count", "software"], `roles[${index}]`);
54
- const count = Number(role.count);
55
- if (!Number.isSafeInteger(count) || count < 1) {
56
- throw new DefinitionError(`roles[${index}].count must be a positive integer.`);
57
- }
53
+ exactKeys(role, ["name", "software"], `roles[${index}]`);
58
54
  if (!Array.isArray(role.software)) {
59
55
  throw new DefinitionError(`roles[${index}].software must be an array.`);
60
56
  }
61
57
  return {
62
58
  name: text(role.name, `roles[${index}].name`),
63
- count,
64
59
  software: role.software.map((rawSoftware, softwareIndex) => {
65
60
  const software = record(rawSoftware, `roles[${index}].software[${softwareIndex}]`);
66
61
  exactKeys(software, ["name", "check", "install"], `roles[${index}].software[${softwareIndex}]`);
@@ -79,6 +79,7 @@ export declare function createDeploymentManager(options: DeploymentOptions): {
79
79
  latestRevision(): Promise<string>;
80
80
  deploy(request?: DeploymentRequest): QueueTask<DeploymentResult>;
81
81
  snapshot: () => {
82
+ width: number;
82
83
  running: number;
83
84
  queued: number;
84
85
  keys: number;
package/dist/fz-agent.js CHANGED
@@ -473,17 +473,12 @@ function parseDeployDefinition(value) {
473
473
  }
474
474
  const roles = root.roles.map((raw, index) => {
475
475
  const role = record(raw, `roles[${index}]`);
476
- exactKeys(role, ["name", "count", "software"], `roles[${index}]`);
477
- const count = Number(role.count);
478
- if (!Number.isSafeInteger(count) || count < 1) {
479
- throw new DefinitionError(`roles[${index}].count must be a positive integer.`);
480
- }
476
+ exactKeys(role, ["name", "software"], `roles[${index}]`);
481
477
  if (!Array.isArray(role.software)) {
482
478
  throw new DefinitionError(`roles[${index}].software must be an array.`);
483
479
  }
484
480
  return {
485
481
  name: text(role.name, `roles[${index}].name`),
486
- count,
487
482
  software: role.software.map((rawSoftware, softwareIndex) => {
488
483
  const software = record(rawSoftware, `roles[${index}].software[${softwareIndex}]`);
489
484
  exactKeys(software, ["name", "check", "install"], `roles[${index}].software[${softwareIndex}]`);
@@ -2714,8 +2709,31 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
2714
2709
  }
2715
2710
  }
2716
2711
 
2712
+ // src/shutdown.ts
2713
+ async function settleWithin(work, timeoutMs, setTimer = setTimeout, clearTimer = clearTimeout) {
2714
+ let timer;
2715
+ try {
2716
+ return await Promise.race([
2717
+ work.then(() => true),
2718
+ new Promise((resolve2) => {
2719
+ timer = setTimer(() => resolve2(false), Math.max(1, timeoutMs));
2720
+ timer.unref?.();
2721
+ })
2722
+ ]);
2723
+ } finally {
2724
+ if (timer)
2725
+ clearTimer(timer);
2726
+ }
2727
+ }
2728
+ async function closeServerWithin(server, timeoutMs) {
2729
+ const closed = await settleWithin(new Promise((resolve2) => server.close(() => resolve2())), timeoutMs);
2730
+ if (!closed)
2731
+ server.unref();
2732
+ return closed;
2733
+ }
2734
+
2717
2735
  // src/version.ts
2718
- var VERSION = "0.1.20";
2736
+ var VERSION = "0.1.22";
2719
2737
 
2720
2738
  // src/index.ts
2721
2739
  function loadOrCreateSeed(path) {
@@ -3150,24 +3168,26 @@ if (import.meta.main) {
3150
3168
  return;
3151
3169
  stopping = true;
3152
3170
  console.log(`[agent] ${signal}: stopping deployment intake and draining`);
3153
- if (control)
3154
- await new Promise((resolve2) => control.close(() => resolve2()));
3155
3171
  const deadlineMs = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 30000));
3156
3172
  const deadline = Date.now() + deadlineMs;
3173
+ const remaining = () => Math.max(1, deadline - Date.now());
3174
+ const controlClosed = control ? await settleWithin(new Promise((resolve2) => control.close(() => resolve2())), remaining()) : true;
3157
3175
  const pullDrain = pull?.stop() ?? Promise.resolve();
3158
3176
  const vaultDrain = vaultSync?.stop() ?? Promise.resolve();
3159
3177
  const attestationDrain = attestationLoop?.stop() ?? Promise.resolve();
3160
- const pullWithinDeadline = pull ? Promise.race([
3161
- pullDrain.then(() => true),
3162
- new Promise((resolve2) => setTimeout(() => resolve2(false), deadlineMs))
3163
- ]) : Promise.resolve(true);
3164
- const pullDrained = await pullWithinDeadline;
3165
- await Promise.all([vaultDrain, attestationDrain]);
3166
- const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(Math.max(1, deadline - Date.now()))));
3167
- await new Promise((resolve2) => server.close(() => resolve2()));
3178
+ const pullDrained = pull ? await settleWithin(pullDrain, remaining()) : true;
3179
+ const backgroundDrained = await settleWithin(Promise.all([vaultDrain, attestationDrain]), remaining());
3180
+ const managerReports = await Promise.all([...new Set(managers.values())].map((manager) => manager.stop(remaining())));
3181
+ const socketClosed = await closeServerWithin(server, remaining());
3168
3182
  const timedOut = managerReports.some((report) => report.timedOut);
3169
- console.log(`[agent] drain ${JSON.stringify({ managers: managerReports, pullDrained })}`);
3170
- process.exit(timedOut || !pullDrained ? 1 : 0);
3183
+ console.log(`[agent] drain ${JSON.stringify({
3184
+ managers: managerReports,
3185
+ controlClosed,
3186
+ pullDrained,
3187
+ backgroundDrained,
3188
+ socketClosed
3189
+ })}`);
3190
+ process.exit(timedOut || !controlClosed || !pullDrained || !backgroundDrained || !socketClosed ? 1 : 0);
3171
3191
  };
3172
3192
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
3173
3193
  process.on("SIGINT", () => void shutdown("SIGINT"));
@@ -3175,19 +3195,24 @@ if (import.meta.main) {
3175
3195
  console.log("[agent] signing/vault mode only; deployment root and pull are not configured");
3176
3196
  if (vaultSync || attestationLoop) {
3177
3197
  let stopping = false;
3178
- const stop = async () => {
3198
+ const stop = async (signal) => {
3179
3199
  if (stopping)
3180
3200
  return;
3181
3201
  stopping = true;
3182
- await Promise.all([
3202
+ const deadlineMs = Math.max(1, Number(process.env.FZ_DRAIN_DEADLINE_MS ?? 30000));
3203
+ const deadline = Date.now() + deadlineMs;
3204
+ const remaining = () => Math.max(1, deadline - Date.now());
3205
+ console.log(`[agent] ${signal}: stopping background intake and draining`);
3206
+ const backgroundDrained = await settleWithin(Promise.all([
3183
3207
  vaultSync?.stop() ?? Promise.resolve(),
3184
3208
  attestationLoop?.stop() ?? Promise.resolve()
3185
- ]);
3186
- await new Promise((resolve2) => server.close(() => resolve2()));
3187
- process.exit(0);
3209
+ ]), remaining());
3210
+ const socketClosed = await closeServerWithin(server, remaining());
3211
+ console.log(`[agent] drain ${JSON.stringify({ backgroundDrained, socketClosed })}`);
3212
+ process.exit(backgroundDrained && socketClosed ? 0 : 1);
3188
3213
  };
3189
- process.on("SIGTERM", () => void stop());
3190
- process.on("SIGINT", () => void stop());
3214
+ process.on("SIGTERM", () => void stop("SIGTERM"));
3215
+ process.on("SIGINT", () => void stop("SIGINT"));
3191
3216
  }
3192
3217
  }
3193
3218
  }
package/dist/fz.js CHANGED
@@ -774,7 +774,7 @@ async function resolveIdentity(selector, socketPath) {
774
774
  }
775
775
 
776
776
  // src/version.ts
777
- var VERSION = "0.1.20";
777
+ var VERSION = "0.1.22";
778
778
 
779
779
  // src/cli/index.ts
780
780
  var DEFAULT_MODE = THRESHOLD_MODES[0].id;
@@ -0,0 +1,10 @@
1
+ import type { Server } from 'node:net';
2
+ /**
3
+ * Wait for one shutdown stage without allowing it to consume systemd's entire
4
+ * stop timeout. The work is not cancelled: deployment/lease code retains its
5
+ * own fencing rules, while the caller decides whether an expired global drain
6
+ * deadline must terminate the process non-zero.
7
+ */
8
+ export declare function settleWithin(work: Promise<unknown>, timeoutMs: number, setTimer?: typeof setTimeout, clearTimer?: typeof clearTimeout): Promise<boolean>;
9
+ /** Stop accepting local Vault clients, but never let one stale socket defeat shutdown. */
10
+ export declare function closeServerWithin(server: Server, timeoutMs: number): Promise<boolean>;
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.20";
2
+ export declare const VERSION = "0.1.22";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
3
  "name": "@forgezero/agent",
4
- "version": "0.1.20",
4
+ "version": "0.1.22",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "check": "tsc --noEmit",
@@ -18,7 +18,7 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@forgezero/access": "^0.1.0",
21
- "@forgezero/runtime": "^0.1.3",
21
+ "@forgezero/runtime": "^0.1.4",
22
22
  "@forgezero/vault": "^0.1.5",
23
23
  "@noble/curves": "^2.2.0",
24
24
  "@noble/hashes": "^2.2.0",