@mycadet/cli 0.12.11 → 0.13.0

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.
Files changed (86) hide show
  1. package/README.md +13 -0
  2. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  3. package/node_modules/@mycadet/assistant-api/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  4. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  5. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  6. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
  7. package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
  8. package/package.json +3 -2
  9. package/src/__tests__/assistant-client-cloud-auth.test.ts +2 -2
  10. package/src/__tests__/backup-ops-matching.test.ts +15 -13
  11. package/src/__tests__/backup-ops.test.ts +105 -0
  12. package/src/__tests__/backup.test.ts +114 -673
  13. package/src/__tests__/cloud-releases.test.ts +82 -0
  14. package/src/__tests__/commands-mock.ts +101 -0
  15. package/src/__tests__/debug.test.ts +17 -91
  16. package/src/__tests__/devices.test.ts +2 -2
  17. package/src/__tests__/gateway-token.test.ts +267 -0
  18. package/src/__tests__/host-teleport-docker.test.ts +371 -0
  19. package/src/__tests__/host-teleport.test.ts +405 -0
  20. package/src/__tests__/nginx-ingress.test.ts +182 -23
  21. package/src/__tests__/orphan-detection.test.ts +1 -1
  22. package/src/__tests__/paired-lifecycle.test.ts +2 -2
  23. package/src/__tests__/ps-cloud-status.test.ts +6 -6
  24. package/src/__tests__/root-backup-docker.test.ts +294 -0
  25. package/src/__tests__/root-backup.test.ts +527 -0
  26. package/src/__tests__/root-restore-command.test.ts +147 -0
  27. package/src/__tests__/root-signing-key.test.ts +50 -0
  28. package/src/__tests__/setup.test.ts +3 -3
  29. package/src/__tests__/sleep.test.ts +11 -0
  30. package/src/__tests__/teleport.test.ts +68 -2996
  31. package/src/__tests__/tunnel.test.ts +1 -1
  32. package/src/__tests__/upgrade-local.test.ts +21 -11
  33. package/src/__tests__/upgrade-replay-env.test.ts +2 -2
  34. package/src/__tests__/upgrade-root-recovery.test.ts +185 -0
  35. package/src/__tests__/windows-lifecycle.test.ts +25 -0
  36. package/src/commands/backup.ts +44 -399
  37. package/src/commands/client.ts +3 -3
  38. package/src/commands/completions.ts +0 -2
  39. package/src/commands/debug.ts +75 -131
  40. package/src/commands/devices.ts +51 -17
  41. package/src/commands/gateway/token.ts +77 -23
  42. package/src/commands/pair.ts +2 -2
  43. package/src/commands/restore.ts +77 -444
  44. package/src/commands/retire.ts +2 -3
  45. package/src/commands/sleep.ts +15 -2
  46. package/src/commands/teleport.ts +47 -1559
  47. package/src/commands/upgrade.ts +78 -156
  48. package/src/commands/wake.ts +1 -1
  49. package/src/index.ts +0 -4
  50. package/src/lib/__tests__/http-client-version.test.ts +29 -0
  51. package/src/lib/__tests__/runtime-url.test.ts +3 -75
  52. package/src/lib/__tests__/web-dist.test.ts +27 -1
  53. package/src/lib/assistant-client.ts +1 -1
  54. package/src/lib/assistant-config.ts +0 -18
  55. package/src/lib/backup-ops.ts +37 -251
  56. package/src/lib/cloud-client.ts +10 -13
  57. package/src/lib/cloud-login.ts +2 -1
  58. package/src/lib/cloud-releases.ts +54 -10
  59. package/src/lib/cloud-values.ts +1 -1
  60. package/src/lib/confirm-action.ts +2 -2
  61. package/src/lib/docker.ts +59 -22
  62. package/src/lib/github-repository.ts +2 -0
  63. package/src/lib/guardian-token.ts +1 -23
  64. package/src/lib/hatch-local.ts +17 -1
  65. package/src/lib/host-teleport.ts +506 -0
  66. package/src/lib/http-client.ts +25 -0
  67. package/src/lib/live-voice/tunnel.test.ts +1 -1
  68. package/src/lib/live-voice/tunnel.ts +1 -1
  69. package/src/lib/local.ts +4 -2
  70. package/src/lib/nginx-ingress.ts +134 -18
  71. package/src/lib/process.ts +13 -0
  72. package/src/lib/root-backup.ts +917 -0
  73. package/src/lib/root-signing-key.ts +31 -0
  74. package/src/lib/runtime-url.ts +0 -29
  75. package/src/lib/teleport-backup.ts +3 -16
  76. package/src/lib/upgrade-lifecycle.ts +63 -141
  77. package/src/lib/web-dist.ts +23 -1
  78. package/src/__tests__/bundle-staging.test.ts +0 -268
  79. package/src/__tests__/unpair.test.ts +0 -160
  80. package/src/commands/rollback.ts +0 -479
  81. package/src/commands/unpair.ts +0 -122
  82. package/src/lib/__tests__/job-polling.test.ts +0 -278
  83. package/src/lib/__tests__/local-runtime-client.test.ts +0 -666
  84. package/src/lib/bundle-staging.ts +0 -220
  85. package/src/lib/job-polling.ts +0 -195
  86. package/src/lib/local-runtime-client.ts +0 -374
