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.
- package/dev/client/web/index.html +44 -22
- package/host-service/cli/run-host-lifecycle.mjs +10 -0
- package/host-service/host-lifecycle-recovery.mjs +32 -0
- package/host-service/lifecycle-continuity-assets.mjs +158 -0
- package/host-service/lifecycle-continuity-probe.mjs +97 -0
- package/host-service/lifecycle-continuity-projection.mjs +22 -0
- package/host-service/lifecycle-continuity-runner.mjs +88 -0
- package/host-service/lifecycle-continuity.mjs +290 -0
- package/host-service/lifecycle-diagnostics.mjs +272 -0
- package/host-service/lifecycle-service-render-bundle.mjs +120 -0
- package/host-service/package.json +7 -2
- package/host-service/server.mjs +1155 -75
- package/host-service/service-definition-migration.mjs +472 -0
- package/host-service/update-reminder-state.mjs +40 -0
- package/package.json +4 -1
- package/tool_scripts/install_oysterun_launch_agents.sh +6 -67
- package/tool_scripts/install_oysterun_update.mjs +160 -21
- package/tool_scripts/oysterun_continuity_transaction.mjs +306 -0
- package/tool_scripts/oysterun_npm_package_manifest.mjs +10 -0
- package/tool_scripts/oysterun_release_service.sh +6 -1
- package/tool_scripts/oysterun_service_common.sh +175 -14
- package/tool_scripts/oysterun_update_restart_runner.mjs +540 -5
- package/tool_scripts/preflight_oysterun_restart.sh +15 -1
- package/tool_scripts/restart_oysterun.sh +29 -5
|
@@ -9513,8 +9513,9 @@
|
|
|
9513
9513
|
class="field-hint"
|
|
9514
9514
|
id="host-restart-default-copy"
|
|
9515
9515
|
>
|
|
9516
|
-
Existing
|
|
9517
|
-
|
|
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
|
-
|
|
9881
|
-
|
|
9882
|
-
|
|
9883
|
-
|
|
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?.
|
|
31688
|
-
operation?.status === "
|
|
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
|
|
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
|
-
|
|
45903
|
-
|
|
45904
|
-
|
|
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
|
|
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
|
+
}
|