oysterun 1.5.7 → 1.5.8

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.
@@ -9513,8 +9513,9 @@
9513
9513
  class="field-hint"
9514
9514
  id="host-restart-default-copy"
9515
9515
  >
9516
- Existing sessions and enabled in-session loops
9517
- will be restored after restart.
9516
+ Existing Sessions will be restored after
9517
+ restart. Loops remain off; active work is not
9518
+ replayed.
9518
9519
  </div>
9519
9520
  <div
9520
9521
  class="host-restart-handoff"
@@ -9877,11 +9878,10 @@
9877
9878
  id="host-update-confirmation-warning"
9878
9879
  >
9879
9880
  Oysterun will prepare restore state for existing
9880
- sessions and enabled in-session loops before
9881
- restarting Host. Active provider replies,
9882
- terminal commands, loop turns, and scheduler
9883
- work may be marked interrupted and will not be
9884
- replayed.
9881
+ Sessions before restarting Host. Loops remain
9882
+ off. Active provider replies, terminal commands,
9883
+ loop turns, and scheduler work may be marked
9884
+ interrupted and will not be replayed.
9885
9885
  </div>
9886
9886
  <div
9887
9887
  class="field-hint host-update-confirmation-warning hidden"
@@ -31684,8 +31684,9 @@ Waiting for Claude login output…</pre
31684
31684
  function getOysterunUpdatePhase(update) {
31685
31685
  const operation = update?.update_operation || null;
31686
31686
  if (
31687
- operation?.status === "update_recovery_required" ||
31688
- operation?.status === "restart_recovery_required"
31687
+ !operation?.root_failure &&
31688
+ (operation?.status === "update_recovery_required" ||
31689
+ operation?.status === "restart_recovery_required")
31689
31690
  ) {
31690
31691
  return (
31691
31692
  update?.recovery_plan?.user_action?.title ||
@@ -31712,6 +31713,27 @@ Waiting for Claude login output…</pre
31712
31713
  ? `An earlier unfinished update was safely closed after Oysterun ${operation.superseded_by_version} became healthy. Its diagnostic record was kept.`
31713
31714
  : "An earlier unfinished update was safely closed. Its diagnostic record was kept.";
31714
31715
  }
31716
+ const rootFailure = operation.root_failure;
31717
+ if (
31718
+ rootFailure &&
31719
+ typeof rootFailure === "object" &&
31720
+ rootFailure.check &&
31721
+ rootFailure.safe_message
31722
+ ) {
31723
+ const restartText = operation.restart_attempted
31724
+ ? "Host restart was attempted."
31725
+ : "Host restart was not attempted.";
31726
+ const rollbackText = operation.rollback_result?.safe_message || "";
31727
+ const recoveryText = operation.recovery_result?.safe_message || "";
31728
+ const cleanupText = operation.cleanup_result?.safe_message || "";
31729
+ return `Failed check: ${rootFailure.check} (stage: ${
31730
+ rootFailure.stage || "lifecycle"
31731
+ }). ${rootFailure.safe_message} ${restartText}${
31732
+ rollbackText ? ` ${rollbackText}` : ""
31733
+ }${recoveryText ? ` ${recoveryText}` : ""}${
31734
+ cleanupText ? ` ${cleanupText}` : ""
31735
+ }`;
31736
+ }
31715
31737
  if (operation.status === "rolled_back") {
31716
31738
  return operation.final_observed_version
31717
31739
  ? `Update failed safely. Oysterun ${operation.final_observed_version} was restored.`
@@ -45551,7 +45573,7 @@ Waiting for Claude login output…</pre
45551
45573
  ...hostPreferencesState,
45552
45574
  restartLoading: true,
45553
45575
  restartStatus:
45554
- "Preparing restore state for existing sessions and enabled loops...",
45576
+ "Preparing existing Sessions for restore. Loops will remain off; prompts will not replay...",
45555
45577
  loginQrError: "",
45556
45578
  };
45557
45579
  renderHostPreferences();
@@ -45898,22 +45920,17 @@ Waiting for Claude login output…</pre
45898
45920
  cancelOysterunUpdateConfirmation();
45899
45921
  return;
45900
45922
  }
45923
+ // Freeze the exact version visible in the confirmation, even if a
45924
+ // background check refreshes the surrounding Preferences state.
45901
45925
  const targetVersion =
45902
- (recoveryConfirmation
45903
- ? update.update_operation?.target_version ||
45904
- update.update_operation?.last_working_version
45905
- : confirmationMode === "beta"
45906
- ? update.beta_version || update.target_version
45907
- : update.newest_version || update.latest_version) ||
45908
- hostPreferencesState.updateConfirmationTargetVersion ||
45909
- "the newest version";
45926
+ hostPreferencesState.updateConfirmationTargetVersion;
45927
+ const confirmedCurrentVersion =
45928
+ hostPreferencesState.updateConfirmationCurrentVersion;
45910
45929
  hostPreferencesState = {
45911
45930
  ...hostPreferencesState,
45912
45931
  updateConfirmationVisible: false,
45913
45932
  updateConfirmationMode: confirmationMode,
45914
- updateConfirmationCurrentVersion:
45915
- update.current_version ||
45916
- hostPreferencesState.updateConfirmationCurrentVersion,
45933
+ updateConfirmationCurrentVersion: confirmedCurrentVersion,
45917
45934
  updateConfirmationTargetVersion: targetVersion,
45918
45935
  updateRunning: true,
45919
45936
  updateRequestPending: true,
@@ -45937,7 +45954,12 @@ Waiting for Claude login output…</pre
45937
45954
  : confirmationMode === "beta"
45938
45955
  ? "/admin/update-beta-run"
45939
45956
  : "/admin/update-run",
45940
- recoveryConfirmation ? { kind: confirmationMode } : {}
45957
+ recoveryConfirmation
45958
+ ? { kind: confirmationMode }
45959
+ : {
45960
+ expected_current_version: confirmedCurrentVersion,
45961
+ expected_target_version: targetVersion,
45962
+ }
45941
45963
  );
45942
45964
  const normalized =
45943
45965
  confirmationMode === "beta" && !recoveryConfirmation
@@ -71,6 +71,16 @@ function printBoundedStatus(payload) {
71
71
  target_version: operation.target_version || payload.target_version || null,
72
72
  recovery_boundary:
73
73
  payload.recovery_boundary || operation.recovery_boundary || null,
74
+ failed_stage: operation.root_failure?.stage || null,
75
+ failed_check: operation.root_failure?.check || null,
76
+ failure_code: operation.root_failure?.code || null,
77
+ safe_message: operation.root_failure?.safe_message || null,
78
+ restart_attempted: operation.restart_attempted === true,
79
+ rollback_result: operation.rollback_result || null,
80
+ recovery_result: operation.recovery_result || null,
81
+ cleanup_result: operation.cleanup_result || null,
82
+ next_safe_action:
83
+ payload.recovery_plan?.user_action?.kind || operation.next_safe_action || null,
74
84
  secret_fields_redacted: true,
75
85
  };
76
86
  process.stdout.write(`${JSON.stringify(fields, null, 2)}\n`);
@@ -73,6 +73,7 @@ export function buildHostLifecycleRecoveryPlan({
73
73
  currentVersion = null,
74
74
  runnerAlive = false,
75
75
  supersededEvaluation = null,
76
+ continuityReceipt = null,
76
77
  } = {}) {
77
78
  if (!operation?.operation_id) {
78
79
  return {
@@ -84,6 +85,37 @@ export function buildHostLifecycleRecoveryPlan({
84
85
  host_restarted: false,
85
86
  };
86
87
  }
88
+ if (operation.continuity_path) {
89
+ const r = continuityReceipt;
90
+ if (r?.operation_id !== operation.operation_id) return {
91
+ outcome: "owner_action_required", blocking: true, user_action: { ...ACTIONS.export_diagnostics },
92
+ reason_code: "continuity_receipt_unverified", package_mutated: false, host_restarted: false,
93
+ };
94
+ if (["CLEANED", "ROLLED_BACK_HEALTHY"].includes(r.state)) return {
95
+ outcome: "continuity_terminal", blocking: false, user_action: null, reason_code: null,
96
+ package_mutated: false, host_restarted: false,
97
+ };
98
+ if (operation.running || runnerAlive) return {
99
+ outcome: "operation_running", blocking: true, user_action: null,
100
+ reason_code: "lifecycle_owner_active", package_mutated: false, host_restarted: false,
101
+ };
102
+ const restartOnly = r.transaction_kind === "restart_only";
103
+ const committed = r.state === "COMMITTED";
104
+ const recovery = r.state === "RECOVERY_REQUIRED";
105
+ const userAction = committed ? {
106
+ ...ACTIONS.complete_update_and_restart, label: "Finish cleanup", title: "Qualification committed",
107
+ description: "Host, Sessions, history and the existing route passed. Retained update files can now be cleaned.",
108
+ confirmation: "Finish this committed operation's cleanup. No additional restart is required.",
109
+ } : restartOnly ? action("restart_only", "Recover restart", "Restart recovery required",
110
+ "Retry the existing package with the retained fresh Session restore receipt.",
111
+ "Restart this Host and verify the original Session roster and history. Active work will not replay.")
112
+ : recovery ? { ...ACTIONS.restore_and_restart } : { ...ACTIONS.complete_update_and_restart,
113
+ description: "Continue the exact retained operation and verify Host, Sessions, history and the existing route.",
114
+ confirmation: "Continue this operation. A completed restart will not be repeated.",
115
+ };
116
+ return { outcome: "owner_action_required", blocking: true, user_action: userAction,
117
+ reason_code: `continuity_${r.state.toLowerCase()}`, package_mutated: false, host_restarted: false };
118
+ }
87
119
  if (operation.running === true || runnerAlive === true) {
88
120
  return {
89
121
  outcome: "operation_running",
@@ -0,0 +1,158 @@
1
+ import {
2
+ cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync,
3
+ readlinkSync, renameSync, rmSync, symlinkSync,
4
+ } from "node:fs";
5
+ import { createHash } from "node:crypto";
6
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import {
8
+ assertContinuityFinalizable, advanceContinuity, continuityDigest,
9
+ continuityFailure, readContinuity, writeContinuityAtomic, writeContinuityBytesAtomic,
10
+ } from "./lifecycle-continuity.mjs";
11
+ import { restoreServiceDefinitionSnapshot, inspectServiceDefinition } from "./service-definition-migration.mjs";
12
+ import { removeContinuityProbeGrant } from "./lifecycle-continuity-probe.mjs";
13
+
14
+ const fail = (check, message) => { throw continuityFailure("retained_assets", check, message); };
15
+
16
+ // Hash executable bits, relative names, symlink text and every regular file.
17
+ // No symlink is followed, including npm's provider executables.
18
+ export function digestContinuityPackage(root) {
19
+ if (!existsSync(root) || !lstatSync(root).isDirectory() || lstatSync(root).isSymbolicLink()) fail("package_root", "Package root must be a retained regular directory.");
20
+ const hash = createHash("sha256");
21
+ const visit = (directory) => {
22
+ for (const name of readdirSync(directory).sort()) {
23
+ const path = join(directory, name);
24
+ const stat = lstatSync(path);
25
+ const kind = stat.isSymbolicLink() ? "link" : stat.isDirectory() ? "directory" : stat.isFile() ? "file" : null;
26
+ if (!kind) fail("package_special_file", "Package contains an unsupported special file.");
27
+ hash.update(JSON.stringify([relative(root, path), kind, stat.mode & 0o777]));
28
+ if (kind === "file") hash.update(continuityDigest(readFileSync(path)));
29
+ if (kind === "link") hash.update(JSON.stringify(readlinkSync(path)));
30
+ if (kind === "directory") visit(path);
31
+ }
32
+ };
33
+ visit(root);
34
+ return hash.digest("hex");
35
+ }
36
+
37
+ export function validateContinuityResources(receipt, configDir, activePackageRoot) {
38
+ const r = receipt.resources;
39
+ if (!r || !receipt.operation_root || !isAbsolute(receipt.operation_root)) fail("resources_missing", "Operation-owned recovery resources are missing.");
40
+ const root = resolve(receipt.operation_root);
41
+ const active = resolve(activePackageRoot);
42
+ const prefix = resolve(active, "../../..");
43
+ if (active !== join(prefix, "lib", "node_modules", "oysterun") ||
44
+ dirname(root) !== prefix || !root.split("/").at(-1).startsWith(".oysterun-update-")) fail("resource_boundary", "Recovery resources escape the exact npm prefix.");
45
+ const exact = {
46
+ active_package_root: active,
47
+ previous_package_root: join(root, "previous-oysterun"),
48
+ failed_package_root: join(root, "failed-oysterun"),
49
+ global_lock_path: join(prefix, "lib", "node_modules", ".package-lock.json"),
50
+ saved_global_lock_path: join(root, "global-package-lock.json"),
51
+ restart_restore_state_path: join(resolve(configDir), "restart-restore-state.json"),
52
+ saved_pre_handoff_restart_restore_state_path: join(root, "pre-handoff-restart-restore-state.json"),
53
+ saved_service_definition_authority_path: join(root, "service-definition-authority.json"),
54
+ source_service_render_bundle_path: join(root, "source-service-render-bundle.json"),
55
+ mutation_journal_path: join(root, "update-mutation-journal.json"),
56
+ };
57
+ for (const [key, value] of Object.entries(exact)) {
58
+ if (r[key] !== value) fail("resource_binding", `Recovery ${key} differs from the operation geometry.`);
59
+ }
60
+ if (r.service_definition_path !== receipt.baseline.host.service_definition_path ||
61
+ r.service_definition_authority_path !== join(resolve(configDir), "service-definition-authority.json")) fail("definition_binding", "Recovery definition/authority differs from the retained Host binding.");
62
+ if (r.service_definition_snapshot_path !== join(root, "service-definition-snapshot.json")) fail("definition_snapshot", "Recovery definition snapshot is outside the operation root.");
63
+ if (existsSync(root) && (!lstatSync(root).isDirectory() || lstatSync(root).isSymbolicLink())) fail("operation_root", "Recovery root must be a regular directory.");
64
+ return { ...r, root, active, prefix };
65
+ }
66
+
67
+ export function retainContinuityFile(path, savedPath) {
68
+ const existed = existsSync(path);
69
+ if (existed && (!lstatSync(path).isFile() || lstatSync(path).isSymbolicLink())) fail("regular_snapshot", "Recovery file must be a regular file.");
70
+ const bytes = existed ? readFileSync(path) : null;
71
+ if (bytes) writeContinuityBytesAtomic(savedPath, bytes);
72
+ return { path, saved_path: savedPath, existed, mode: existed ? lstatSync(path).mode & 0o777 : null,
73
+ sha256: bytes ? continuityDigest(bytes) : null };
74
+ }
75
+
76
+ function restoreRetainedFile(binding) {
77
+ if (!binding) fail("file_binding", "Recovery file binding is missing.");
78
+ if (!binding.existed) { rmSync(binding.path, { force: true }); return; }
79
+ const bytes = readFileSync(binding.saved_path);
80
+ if (continuityDigest(bytes) !== binding.sha256) fail("file_digest", "Retained recovery file changed.");
81
+ writeContinuityBytesAtomic(binding.path, bytes, { mode: binding.mode ?? 0o600 });
82
+ }
83
+
84
+ // Rollback keeps the original package. A fresh copy is verified before the
85
+ // active tree is exchanged. Repeating this function after either rename is
86
+ // safe; an unknown tree is never deleted to make recovery appear successful.
87
+ export function verifyContinuityAssets({ path, operationId, configDir, activePackageRoot }) {
88
+ const receipt = readContinuity(path, operationId);
89
+ if (["COMMITTED", "CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) fail("terminal_assets", "Terminal recovery resources cannot be rewritten.");
90
+ const r = validateContinuityResources(receipt, configDir, activePackageRoot);
91
+ const sourceHash = receipt.source.package_tree_sha256;
92
+ if (!sourceHash) fail("source_hash", "Prior package has no retained tree identity.");
93
+ const activeIsSource = existsSync(r.active) && digestContinuityPackage(r.active) === sourceHash;
94
+ // Validate the entire pair before the first mutation. A valid package alone
95
+ // cannot authorize overwriting a changed definition or a missing snapshot.
96
+ const snapshot = JSON.parse(readFileSync(r.service_definition_snapshot_path, "utf8"));
97
+ if (continuityDigest(snapshot) !== receipt.source.definition_snapshot_digest || snapshot.definition_path !== r.service_definition_path ||
98
+ (snapshot.existed && continuityDigest(Buffer.from(snapshot.bytes_base64, "base64")) !== snapshot.sha256)) fail("definition_snapshot_digest", "Prior service definition snapshot changed.");
99
+ const rendered = { sha256: receipt.target.service_definition_sha256 || "" };
100
+ if (inspectServiceDefinition({ definitionPath: r.service_definition_path, snapshot, rendered }).state === "unexpected") fail("definition_changed", "Live definition is outside the retained source/target pair.");
101
+ const bindings = [];
102
+ for (const key of ["runtime_identity", "service_authority", "global_lock", "fresh_restart_state"]) {
103
+ const binding = receipt.retained_files?.[key];
104
+ if (!binding && key === "fresh_restart_state" && !receipt.restart_attempted && activeIsSource) continue;
105
+ if (!binding) fail("file_binding", `Retained ${key} is missing.`);
106
+ const expectedPath = key === "runtime_identity" ? join(resolve(configDir), "node-npm-runtime-identity.json")
107
+ : key === "service_authority" ? r.service_definition_authority_path
108
+ : key === "global_lock" ? r.global_lock_path : r.restart_restore_state_path;
109
+ if (binding.path !== expectedPath || dirname(binding.saved_path) !== r.root) fail("file_boundary", "Retained file escapes the operation boundary.");
110
+ if (existsSync(binding.path) && (!lstatSync(binding.path).isFile() || lstatSync(binding.path).isSymbolicLink())) fail("live_file_kind", "Live recovery destination is not a regular file.");
111
+ if (binding.existed && (!lstatSync(binding.saved_path).isFile() || lstatSync(binding.saved_path).isSymbolicLink() ||
112
+ continuityDigest(readFileSync(binding.saved_path)) !== binding.sha256)) fail("file_digest", "Retained recovery file changed.");
113
+ bindings.push(binding);
114
+ }
115
+ const binDir = join(r.prefix, "bin");
116
+ const link = join(binDir, "oysterun");
117
+ if (existsSync(link) && !lstatSync(link).isSymbolicLink()) fail("cli_link", "Global CLI path is not the expected symlink.");
118
+ if (!activeIsSource && (!existsSync(r.previous_package_root) || digestContinuityPackage(r.previous_package_root) !== sourceHash)) fail("source_package_changed", "Retained prior package is missing or changed; recovery assets remain intact.");
119
+ return { receipt, r, sourceHash, activeIsSource, snapshot, rendered, bindings, binDir, link };
120
+ }
121
+
122
+ export function restoreContinuityAssets({ path, operationId, configDir, activePackageRoot }) {
123
+ const { receipt, r, sourceHash, activeIsSource, snapshot, rendered, bindings, binDir, link } = verifyContinuityAssets({ path, operationId, configDir, activePackageRoot });
124
+ if (!activeIsSource) {
125
+ const prepared = join(r.root, "restore-oysterun");
126
+ // This copy is disposable; the receipt-bound original is never consumed.
127
+ // A crash during cp can therefore be retried without using partial bytes.
128
+ if (existsSync(prepared) && digestContinuityPackage(prepared) !== sourceHash) rmSync(prepared, { recursive: true });
129
+ if (!existsSync(prepared)) cpSync(r.previous_package_root, prepared, { recursive: true, dereference: false, verbatimSymlinks: true, preserveTimestamps: true });
130
+ if (digestContinuityPackage(prepared) !== sourceHash) fail("restore_copy", "Prepared rollback package differs from the retained source.");
131
+ if (existsSync(r.active)) {
132
+ if (existsSync(r.failed_package_root)) fail("ambiguous_active_package", "Both an active target and failed target already exist; retain both for recovery review.");
133
+ renameSync(r.active, r.failed_package_root);
134
+ }
135
+ renameSync(prepared, r.active);
136
+ }
137
+ mkdirSync(binDir, { recursive: true });
138
+ rmSync(link, { force: true });
139
+ symlinkSync(relative(binDir, join(r.active, "bin", "oysterun.mjs")), link);
140
+ restoreServiceDefinitionSnapshot({ definitionPath: r.service_definition_path, snapshot, rendered });
141
+ for (const binding of bindings) restoreRetainedFile(binding);
142
+ writeContinuityAtomic(path, { ...receipt, paired_restore_completed_at: new Date().toISOString() });
143
+ return { passed: true, source_package_sha256: sourceHash, version: receipt.source.version };
144
+ }
145
+
146
+ export function finalizeContinuityAssets({ path, operationId, configDir, activePackageRoot }) {
147
+ const receipt = assertContinuityFinalizable(path, operationId);
148
+ if (receipt.operation_root) {
149
+ const r = validateContinuityResources(receipt, configDir, activePackageRoot);
150
+ rmSync(r.root, { recursive: true, force: true });
151
+ }
152
+ removeContinuityProbeGrant(configDir, operationId);
153
+ if (receipt.restart_only_snapshot) {
154
+ if (receipt.restart_only_snapshot.saved_path !== `${path}.restore`) fail("restart_snapshot_boundary", "Restart snapshot is outside its exact operation path.");
155
+ rmSync(receipt.restart_only_snapshot.saved_path, { force: true });
156
+ }
157
+ return receipt.state === "CLEANED" ? receipt : advanceContinuity(path, operationId, "CLEANED", { recovery_assets_removed: true });
158
+ }
@@ -0,0 +1,97 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
3
+ import { continuityFailure, continuityReceiptPath, readContinuity, writeContinuityAtomic } from "./lifecycle-continuity.mjs";
4
+
5
+ // This grant authenticates one read-only operation observation. It cannot
6
+ // authenticate dashboard mutations, Matrix reads, Session control or login.
7
+ export const CONTINUITY_PROBE_ENDPOINT = "/admin/lifecycle-qualification";
8
+ export function continuityProbeGrantPath(configDir, operationId) {
9
+ return `${continuityReceiptPath(configDir, operationId)}.probe`;
10
+ }
11
+
12
+ export function createContinuityProbeGrant(configDir, operationId) {
13
+ const path = continuityProbeGrantPath(configDir, operationId);
14
+ if (existsSync(path)) throw continuityFailure("preparation", "probe_exists", "Lifecycle route grant already exists.");
15
+ writeContinuityAtomic(path, {
16
+ operation_id: operationId, token: randomBytes(32).toString("base64url"),
17
+ expires_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(),
18
+ });
19
+ }
20
+
21
+ function readGrant(configDir, operationId) {
22
+ const path = continuityProbeGrantPath(configDir, operationId);
23
+ const stat = lstatSync(path);
24
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
25
+ throw continuityFailure("route_probe", "private_grant", "Lifecycle route grant must be a private regular file.");
26
+ }
27
+ const grant = JSON.parse(readFileSync(path, "utf8"));
28
+ if (grant.operation_id !== operationId || typeof grant.token !== "string" || !Number.isFinite(Date.parse(grant.expires_at)) || Date.parse(grant.expires_at) <= Date.now()) {
29
+ throw continuityFailure("route_probe", "expired_grant", "Lifecycle route grant is missing, expired or mismatched.");
30
+ }
31
+ return grant;
32
+ }
33
+
34
+ // Only an already bound local controller may renew this read-only grant. The
35
+ // HTTP verifier never renews it; offline recovery does not need a live Host.
36
+ export function ensureContinuityProbeGrant(configDir, operationId) {
37
+ const receipt = readContinuity(continuityReceiptPath(configDir, operationId), operationId);
38
+ if (["CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) return;
39
+ const path = continuityProbeGrantPath(configDir, operationId);
40
+ if (existsSync(path)) {
41
+ const stat = lstatSync(path);
42
+ if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw continuityFailure("route_probe", "private_grant", "Lifecycle grant is not a private regular file.");
43
+ const grant = JSON.parse(readFileSync(path, "utf8"));
44
+ if (grant.operation_id !== operationId) throw continuityFailure("route_probe", "grant_binding", "Lifecycle grant belongs to a different operation.");
45
+ if (Date.parse(grant.expires_at) > Date.now() + 15 * 60 * 1000) return;
46
+ }
47
+ writeContinuityAtomic(path, { operation_id: operationId, token: randomBytes(32).toString("base64url"),
48
+ expires_at: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString() });
49
+ }
50
+
51
+ export function removeContinuityProbeGrant(configDir, operationId) {
52
+ rmSync(continuityProbeGrantPath(configDir, operationId), { force: true });
53
+ }
54
+
55
+ export function verifyContinuityProbeGrant(configDir, operationId, token) {
56
+ try {
57
+ const receipt = readContinuity(continuityReceiptPath(configDir, operationId), operationId);
58
+ if (["CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) return false;
59
+ const expected = Buffer.from(readGrant(configDir, operationId).token);
60
+ const actual = Buffer.from(typeof token === "string" ? token : "");
61
+ return actual.length === expected.length && timingSafeEqual(actual, expected);
62
+ } catch { return false; }
63
+ }
64
+
65
+ export async function fetchContinuityObservation({ configDir, operationId, origin, timeoutMs = 10_000 }) {
66
+ const url = new URL(origin);
67
+ if (url.origin !== origin || url.username || url.password ||
68
+ !(url.protocol === "https:" || (url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
69
+ throw continuityFailure("route_probe", "bounded_origin", "Lifecycle observation requires the retained HTTPS or loopback origin.");
70
+ }
71
+ const nonce = randomUUID();
72
+ url.pathname = CONTINUITY_PROBE_ENDPOINT;
73
+ url.searchParams.set("operation_id", operationId);
74
+ url.searchParams.set("nonce", nonce);
75
+ const response = await fetch(url, {
76
+ headers: { Authorization: `Bearer ${readGrant(configDir, operationId).token}`, Accept: "application/json" },
77
+ signal: AbortSignal.timeout(timeoutMs), redirect: "error", cache: "no-store",
78
+ });
79
+ if (!response.ok) {
80
+ const failure = await response.json().catch(() => null);
81
+ if (failure?.operation_id === operationId && failure.request_nonce === nonce &&
82
+ typeof failure.lifecycle_diagnostic?.stage === "string" && typeof failure.lifecycle_diagnostic?.check === "string") {
83
+ throw continuityFailure(failure.lifecycle_diagnostic.stage, failure.lifecycle_diagnostic.check,
84
+ String(failure.error || "Bound lifecycle observation failed."));
85
+ }
86
+ throw continuityFailure("route_probe", "http_status", `Lifecycle observation returned HTTP ${response.status}.`);
87
+ }
88
+ const observed = await response.json();
89
+ if (observed.operation_id !== operationId || observed.request_nonce !== nonce) {
90
+ throw continuityFailure("route_probe", "response_binding", "Lifecycle observation is stale or belongs to a different operation.");
91
+ }
92
+ return {
93
+ ...observed,
94
+ route: { origin, device_id: observed.host.device_id, operation_id: operationId,
95
+ authenticated: true, authentication: "operation_read_only_grant", http_status: response.status },
96
+ };
97
+ }
@@ -0,0 +1,22 @@
1
+ // UI state is a projection of durable facts, never HTTP/consumed inference.
2
+ export function projectContinuityOperation(operation, receipt) {
3
+ if (!receipt || receipt.operation_id !== operation.operation_id) return operation;
4
+ const restartOnly = receipt.transaction_kind === "restart_only";
5
+ const common = { ...operation, continuity_state: receipt.state,
6
+ restart_attempted: receipt.restart_attempted === true,
7
+ ...(receipt.first_failure ? { root_failure: { ...receipt.first_failure,
8
+ safe_message: receipt.first_failure.message, observed_at: receipt.first_failure.at } } : {}),
9
+ };
10
+ if (!["CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state)) return common;
11
+ const recovered = receipt.state === "ROLLED_BACK_HEALTHY";
12
+ return { ...common, status: restartOnly ? "restart_complete" : recovered ? "rolled_back" : "update_complete",
13
+ running: false, runner_pid: null, runner_lease_expires_at: null, lifecycle_owner_pid: null,
14
+ phase: recovered ? "Prior Host, every Session, history and route qualified; recovery evidence retained"
15
+ : "Host, every Session, history and route qualified; cleanup complete",
16
+ final_observed_version: recovered ? receipt.source.version : receipt.target.version,
17
+ operation_cleanup_completed: !recovered, rollback_completed: recovered && !restartOnly,
18
+ service_status: "healthy", session_restore_status: "qualified_without_replay",
19
+ restart_restore_status: receipt.recovery_qualification?.sessions?.restore_required === false ? "not_required" : "consumed",
20
+ next_safe_action: null, recovery_required_at: null, recovery_reason: null, recovery_boundary: null,
21
+ };
22
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ CONTINUITY_STATES, advanceContinuity, continuityFailure, readContinuity, qualifyContinuityHost,
3
+ qualifyContinuitySessions, qualifyContinuityTunnel,
4
+ } from "./lifecycle-continuity.mjs";
5
+ import { fetchContinuityObservation } from "./lifecycle-continuity-probe.mjs";
6
+
7
+ /** Shared qualification used by the existing runner and the one-time adapter.
8
+ * Observations are live authenticated reads; the retained baseline is never
9
+ * replaced by a new observation after a restart or a failed qualification.
10
+ */
11
+ export async function qualifyLifecycleContinuity({
12
+ path, operationId, configDir, healthUrl, version, definitionDigest,
13
+ rollback = false, requireRestore = true, restartId = null,
14
+ timeoutMs = 180_000, onStage = () => {}, observe = fetchContinuityObservation,
15
+ }) {
16
+ const receipt = readContinuity(path, operationId);
17
+ const baseline = receipt.baseline;
18
+ const expectedOrigin = new URL(baseline.host.managed_endpoint || baseline.host.direct_host_url).origin;
19
+ const loopbackOrigin = new URL(healthUrl).origin;
20
+ const result = {};
21
+ const advance = (stage, proof) => {
22
+ if (rollback) return;
23
+ const current = readContinuity(path, operationId);
24
+ if (CONTINUITY_STATES.indexOf(current.state) < CONTINUITY_STATES.indexOf(stage)) {
25
+ advanceContinuity(path, operationId, stage, proof);
26
+ }
27
+ };
28
+ const waitFor = async (stage, callback) => {
29
+ await onStage(stage);
30
+ const deadline = Date.now() + timeoutMs;
31
+ let failure;
32
+ do {
33
+ try { return await callback(); } catch (error) {
34
+ failure = error;
35
+ // Identity, lost history, explicit failed/skipped restores and changed
36
+ // resume IDs cannot be repaired by waiting. Provider boot and route
37
+ // readiness may be transient.
38
+ const transient = new Set([
39
+ "p233_runtime_not_ready", "p233_http_status", "p233_authenticated_path",
40
+ "p233_restart_not_consumed", "p233_user_path_pending",
41
+ ]).has(error.code) || ["TypeError", "TimeoutError", "AbortError"].includes(error.name);
42
+ if (!transient) throw error;
43
+ if (Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, Math.min(1000, deadline - Date.now())));
44
+ }
45
+ } while (Date.now() < deadline);
46
+ throw failure;
47
+ };
48
+ const local = () => observe({ configDir, operationId, origin: loopbackOrigin });
49
+ const checkHost = (observation) => {
50
+ const proof = qualifyContinuityHost(baseline.host, observation.host, {
51
+ version, runtimeIdentityDigest: receipt.source.runtime_identity_digest,
52
+ expectNewPid: requireRestore, definitionDigest,
53
+ });
54
+ if (result.host && proof.pid !== result.host.pid) throw continuityFailure("host_qualification", "qualification_pid_changed", "Host PID changed during qualification; retain recovery assets.");
55
+ return proof;
56
+ };
57
+ const checkSessions = (observation) => {
58
+ if (requireRestore && (observation.restart_state !== "consumed" || observation.restart_id !== restartId)) {
59
+ throw continuityFailure("session_qualification", "restart_not_consumed", "Waiting for the exact fresh Session restore receipt.");
60
+ }
61
+ return qualifyContinuitySessions(baseline.sessions, observation.sessions,
62
+ observation.restore_result?.session_restore, { requireRestore });
63
+ };
64
+ result.host = await waitFor("HOST_QUALIFIED", async () => checkHost(await local()));
65
+ advance("HOST_QUALIFIED", result.host);
66
+ result.sessions = await waitFor("SESSIONS_QUALIFIED", async () => {
67
+ const observation = await local();
68
+ checkHost(observation);
69
+ return checkSessions(observation);
70
+ });
71
+ advance("SESSIONS_QUALIFIED", result.sessions);
72
+ result.tunnel = await waitFor("TUNNEL_QUALIFIED", async () => {
73
+ const observation = await observe({ configDir, operationId, origin: expectedOrigin });
74
+ // A provider can exit while the user signs back in. Commit only a fresh
75
+ // observation that still proves the same Host and complete Session roster.
76
+ checkHost(observation);
77
+ checkSessions(observation);
78
+ // The scoped route probe proves transport and Host identity; a real
79
+ // dashboard-authenticated request on that origin proves the normal user
80
+ // path. Neither observation substitutes for the other.
81
+ if (observation.authenticated_user_path?.pid !== observation.host.pid) {
82
+ throw continuityFailure("tunnel_qualification", "user_path_pending", "Sign in through the existing Host URL to finish authenticated route verification.");
83
+ }
84
+ return qualifyContinuityTunnel({ origin: expectedOrigin, device_id: baseline.host.device_id, operation_id: operationId }, observation.route);
85
+ });
86
+ advance("TUNNEL_QUALIFIED", result.tunnel);
87
+ return result;
88
+ }