@@ -0,0 +1,31 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ lstatSync,
4
+ mkdirSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ /** Persist gateway identity in the service root without following an existing file link. */
12
+ export function persistRootSigningKey(root: string, key: string): void {
13
+ if (!/^[a-f0-9]{64}$/i.test(key)) {
14
+ throw new Error("Invalid gateway signing key.");
15
+ }
16
+ const directory = join(root, "gateway-security");
17
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
18
+ if (!lstatSync(directory).isDirectory()) {
19
+ throw new Error("Gateway security root must be a directory.");
20
+ }
21
+ const temporary = join(directory, `.signing-key-${randomUUID()}`);
22
+ try {
23
+ writeFileSync(temporary, Buffer.from(key, "hex"), {
24
+ mode: 0o600,
25
+ flag: "wx",
26
+ });
27
+ renameSync(temporary, join(directory, "actor-token-signing-key"));
28
+ } finally {
29
+ rmSync(temporary, { force: true });
30
+ }
31
+ }
@@ -4,35 +4,6 @@ import { hostname } from "node:os";
4
4
  import { getLocalLanIPv4 } from "./local";
5
5
  import type { AssistantEntry } from "./assistant-config.js";
6
6
 
7
- /**
8
- * Resolve the URL for a runtime migration endpoint, taking the assistant's
9
- * topology into account.
10
- *
11
- * - For local/docker assistants, `runtimeUrl` is the loopback gateway and
12
- * the runtime serves `/v1/migrations/<subpath>` directly. The CLI hits
13
- * that path with guardian-token bearer auth.
14
- * - For cloud-managed (cloud="cadet") assistants, `runtimeUrl` is the
15
- * platform host (e.g. `https://cloud.mycadet.ai`). The cloud's
16
- * `MigrationViewSet` does NOT expose `export-to-gcs` or arbitrary runtime
17
- * migration paths under `/v1/migrations/...`. The wildcard runtime proxy
18
- * at `/v1/assistants/<id>/<path:rest>` is what forwards arbitrary runtime
19
- * paths to the cloud runtime — vembda's unified proxy bootstraps the
20
- * guardian token internally for the runtime call. From the CLI side it's
21
- * user-session auth.
22
- *
23
- * The `subpath` is appended to the migrations namespace verbatim
24
- * (e.g. `"export-to-gcs"`, `"import-from-gcs"`, `\`jobs/${jobId}\``).
25
- */
26
- export function resolveRuntimeMigrationUrl(
27
- entry: Pick<AssistantEntry, "cloud" | "runtimeUrl" | "assistantId">,
28
- subpath: string,
29
- ): string {
30
- if (isCloudHome(entry.cloud)) {
31
- return `${entry.runtimeUrl}/v1/assistants/${entry.assistantId}/migrations/${subpath}`;
32
- }
33
- return `${entry.runtimeUrl}/v1/migrations/${subpath}`;
34
- }
35
-
36
7
  /**
37
8
  * Resolve the URL for a generic runtime endpoint under `/v1/<subpath>`,
38
9
  * taking the assistant's topology into account.
@@ -1,17 +1,4 @@
1
- /**
2
- * Platform (cloud="cadet") half of the pre-teleport safety backup.
3
- *
4
- * A platform source is snapshotted through Django's user-facing backup
5
- * endpoint, which cuts a PVC VolumeSnapshot via vembda. The POST returns as
6
- * soon as the snapshot object exists; it becomes restorable asynchronously,
7
- * so {@link createPlatformBackup} polls the listing until the new snapshot
8
- * reports `ready_to_use` before returning. A snapshot that never becomes
9
- * ready within the timeout is a backup failure, not a success.
10
- *
11
- * Local and docker sources are backed up host-side by `backup-ops` instead,
12
- * so the restore point survives the source's retirement. Policy shared with
13
- * the web teleport lives in `@mycadet/local-mode/teleport-backup-policy`.
14
- */
1
+ /** Cloud API safety backups use session auth and wait for a restorable root tar. */
15
2
 
