@forgezero/agent 0.1.85 → 0.1.87

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.
@@ -7,15 +7,21 @@ export declare const AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.jso
7
7
  /** Bounded, group-readable evidence consumed by the unprivileged Agent heartbeat. */
8
8
  export declare const AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
9
9
  export type AgentUpdateRequest = {
10
- op: 'apply';
10
+ op: 'prepare';
11
11
  target: 'compute' | 'metal';
12
12
  attemptId?: string;
13
13
  currentVersion: string;
14
14
  release: AgentRelease;
15
+ } | {
16
+ op: 'commit' | 'abort';
17
+ target: 'compute' | 'metal';
18
+ attemptId: string;
19
+ currentVersion: string;
20
+ targetVersion: string;
15
21
  };
16
22
  export type AgentUpdateResponse = {
17
23
  ok: true;
18
- status: 'staged';
24
+ status: 'prepared' | 'active' | 'aborted';
19
25
  version: string;
20
26
  attemptId: string;
21
27
  } | {
@@ -53,6 +59,8 @@ export declare function activateAgentRelease(staged: StagedAgentRelease, options
53
59
  journalPath?: string;
54
60
  attemptId?: string;
55
61
  now?: () => number;
62
+ candidatePrepared?: boolean;
63
+ publicSocketPath?: string;
56
64
  }): Promise<{
57
65
  ok: true;
58
66
  version: string;
@@ -70,6 +78,7 @@ export declare function recoverInterruptedAgentUpdate(options?: {
70
78
  run?: (input: UpdateCommand) => Promise<UpdateCommandResult>;
71
79
  probe?: () => Promise<boolean>;
72
80
  now?: () => number;
81
+ publicSocketPath?: string;
73
82
  }): Promise<AgentUpdateReceipt | undefined>;
74
83
  export declare function startAgentUpdateHelper(options?: {
75
84
  socketPath?: string;
@@ -16,6 +16,7 @@ import {
16
16
  } from "node:fs";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
  var DEFAULT_AGENT_RELEASE_ROOT = "/opt/forgezero/agent";
19
+ var DEFAULT_AGENT_CANDIDATE_LINK = `${DEFAULT_AGENT_RELEASE_ROOT}/candidate`;
19
20
  var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
20
21
  var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
21
22
  var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
@@ -209,25 +210,90 @@ function restoreAgentRelease(staged) {
209
210
  rmSync(next, { force: true });
210
211
  }
211
212
  }
213
+ function selectAgentCandidate(staged) {
214
+ const link = join(dirname(staged.currentLink), "candidate");
215
+ const next = `${link}.${randomUUID()}.next`;
216
+ try {
217
+ symlinkSync(staged.nextTarget, next);
218
+ renameSync(next, link);
219
+ syncPath(dirname(link));
220
+ } finally {
221
+ rmSync(next, { force: true });
222
+ }
223
+ }
224
+ function clearAgentCandidate(root = DEFAULT_AGENT_RELEASE_ROOT) {
225
+ rmSync(join(resolve(root), "candidate"), { force: true });
226
+ }
212
227
 
213
228
  // src/agent-update-helper.ts
214
- import { randomUUID as randomUUID2 } from "node:crypto";
229
+ import { randomUUID as randomUUID3 } from "node:crypto";
215
230
  import {
216
- chmodSync as chmodSync2,
231
+ chmodSync as chmodSync3,
217
232
  closeSync as closeSync2,
218
- existsSync as existsSync2,
233
+ existsSync as existsSync3,
219
234
  fsyncSync as fsyncSync2,
220
235
  mkdirSync as mkdirSync2,
221
236
  openSync as openSync2,
222
237
  readFileSync as readFileSync2,
223
- renameSync as renameSync2,
224
- rmSync as rmSync2,
225
- unlinkSync,
238
+ renameSync as renameSync3,
239
+ rmSync as rmSync3,
240
+ unlinkSync as unlinkSync2,
226
241
  writeFileSync as writeFileSync2
227
242
  } from "node:fs";
228
- import { connect, createServer } from "node:net";
229
- import { dirname as dirname2, join as join2, resolve as resolve2 } from "node:path";
243
+ import { connect as connect2, createServer as createServer2 } from "node:net";
244
+ import { dirname as dirname3, join as join2, resolve as resolve2 } from "node:path";
230
245
  import { DEFAULT_SOCKET } from "@forgezero/vault";
246
+
247
+ // src/agent-handover.ts
248
+ import { randomUUID as randomUUID2 } from "node:crypto";
249
+ import { chmodSync as chmodSync2, existsSync as existsSync2, renameSync as renameSync2, rmSync as rmSync2, symlinkSync as symlinkSync2, unlinkSync } from "node:fs";
250
+ import { basename, dirname as dirname2 } from "node:path";
251
+ import { connect, createServer } from "node:net";
252
+ var DEFAULT_AGENT_CANDIDATE_READY_SOCKET = "/run/forgezero/candidate-ready.sock";
253
+ function switchAgentSocketRoute(route, backend) {
254
+ if (dirname2(route) !== dirname2(backend))
255
+ throw new Error("Agent handover sockets must share one runtime directory");
256
+ const target = basename(backend);
257
+ const next = `${route}.${randomUUID2()}.next`;
258
+ try {
259
+ symlinkSync2(target, next);
260
+ renameSync2(next, route);
261
+ } finally {
262
+ rmSync2(next, { force: true });
263
+ }
264
+ }
265
+ function probeAgentCandidate(expected, socketPath = DEFAULT_AGENT_CANDIDATE_READY_SOCKET, timeoutMs = 5000) {
266
+ return new Promise((resolve2) => {
267
+ const socket = connect(socketPath);
268
+ let buffer = "";
269
+ let settled = false;
270
+ const finish = (value) => {
271
+ if (settled)
272
+ return;
273
+ settled = true;
274
+ clearTimeout(timer);
275
+ socket.destroy();
276
+ resolve2(value);
277
+ };
278
+ const timer = setTimeout(() => finish(false), timeoutMs);
279
+ socket.on("data", (chunk) => {
280
+ buffer += chunk.toString("utf8");
281
+ const newline = buffer.indexOf(`
282
+ `);
283
+ if (newline < 0)
284
+ return;
285
+ try {
286
+ const value = JSON.parse(buffer.slice(0, newline));
287
+ finish(value.version === expected.version && typeof value.nodeKey === "string" && typeof value.bound === "boolean" && ["ready", "partial", "unavailable", "unbound"].includes(value.vault ?? ""));
288
+ } catch {
289
+ finish(false);
290
+ }
291
+ });
292
+ socket.on("error", () => finish(false));
293
+ });
294
+ }
295
+
296
+ // src/agent-update-helper.ts
231
297
  var AGENT_UPDATE_GROUP = "forgezero-update";
232
298
  var AGENT_UPDATE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-agent-update-helper.service";
233
299
  var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
@@ -322,13 +388,13 @@ function validateJournal(value, root) {
322
388
  return journal;
323
389
  }
324
390
  function readJournal(path, root) {
325
- if (!existsSync2(path))
391
+ if (!existsSync3(path))
326
392
  return;
327
393
  return validateJournal(JSON.parse(readFileSync2(path, "utf8")), root);
328
394
  }
329
395
  function writeAtomic(path, value, mode) {
330
- mkdirSync2(dirname2(path), { recursive: true, mode: 493 });
331
- const next = `${path}.${randomUUID2()}.next`;
396
+ mkdirSync2(dirname3(path), { recursive: true, mode: 493 });
397
+ const next = `${path}.${randomUUID3()}.next`;
332
398
  let file;
333
399
  try {
334
400
  file = openSync2(next, "wx", mode);
@@ -337,8 +403,8 @@ function writeAtomic(path, value, mode) {
337
403
  fsyncSync2(file);
338
404
  closeSync2(file);
339
405
  file = undefined;
340
- renameSync2(next, path);
341
- const directory = openSync2(dirname2(path), "r");
406
+ renameSync3(next, path);
407
+ const directory = openSync2(dirname3(path), "r");
342
408
  try {
343
409
  fsyncSync2(directory);
344
410
  } finally {
@@ -347,7 +413,7 @@ function writeAtomic(path, value, mode) {
347
413
  } finally {
348
414
  if (file !== undefined)
349
415
  closeSync2(file);
350
- rmSync2(next, { force: true });
416
+ rmSync3(next, { force: true });
351
417
  }
352
418
  }
353
419
  var publicReceipt = (journal) => {
@@ -380,7 +446,7 @@ function writeUpdateState(journalPath, receiptPath, journal) {
380
446
  }
381
447
  function readAgentUpdateReceipt(path = AGENT_UPDATE_RECEIPT) {
382
448
  try {
383
- if (!existsSync2(path))
449
+ if (!existsSync3(path))
384
450
  return;
385
451
  return validateReceipt(JSON.parse(readFileSync2(path, "utf8")));
386
452
  } catch {
@@ -420,10 +486,32 @@ var restartAgent = async (target, run) => {
420
486
  throw new Error(`systemd could not restart ${service}`);
421
487
  }
422
488
  };
489
+ var candidateUnit = (target) => target === "compute" ? "forgezero-agent-candidate.service" : "forgezero-metal-agent-candidate.service";
490
+ var startCandidate = async (staged, target, run, probe = (expected) => probeAgentCandidate(expected, DEFAULT_AGENT_CANDIDATE_READY_SOCKET)) => {
491
+ selectAgentCandidate(staged);
492
+ if (!await runOk(run, "/usr/bin/systemctl", ["restart", candidateUnit(target)])) {
493
+ clearAgentCandidate(dirname3(staged.currentLink));
494
+ throw new Error(`systemd could not start ${candidateUnit(target)}`);
495
+ }
496
+ if (!await probe({ version: staged.version })) {
497
+ await run({ command: "/usr/bin/systemctl", args: ["stop", candidateUnit(target)] });
498
+ clearAgentCandidate(dirname3(staged.currentLink));
499
+ throw new Error("the candidate Agent did not prove its identity and durable state");
500
+ }
501
+ };
502
+ var stopCandidate = async (staged, target, run) => {
503
+ await run({ command: "/usr/bin/systemctl", args: ["stop", candidateUnit(target)] });
504
+ clearAgentCandidate(dirname3(staged.currentLink));
505
+ };
506
+ var socketPaths = (publicSocket = process.env.FZ_AGENT_PUBLIC_SOCKET ?? DEFAULT_SOCKET) => ({
507
+ route: `${publicSocket}.backend`,
508
+ active: `${publicSocket}.backend.active`,
509
+ candidate: `${publicSocket}.backend.candidate`
510
+ });
423
511
  var targetProbe = (target, run) => target === "compute" ? () => probeAgentSocket() : async () => await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-agent.service"]) && await runOk(run, "/usr/bin/systemctl", ["is-active", "--quiet", "forgezero-metal-helper.service"]);
424
512
  function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
425
513
  return new Promise((resolve3) => {
426
- const socket = connect(socketPath);
514
+ const socket = connect2(socketPath);
427
515
  let settled = false;
428
516
  let buffer = "";
429
517
  const finish = (value) => {
@@ -456,12 +544,13 @@ function probeAgentSocket(socketPath = DEFAULT_SOCKET, timeoutMs = 5000) {
456
544
  async function activateAgentRelease(staged, options = {}) {
457
545
  const run = options.run ?? runCommand;
458
546
  const target = options.target ?? "compute";
459
- const probe = options.probe ?? targetProbe(target, run);
547
+ const paths = socketPaths(options.publicSocketPath);
548
+ const probe = options.probe ?? (target === "compute" ? () => probeAgentSocket(paths.active) : targetProbe(target, run));
460
549
  const now = options.now ?? Date.now;
461
550
  const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
462
551
  const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
463
- const previous = readJournal(journalPath, dirname2(staged.currentLink));
464
- const attemptId = options.attemptId ?? randomUUID2();
552
+ const previous = readJournal(journalPath, dirname3(staged.currentLink));
553
+ const attemptId = options.attemptId ?? randomUUID3();
465
554
  if (!ATTEMPT_ID.test(attemptId))
466
555
  throw new Error("Agent update attempt ID is invalid");
467
556
  const startedAtTs = now();
@@ -483,11 +572,18 @@ async function activateAgentRelease(staged, options = {}) {
483
572
  writeUpdateState(journalPath, receiptPath, journal);
484
573
  let selectionAttempted = false;
485
574
  try {
575
+ if (!options.candidatePrepared)
576
+ await startCandidate(staged, target, run);
577
+ if (target === "compute")
578
+ switchAgentSocketRoute(paths.route, paths.candidate);
486
579
  selectionAttempted = true;
487
580
  selectAgentRelease(staged);
488
581
  await restartAgent(target, run);
489
582
  if (!await probe())
490
- throw new Error("the replacement Agent did not answer its retained Vault socket");
583
+ throw new Error("the replacement Agent did not answer its active Vault backend");
584
+ if (target === "compute")
585
+ switchAgentSocketRoute(paths.route, paths.active);
586
+ await stopCandidate(staged, target, run);
491
587
  journal = { ...journal, outcome: "active", updatedAtTs: now(), rollbackHealthy: undefined };
492
588
  writeUpdateState(journalPath, receiptPath, journal);
493
589
  run({
@@ -505,10 +601,13 @@ async function activateAgentRelease(staged, options = {}) {
505
601
  restored = true;
506
602
  await restartAgent(target, run);
507
603
  rollbackHealthy = await probe();
604
+ if (rollbackHealthy && target === "compute")
605
+ switchAgentSocketRoute(paths.route, paths.active);
508
606
  } catch {
509
607
  rollbackHealthy = false;
510
608
  }
511
609
  }
610
+ await stopCandidate(staged, target, run).catch(() => {});
512
611
  const failures = failureCount + 1;
513
612
  const updatedAtTs = now();
514
613
  journal = {
@@ -528,28 +627,38 @@ async function recoverInterruptedAgentUpdate(options = {}) {
528
627
  const root = resolve2(options.root ?? DEFAULT_AGENT_RELEASE_ROOT);
529
628
  const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
530
629
  const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
630
+ const run = options.run ?? runCommand;
531
631
  const journal = readJournal(journalPath, root);
532
- if (!journal)
632
+ if (!journal) {
633
+ for (const target of ["compute", "metal"]) {
634
+ await run({ command: "/usr/bin/systemctl", args: ["stop", candidateUnit(target)] });
635
+ }
636
+ clearAgentCandidate(root);
533
637
  return;
638
+ }
534
639
  if (journal.outcome !== "activating") {
640
+ await stopCandidate(stagedFromJournal(journal, root), journal.target, run).catch(() => {});
535
641
  writeAtomic(receiptPath, publicReceipt(journal), 416);
536
642
  return publicReceipt(journal);
537
643
  }
538
644
  const staged = stagedFromJournal(journal, root);
539
- if (!existsSync2(join2(root, journal.previousTarget))) {
645
+ if (!existsSync3(join2(root, journal.previousTarget))) {
540
646
  throw new Error("Agent update rollback release is missing");
541
647
  }
542
- const run = options.run ?? runCommand;
543
- const probe = options.probe ?? targetProbe(journal.target, run);
648
+ const paths = socketPaths(options.publicSocketPath);
649
+ const probe = options.probe ?? (journal.target === "compute" ? () => probeAgentSocket(paths.active) : targetProbe(journal.target, run));
544
650
  restoreAgentRelease(staged);
545
651
  let rollbackHealthy = false;
546
652
  let failureMessage = "activation was interrupted before its health verdict became durable";
547
653
  try {
548
654
  await restartAgent(journal.target, run);
549
655
  rollbackHealthy = await probe();
656
+ if (rollbackHealthy && journal.target === "compute")
657
+ switchAgentSocketRoute(paths.route, paths.active);
550
658
  } catch (cause) {
551
659
  failureMessage = `${failureMessage}; ${cause instanceof Error ? cause.message : String(cause)}`;
552
660
  }
661
+ await stopCandidate(staged, journal.target, run).catch(() => {});
553
662
  const failures = journal.failureCount + 1;
554
663
  const updatedAtTs = (options.now ?? Date.now)();
555
664
  const recovered = {
@@ -566,15 +675,23 @@ async function recoverInterruptedAgentUpdate(options = {}) {
566
675
  }
567
676
  function startAgentUpdateHelper(options = {}) {
568
677
  const socketPath = options.socketPath ?? DEFAULT_AGENT_UPDATE_SOCKET;
569
- if (existsSync2(socketPath))
570
- unlinkSync(socketPath);
571
- mkdirSync2(dirname2(socketPath), { recursive: true, mode: 488 });
572
- const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
678
+ if (existsSync3(socketPath))
679
+ unlinkSync2(socketPath);
680
+ mkdirSync2(dirname3(socketPath), { recursive: true, mode: 488 });
573
681
  const receiptPath = options.receiptPath ?? AGENT_UPDATE_RECEIPT;
574
682
  const journalPath = options.journalPath ?? AGENT_UPDATE_JOURNAL;
575
683
  const releaseRoot = options.root ?? DEFAULT_AGENT_RELEASE_ROOT;
576
- const activate = options.activate ?? ((staged, target, attemptId) => activateAgentRelease(staged, { target, attemptId, journalPath, receiptPath, now: options.now }));
684
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
685
+ const activate = options.activate ?? ((staged, target, attemptId) => activateAgentRelease(staged, {
686
+ target,
687
+ attemptId,
688
+ journalPath,
689
+ receiptPath,
690
+ now: options.now,
691
+ candidatePrepared: true
692
+ }));
577
693
  let busy = true;
694
+ let pending;
578
695
  let blocked;
579
696
  (options.recover ?? (() => recoverInterruptedAgentUpdate({
580
697
  root: releaseRoot,
@@ -586,7 +703,7 @@ function startAgentUpdateHelper(options = {}) {
586
703
  }).finally(() => {
587
704
  busy = false;
588
705
  });
589
- const server = createServer((socket) => {
706
+ const server = createServer2((socket) => {
590
707
  let buffer = "";
591
708
  socket.on("data", (chunk) => {
592
709
  buffer += chunk.toString("utf8");
@@ -605,16 +722,49 @@ function startAgentUpdateHelper(options = {}) {
605
722
  Promise.resolve().then(() => JSON.parse(line)).then(async (request) => {
606
723
  if (blocked)
607
724
  throw new Error(`update journal needs operator recovery: ${blocked}`);
608
- if (busy)
609
- throw new Error("another Agent update or recovery is already active");
610
- if (request.op !== "apply")
611
- throw new Error("unknown update operation");
612
725
  if (request.target !== "compute" && request.target !== "metal") {
613
726
  throw new Error("agent update target is invalid");
614
727
  }
615
- const attemptId = request.attemptId ?? randomUUID2();
728
+ const attemptId = request.attemptId ?? randomUUID3();
616
729
  if (!ATTEMPT_ID.test(attemptId))
617
730
  throw new Error("Agent update attempt ID is invalid");
731
+ if (request.op === "commit" || request.op === "abort") {
732
+ if (!pending || pending.attemptId !== attemptId || pending.target !== request.target || pending.staged.fromVersion !== request.currentVersion || pending.staged.version !== request.targetVersion) {
733
+ throw new Error("Agent update handover does not match the prepared candidate");
734
+ }
735
+ const selected = pending;
736
+ pending = undefined;
737
+ if (request.op === "abort") {
738
+ await stopCandidate(selected.staged, selected.target, runCommand);
739
+ busy = false;
740
+ const response3 = {
741
+ ok: true,
742
+ status: "aborted",
743
+ version: selected.staged.version,
744
+ attemptId
745
+ };
746
+ socket.end(`${JSON.stringify(response3)}
747
+ `);
748
+ return;
749
+ }
750
+ const outcome = await activate(selected.staged, selected.target, attemptId);
751
+ busy = false;
752
+ if (outcome?.ok === false)
753
+ throw new Error(outcome.reason ?? "Agent handover failed");
754
+ const response2 = {
755
+ ok: true,
756
+ status: "active",
757
+ version: selected.staged.version,
758
+ attemptId
759
+ };
760
+ socket.end(`${JSON.stringify(response2)}
761
+ `);
762
+ return;
763
+ }
764
+ if (request.op !== "prepare")
765
+ throw new Error("unknown update operation");
766
+ if (busy)
767
+ throw new Error("another Agent update or recovery is already active");
618
768
  const prior = readJournal(journalPath, releaseRoot);
619
769
  const now = (options.now ?? Date.now)();
620
770
  if (prior?.targetVersion === request.release.version && (prior.outcome === "rolled-back" || prior.outcome === "failed") && (prior.retryAfterTs ?? 0) > now)
@@ -625,17 +775,23 @@ function startAgentUpdateHelper(options = {}) {
625
775
  currentVersion: request.currentVersion,
626
776
  root: releaseRoot
627
777
  });
628
- const response = { ok: true, status: "staged", version: staged.version, attemptId };
778
+ await startCandidate(staged, request.target, runCommand);
779
+ pending = { staged, target: request.target, attemptId };
780
+ ownsBusy = false;
781
+ setTimer(() => {
782
+ if (pending?.attemptId !== attemptId)
783
+ return;
784
+ const expired = pending;
785
+ pending = undefined;
786
+ stopCandidate(expired.staged, expired.target, runCommand).finally(() => {
787
+ busy = false;
788
+ });
789
+ }, 180000);
790
+ const response = { ok: true, status: "prepared", version: staged.version, attemptId };
629
791
  socket.end(`${JSON.stringify(response)}
630
792
  `);
631
- setTimer(() => void activate(staged, request.target, attemptId).catch((cause) => {
632
- blocked = cause instanceof Error ? cause.message : String(cause);
633
- }).finally(() => {
634
- busy = false;
635
- }), 100);
636
- ownsBusy = false;
637
793
  }).catch((cause) => {
638
- if (ownsBusy)
794
+ if (ownsBusy && !pending)
639
795
  busy = false;
640
796
  const response = {
641
797
  ok: false,
@@ -647,12 +803,12 @@ function startAgentUpdateHelper(options = {}) {
647
803
  });
648
804
  socket.on("error", () => socket.destroy());
649
805
  });
650
- server.listen(socketPath, () => chmodSync2(socketPath, 432));
806
+ server.listen(socketPath, () => chmodSync3(socketPath, 432));
651
807
  return server;
652
808
  }
653
809
  function requestAgentUpdate(request, socketPath = DEFAULT_AGENT_UPDATE_SOCKET, timeoutMs = 90000) {
654
810
  return new Promise((resolve3, reject) => {
655
- const socket = connect(socketPath, () => socket.write(`${JSON.stringify(request)}
811
+ const socket = connect2(socketPath, () => socket.write(`${JSON.stringify(request)}
656
812
  `));
657
813
  let buffer = "";
658
814
  socket.setTimeout(timeoutMs, () => {
@@ -23,6 +23,7 @@ export interface StagedAgentRelease {
23
23
  currentLink: string;
24
24
  }
25
25
  export declare const DEFAULT_AGENT_RELEASE_ROOT = "/opt/forgezero/agent";
26
+ export declare const DEFAULT_AGENT_CANDIDATE_LINK = "/opt/forgezero/agent/candidate";
26
27
  export declare const DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
27
28
  export declare const MAX_AGENT_TARBALL_BYTES: number;
28
29
  /** Root helper input is data, never a command or filesystem path. */
@@ -45,3 +46,6 @@ export declare function stageAgentRelease(releaseInput: AgentRelease, options: {
45
46
  export declare function selectAgentRelease(staged: StagedAgentRelease): void;
46
47
  /** Roll back only to the exact link target captured before activation. */
47
48
  export declare function restoreAgentRelease(staged: StagedAgentRelease): void;
49
+ /** Select a verified candidate without changing the active generation. */
50
+ export declare function selectAgentCandidate(staged: StagedAgentRelease): void;
51
+ export declare function clearAgentCandidate(root?: string): void;
@@ -16,6 +16,7 @@ import {
16
16
  } from "node:fs";
17
17
  import { dirname, join, resolve } from "node:path";
18
18
  var DEFAULT_AGENT_RELEASE_ROOT = "/opt/forgezero/agent";
19
+ var DEFAULT_AGENT_CANDIDATE_LINK = `${DEFAULT_AGENT_RELEASE_ROOT}/candidate`;
19
20
  var DEFAULT_AGENT_UPDATE_SOCKET = "/run/forgezero-update/helper.sock";
20
21
  var MAX_AGENT_TARBALL_BYTES = 32 * 1024 * 1024;
21
22
  var VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
@@ -209,13 +210,30 @@ function restoreAgentRelease(staged) {
209
210
  rmSync(next, { force: true });
210
211
  }
211
212
  }
213
+ function selectAgentCandidate(staged) {
214
+ const link = join(dirname(staged.currentLink), "candidate");
215
+ const next = `${link}.${randomUUID()}.next`;
216
+ try {
217
+ symlinkSync(staged.nextTarget, next);
218
+ renameSync(next, link);
219
+ syncPath(dirname(link));
220
+ } finally {
221
+ rmSync(next, { force: true });
222
+ }
223
+ }
224
+ function clearAgentCandidate(root = DEFAULT_AGENT_RELEASE_ROOT) {
225
+ rmSync(join(resolve(root), "candidate"), { force: true });
226
+ }
212
227
  export {
213
228
  validateAgentRelease,
214
229
  stageAgentRelease,
215
230
  selectAgentRelease,
231
+ selectAgentCandidate,
216
232
  restoreAgentRelease,
217
233
  compareVersions,
234
+ clearAgentCandidate,
218
235
  MAX_AGENT_TARBALL_BYTES,
219
236
  DEFAULT_AGENT_UPDATE_SOCKET,
220
- DEFAULT_AGENT_RELEASE_ROOT
237
+ DEFAULT_AGENT_RELEASE_ROOT,
238
+ DEFAULT_AGENT_CANDIDATE_LINK
221
239
  };