16
3
  import {
17
4
  MANAGED_BACKUP_READY_POLL_INTERVAL_MS,
@@ -93,7 +80,7 @@ async function fetchPlatformBackups(
93
80
  }
94
81
 
95
82
  /**
96
- * `created_at` of every restorable PVC snapshot of a cloud assistant:
83
+ * `created_at` of every restorable root archive of a cloud assistant:
97
84
  * `GET /v1/assistants/<id>/backups/`.
98
85
  */
99
86
  export async function listPlatformBackups(
@@ -109,7 +96,7 @@ export interface CreatePlatformBackupOptions {
109
96
  }
110
97
 
111
98
  /**
112
- * Take a PVC snapshot of a cloud assistant and wait until it is
99
+ * Capture a root archive of a cloud assistant and wait until it is
113
100
  * restorable: `POST /v1/assistants/<id>/backups/`, then re-list until the
114
101
  * returned `snapshot_name` reports `ready_to_use`. Throws if the POST fails
115
102
  * or the snapshot is not ready within `timeoutMs`.
@@ -20,18 +20,16 @@ import {
20
20
  pullImage,
21
21
  startContainers,
22
22
  stopContainers,
23
+ sleepContainers,
24
+ wakeContainers,
23
25
  } from "./docker.js";
24
26
  import { getStateDir } from "./environments/paths.js";
25
27
  import { getCurrentEnvironment } from "./environments/resolve.js";
26
28
  import { loadGuardianToken } from "./guardian-token.js";
27
29
  import { classifyReadyzResponse } from "./http-client.js";
28
- import { fetchReleases, resolveImageRefs } from "./cloud-releases.js";
30
+ import { resolveImageRefs } from "./cloud-releases.js";
29
31
  import { z } from "zod";
30
32
 
31
- const GatewayHealthSchema = z.object({
32
- version: z.string().optional(),
33
- });
34
-
35
33
  const MigrationGateBodySchema = z.object({
36
34
  status: z.string().optional(),
37
35
  reason: z.string().optional(),
@@ -42,7 +40,7 @@ import {
42
40
  type ServiceName,
43
41
  } from "./statefulset.js";
44
42
  import { execOutput } from "./step-runner.js";
45
- import { compareVersions, stripVersionPrefix } from "./version-compat.js";
43
+ import { compareVersions } from "./version-compat.js";
46
44
  import { loopbackSafeFetch } from "./loopback-fetch.js";
47
45
 
48
46
  // ---------------------------------------------------------------------------
@@ -231,7 +229,7 @@ export function buildReplayEnv(
231
229
  interface ReplayState {
232
230
  bootstrapSecret: string | undefined;
233
231
  cesServiceToken: string;
234
- signingKey: string;
232
+ signingKey?: string;
235
233
  extraAssistantEnv: Record<string, string>;
236
234
  extraGatewayEnv: Record<string, string>;
237
235
  }
@@ -239,8 +237,9 @@ interface ReplayState {
239
237
  /**
240
238
  * Derive the secrets and replay env for replacement containers from
241
239
  * already-captured assistant/gateway envs. GUARDIAN_BOOTSTRAP_SECRET is only
242
- * set on the gateway; CES_SERVICE_TOKEN and ACTOR_TOKEN_SIGNING_KEY fall back
243
- * to fresh values for instances that predate them. CADET_DEVICE_ID is
240
+ * set on the gateway; CES_SERVICE_TOKEN can be generated for older instances.
241
+ * An absent signing-key override stays absent so replacements use the persisted
242
+ * gateway root rather than rotate its identity. CADET_DEVICE_ID is
244
243
  * backfilled on the gateway from the host, and the assistant inherits the
245
244
  * gateway's value (captured values win) so the pair always matches.
246
245
  */
@@ -259,7 +258,8 @@ export function buildReplayState(
259
258
  cesServiceToken:
260
259
  capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex"),
261
260
  signingKey:
262
- capturedEnv["ACTOR_TOKEN_SIGNING_KEY"] || randomBytes(32).toString("hex"),
261
+ gatewayEnv["ACTOR_TOKEN_SIGNING_KEY"] ||
262
+ capturedEnv["ACTOR_TOKEN_SIGNING_KEY"],
263
263
  extraAssistantEnv,
264
264
  extraGatewayEnv,
265
265
  };
@@ -287,28 +287,6 @@ export async function captureReplayState(
287
287
  return buildReplayState(capturedEnv, gatewayEnv);
288
288
  }
289
289
 
290
- /**
291
- * Best-effort fetch of the running service group version from the gateway
292
- * `/healthz` endpoint. Returns `undefined` when the endpoint is
293
- * unreachable or does not include a version field.
294
- */
295
- export async function fetchCurrentVersion(
296
- runtimeUrl: string,
297
- ): Promise<string | undefined> {
298
- try {
299
- const resp = await loopbackSafeFetch(`${runtimeUrl}/healthz`, {
300
- signal: AbortSignal.timeout(5000),
301
- });
302
- if (resp.ok) {
303
- const parsed = GatewayHealthSchema.safeParse(await resp.json());
304
- return parsed.success ? parsed.data.version : undefined;
305
- }
306
- } catch {
307
- // Best-effort
308
- }
309
- return undefined;
310
- }
311
-
312
290
  /**
313
291
  * Best-effort fetch of the assistant's configured public ingress URL from the
314
292
  * gateway `integrations/ingress/config` endpoint. Returns `undefined` when
@@ -344,41 +322,6 @@ export async function fetchAssistantIngressUrl(
344
322
  return undefined;
345
323
  }
346
324
 
347
- /**
348
- * Determine the version that was running before the current one.
349
- *
350
- * Checks (in order):
351
- * 1. `entry.previousVersion` (saved by the upgrade flow from health).
352
- * 2. The releases list from the cloud API — finds the version
353
- * immediately before `currentVersion`.
354
- *
355
- * Returns `undefined` when neither source yields a result.
356
- */
357
- export async function fetchPreviousVersion(
358
- currentVersion: string | undefined,
359
- previousVersionFromLockfile: string | undefined,
360
- ): Promise<string | undefined> {
361
- // 1. Lockfile-cached value (written during upgrade from health endpoint)
362
- if (previousVersionFromLockfile) return previousVersionFromLockfile;
363
-
364
- // 2. Derive from releases list
365
- if (!currentVersion) return undefined;
366
- const releases = await fetchReleases();
367
- if (!releases) return undefined;
368
-
369
- const normalizedCurrent = stripVersionPrefix(currentVersion);
370
-
371
- // Releases are ordered newest-first; find the entry right after the
372
- // current version (i.e. the one that was running before the upgrade).
373
- const idx = releases.findIndex(
374
- (r) => stripVersionPrefix(r.version ?? "") === normalizedCurrent,
375
- );
376
- if (idx >= 0 && idx + 1 < releases.length) {
377
- return releases[idx + 1].version;
378
- }
379
- return undefined;
380
- }
381
-
382
325
  /**
383
326
  * Poll the gateway `/readyz` endpoint until the assistant reports migrations
384
327
  * ready or the timeout elapses. Returns whether the assistant became ready.
@@ -577,12 +520,8 @@ export async function rollbackMigrations(
577
520
  }
578
521
 
579
522
  /**
580
- * Recovery step for a reverted stack that reports terminally failed
581
- * migrations: import the backup (the failed-state migrations/import
582
- * exemption admits it, and the import replaces the DB wholesale), then
583
- * restart the containers — the failed readiness latch only clears on
584
- * restart — and re-check readiness. Shared by the upgrade failure branch
585
- * and performDockerRollback's auto-rollback.
523
+ * Restore the host root archive for a reverted stack with failed migrations,
524
+ * restart its containers, and check readiness before resuming the installation.
586
525
  */
587
526
  export async function attemptFailedStateRestore(opts: {
588
527
  runtimeUrl: string;
@@ -601,7 +540,7 @@ export async function attemptFailedStateRestore(opts: {
601
540
  opts.runtimeUrl,
602
541
  opts.assistantId,
603
542
  opts.backupPath,
604
- { kind: "docker", assistantId: opts.assistantId },
543
+ { resume: false },
605
544
  );
606
545
  if (!restored) {
607
546
  return { restored: false, ready: false };
@@ -609,6 +548,7 @@ export async function attemptFailedStateRestore(opts: {
609
548
  console.log(
610
549
  " Backup imported — restarting containers to complete recovery...",
611
550
  );
551
+ await sleepContainers(opts.res);
612
552
  await stopContainers(opts.res);
613
553
  await startContainers(opts.containerOptions, (msg) => console.log(msg));
614
554
  return { restored: true, ready: await waitForReady(opts.runtimeUrl) };
@@ -619,28 +559,29 @@ export async function attemptFailedStateRestore(opts: {
619
559
  // ---------------------------------------------------------------------------
620
560
 
621
561
  export interface PerformDockerRollbackOptions {
562
+ /** Root archive must be installed before the target runtime starts. */
563
+ backupPath: string;
622
564
  /** Specific version to roll back to. */
623
565
  targetVersion?: string;
624
566
  }
625
567
 
626
568
  /**
627
- * Perform a Docker rollback to a target version. Reusable by both `rollback.ts`
628
- * (targeted version rollback) and `restore.ts` (version + data restore).
569
+ * Switch Docker runtime versions as part of a versioned backup restore.
629
570
  *
630
571
  * This function handles the full lifecycle:
631
572
  * - Version validation (target must be older than current)
632
573
  * - Image resolution and pulling
633
- * - Migration ceiling lookup and pre-swap rollback
574
+ * - Post-swap migration rollback to the target runtime's registry ceiling
634
575
  * - Container stop/start with target images
635
576
  * - Readiness check
636
- * - Lockfile update with rollback state
577
+ * - Lockfile update with the running images
637
578
  * - Auto-rollback on failure
638
579
  */
639
580
  export async function performDockerRollback(
640
581
  entry: AssistantEntry,
641
582
  options: PerformDockerRollbackOptions,
642
583
  ): Promise<void> {
643
- const { targetVersion } = options;
584
+ const { targetVersion, backupPath } = options;
644
585
 
645
586
  if (!targetVersion) {
646
587
  throw new Error("targetVersion is required for performDockerRollback");
@@ -708,21 +649,6 @@ export async function performDockerRollback(
708
649
  }
709
650
  }
710
651
 
711
- // Persist rollback state to lockfile BEFORE any destructive changes
712
- if (entry.containerInfo) {
713
- const rollbackEntry: AssistantEntry = {
714
- ...entry,
715
- previousContainerInfo: { ...entry.containerInfo },
716
- previousVersion: currentVersion,
717
- previousDbMigrationVersion: preMigrationState.dbVersion,
718
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
719
- };
720
- saveAssistantEntry(rollbackEntry);
721
- if (currentVersion) {
722
- console.log(` Saved rollback state: ${currentVersion}\n`);
723
- }
724
- }
725
-
726
652
  // Record rollback start in workspace git history
727
653
  await commitWorkspaceViaGateway(
728
654
  entry.runtimeUrl,
@@ -747,15 +673,17 @@ export async function performDockerRollback(
747
673
  entry.runtimeUrl,
748
674
  entry.assistantId,
749
675
  {
750
- prefix: `${entry.assistantId}-pre-upgrade`,
676
+ prefix: `${entry.assistantId}-pre-rollback`,
751
677
  description: `Pre-rollback snapshot before ${currentVersion ?? "unknown"} → ${targetVersion}`,
752
678
  },
753
679
  );
754
680
  if (preRollbackBackupPath) {
755
681
  console.log(` Backup saved: ${preRollbackBackupPath}\n`);
756
- pruneOldBackups(entry.assistantId, 3);
682
+ pruneOldBackups(entry.assistantId, 3, "pre-rollback");
757
683
  } else {
758
- console.warn("⚠️ Pre-rollback backup failed, continuing with rollback\n");
684
+ throw new Error(
685
+ "Pre-rollback root backup failed; rollback aborted before replacing services.",
686
+ );
759
687
  }
760
688
 
761
689
  const {
@@ -831,46 +759,47 @@ export async function performDockerRollback(
831
759
 
832
760
  // Stop containers, migrate security files, start with target images
833
761
  console.log("🛑 Stopping existing containers...");
762
+ await sleepContainers(res);
834
763
  await stopContainers(res);
835
764
  console.log("✅ Containers stopped\n");
836
765
 
837
- console.log("🚀 Starting containers with target version...");
838
- await startContainers(
839
- {
840
- signingKey,
841
- bootstrapSecret,
842
- cesServiceToken,
843
- extraAssistantEnv,
844
- extraGatewayEnv,
845
- gatewayPort,
846
- assistantPort,
847
- imageTags: targetImageTags,
848
- instanceName,
849
- res,
850
- },
851
- (msg) => console.log(msg),
852
- );
853
- console.log("✅ Containers started\n");
854
-
855
- // Wait for readiness
856
- console.log("Waiting for assistant to become ready...");
857
- const ready = await waitForReady(entry.runtimeUrl);
766
+ let ready = false;
767
+ try {
768
+ console.log("Creating stopped containers with the target version...");
769
+ await startContainers(
770
+ {
771
+ signingKey,
772
+ bootstrapSecret,
773
+ cesServiceToken,
774
+ extraAssistantEnv,
775
+ extraGatewayEnv,
776
+ gatewayPort,
777
+ assistantPort,
778
+ imageTags: targetImageTags,
779
+ instanceName,
780
+ res,
781
+ createOnly: true,
782
+ },
783
+ (msg) => console.log(msg),
784
+ );
785
+ if (
786
+ !(await restoreBackup(entry.runtimeUrl, entry.assistantId, backupPath, {
787
+ resume: false,
788
+ }))
789
+ )
790
+ throw new Error("Target backup roots could not be restored.");
791
+ await wakeContainers(res);
792
+ console.log("Waiting for restored assistant to become ready...");
793
+ ready = await waitForReady(entry.runtimeUrl);
794
+ } catch (error) {
795
+ console.error(
796
+ `Versioned restore failed: ${error instanceof Error ? error.message : String(error)}`,
797
+ );
798
+ }
858
799
 
859
800
  if (ready) {
860
801
  // Success path
861
802
 
862
- // Post-swap migration rollback: ask the now-running old daemon to roll
863
- // back any migrations above its own registry ceiling.
864
- await rollbackMigrations(
865
- entry.runtimeUrl,
866
- entry.assistantId,
867
- undefined,
868
- undefined,
869
- true,
870
- );
871
-
872
- // Capture new digests from the rolled-back containers
873
- // Swap current/previous state to enable "rollback the rollback"
874
803
  const updatedEntry: AssistantEntry = {
875
804
  ...entry,
876
805
  containerInfo: {
@@ -879,10 +808,6 @@ export async function performDockerRollback(
879
808
  cesImage: targetImageTags["credential-executor"],
880
809
  assistantPort,
881
810
  },
882
- previousContainerInfo: entry.containerInfo,
883
- previousDbMigrationVersion: preMigrationState.dbVersion,
884
- previousWorkspaceMigrationId: preMigrationState.lastWorkspaceMigrationId,
885
- preUpgradeBackupPath: undefined,
886
811
  };
887
812
  saveAssistantEntry(updatedEntry);
888
813
 
@@ -950,6 +875,7 @@ export async function performDockerRollback(
950
875
  );
951
876
  }
952
877
 
878
+ await sleepContainers(res);
953
879
  await stopContainers(res);
954
880
 
955
881
  await startContainers(
@@ -1013,13 +939,13 @@ export async function performDockerRollback(
1013
939
  entry.runtimeUrl,
1014
940
  entry.assistantId,
1015
941
  preRollbackBackupPath,
1016
- { kind: "docker", assistantId: entry.assistantId },
942
+ {},
1017
943
  );
1018
944
  if (restored) {
1019
945
  console.log(" ✅ Data restored successfully\n");
1020
946
  } else {
1021
- console.warn(
1022
- " ⚠️ Data restore failed (auto-rollback continues without data restoration)\n",
947
+ throw new Error(
948
+ `Root restore failed; rollback recovery is incomplete. Backup: ${preRollbackBackupPath}`,
1023
949
  );
1024
950
  }
1025
951
  }
@@ -1038,10 +964,6 @@ export async function performDockerRollback(
1038
964
  currentImageRefs["credential-executor"],
1039
965
  assistantPort,
1040
966
  },
1041
- previousContainerInfo: undefined,
1042
- previousDbMigrationVersion: undefined,
1043
- previousWorkspaceMigrationId: undefined,
1044
- preUpgradeBackupPath: undefined,
1045
967
  };
1046
968
  saveAssistantEntry(revertedEntry);
1047
969
 
@@ -1,4 +1,7 @@
1
- import { existsSync } from "node:fs";
1
+ import { stripVersionPrefix } from "./version-compat.js";
2
+ import { z } from "zod";
3
+ import { isValidReleaseVersion } from "@mycadet/local-mode";
4
+ import { existsSync, readFileSync } from "node:fs";
2
5
  import { createRequire } from "node:module";
3
6
  import { dirname, join } from "node:path";
4
7
 
@@ -46,3 +49,22 @@ export function findWebDistDir(options: WebDistOptions = {}): string | null {
46
49
  }
47
50
  return null;
48
51
  }
52
+
53
+ const WebReleaseSchema = z.object({
54
+ version: z
55
+ .string()
56
+ .min(1)
57
+ .refine((value) => value !== "latest" && isValidReleaseVersion(value)),
58
+ });
59
+
60
+ export function readWebReleaseVersion(distDir: string): string | undefined {
61
+ try {
62
+ const payload: unknown = JSON.parse(
63
+ readFileSync(join(distDir, "release.json"), "utf8"),
64
+ );
65
+ const parsed = WebReleaseSchema.safeParse(payload);
66
+ return parsed.success ? stripVersionPrefix(parsed.data.version) : undefined;
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }