oysterun 1.6.0-beta.2 → 1.6.0-beta.21

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 (242) hide show
  1. package/bin/oysterun.mjs +64 -17
  2. package/dev/client/connectivity/completed-message-neighbor-click.mjs +78 -0
  3. package/dev/client/connectivity/completed-message-neighbor-contract.mjs +205 -0
  4. package/dev/client/connectivity/completed-message-visibility.d.mts +4 -4
  5. package/dev/client/connectivity/completed-message-visibility.mjs +14 -11
  6. package/dev/client/connectivity/desktop-chat-sidebar-preference.mjs +57 -0
  7. package/dev/client/connectivity/host-connection-feedback.mjs +12 -1
  8. package/dev/client/connectivity/managed-host-browser.mjs +10 -0
  9. package/dev/client/connectivity/session-navigation-intent.mjs +189 -0
  10. package/dev/client/connectivity/session-transition-state.mjs +18 -6
  11. package/dev/client/connectivity/test-completed-message-neighbor-click.mjs +180 -0
  12. package/dev/client/web/index.html +5367 -609
  13. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js → ReactPrism-C6YiTZTC.js} +1 -1
  14. package/dev/client/web-chat/dist/assets/{ReactPrism-BwJhidYf.js.map → ReactPrism-C6YiTZTC.js.map} +1 -1
  15. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js +6442 -0
  16. package/dev/client/web-chat/dist/assets/SessionsSetupApp-CKJOSPFP.js.map +1 -0
  17. package/dev/client/web-chat/dist/assets/SessionsSetupApp-Dx09Uz-6.css +1 -0
  18. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js +9 -0
  19. package/dev/client/web-chat/dist/assets/index-Ae5I4LHp.js.map +1 -0
  20. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js +2 -0
  21. package/dev/client/web-chat/dist/assets/index-CN_FoTB1.js.map +1 -0
  22. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js → index-DCQFwgGf.js} +2 -2
  23. package/dev/client/web-chat/dist/assets/{index--1xmQ99x.js.map → index-DCQFwgGf.js.map} +1 -1
  24. package/dev/client/web-chat/dist/assets/matrix-entry-BV7G38kH.css +10 -0
  25. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js +594 -0
  26. package/dev/client/web-chat/dist/assets/matrix-entry-abAy3aOo.js.map +1 -0
  27. package/dev/client/web-chat/dist/assets/{pdf-ZjtalfgI.js → pdf-DN7oVvM6.js} +5 -5
  28. package/dev/client/web-chat/dist/assets/pdf-DN7oVvM6.js.map +1 -0
  29. package/dev/client/web-chat/dist/assets/session-notice-DlyODy1z.css +1 -0
  30. package/dev/client/web-chat/dist/assets/web-BlYuDlOV.js +1 -0
  31. package/dev/client/web-chat/dist/assets/{web-c70YaskK.js.map → web-BlYuDlOV.js.map} +1 -1
  32. package/dev/client/web-chat/dist/assets/web-Dabw6cJi.js +1 -0
  33. package/dev/client/web-chat/dist/assets/{web-BzHcZK2M.js.map → web-Dabw6cJi.js.map} +1 -1
  34. package/dev/client/web-chat/dist/index.html +1 -2
  35. package/docs/routec/dev/cloud_deplyments.md +5 -0
  36. package/docs/routec/dev/npm_native_dependency_install.md +6 -2
  37. package/docs/routec/dev/npmjs_publish.md +55 -19
  38. package/docs/routec/dev/p262_setup_input_file.md +117 -0
  39. package/docs/routec/host2_turn_started_idempotency_diagnostics.md +8 -7
  40. package/docs/routec/host_setup.md +39 -10
  41. package/docs/routec/oysterun_operation_interface_spec.md +78 -2
  42. package/docs/routec/provider_contract.md +35 -5
  43. package/host-service/README.md +36 -0
  44. package/host-service/adapters/claude-code-adapter.mjs +48 -5
  45. package/host-service/adapters/codex-app-server-adapter.mjs +320 -18
  46. package/host-service/adapters/debug-fixture-provider-adapter.mjs +102 -1
  47. package/host-service/adapters/debug-large-tool-spillover-provider-adapter.mjs +2 -0
  48. package/host-service/adapters/debug-p135-codex-replay-provider-adapter.mjs +2 -0
  49. package/host-service/adapters/debug-routec-structural-replay-provider-adapter.mjs +2 -0
  50. package/host-service/agent-config.mjs +16 -5
  51. package/host-service/agent-folder-availability.mjs +41 -0
  52. package/host-service/apns-complete-message-dispatcher.mjs +1 -0
  53. package/host-service/assets/product-skills/Oysterun/modules/oysterun-find-context/SKILL.md +24 -0
  54. package/host-service/assets/product-skills/Oysterun/modules/oysterun-session-chat/SKILL.md +94 -5
  55. package/host-service/assets/product-skills/Oysterun/modules/oysterun-sessions/SKILL.md +103 -0
  56. package/host-service/assets/product-skills/Oysterun/modules/oysterun-website/SKILL.md +7 -0
  57. package/host-service/assets/product-skills/oysterun-find-context/SKILL.md +24 -0
  58. package/host-service/assets/product-skills/oysterun-session-chat/SKILL.md +94 -5
  59. package/host-service/assets/product-skills/oysterun-sessions/SKILL.md +103 -0
  60. package/host-service/assets/product-skills/oysterun-website/SKILL.md +7 -0
  61. package/host-service/callback-durable-store.mjs +354 -47
  62. package/host-service/callback-foundation.mjs +21 -1
  63. package/host-service/callback-host-integration.mjs +345 -30
  64. package/host-service/claude-acp-model-discovery.mjs +23 -18
  65. package/host-service/cli/assert-installer-reinstall-target.mjs +9 -0
  66. package/host-service/cli/installer-recovery-guidance.mjs +20 -0
  67. package/host-service/cli/prepare-restart-restore.mjs +2 -0
  68. package/host-service/cli/product-cli.mjs +34 -33
  69. package/host-service/cli/reconcile-initial-preflight.mjs +19 -0
  70. package/host-service/cli/recover-installer-lifecycle.mjs +24 -0
  71. package/host-service/cli/run-host-lifecycle.mjs +2 -0
  72. package/host-service/cli/run-staged-lifecycle-recovery.mjs +161 -0
  73. package/host-service/codex-model-discovery.mjs +23 -18
  74. package/host-service/complete-cli-uninstall.mjs +176 -0
  75. package/host-service/complete-message-notification-predicate.mjs +8 -6
  76. package/host-service/complete-message-notification-title.mjs +4 -0
  77. package/host-service/completed-session-unread.mjs +425 -42
  78. package/host-service/completed-turn-navigation.mjs +444 -144
  79. package/host-service/completed-turn-notification-observer.mjs +116 -0
  80. package/host-service/completed-turn-service.mjs +445 -0
  81. package/host-service/config.mjs +115 -9
  82. package/host-service/desired-open-session-state.mjs +331 -0
  83. package/host-service/fixtures/p218-completed-turn-notification.json +11 -0
  84. package/host-service/frp-agent.mjs +9 -9
  85. package/host-service/grok-acp-model-discovery.mjs +31 -33
  86. package/host-service/host-lifecycle-recovery.mjs +37 -0
  87. package/host-service/host-port-resolution.mjs +98 -0
  88. package/host-service/host-runtime-diagnostics.mjs +18 -1
  89. package/host-service/initial-preflight-recovery.mjs +71 -0
  90. package/host-service/installer-recovery-guidance.mjs +85 -0
  91. package/host-service/installer-reinstall-bindings.mjs +49 -0
  92. package/host-service/installer-reinstall-target.mjs +49 -0
  93. package/host-service/legacy-continuity-observation.mjs +33 -0
  94. package/host-service/lifecycle-compat-cli.mjs +13 -0
  95. package/host-service/lifecycle-compat-preload.mjs +89 -0
  96. package/host-service/lifecycle-compatibility.mjs +164 -0
  97. package/host-service/lifecycle-continuity-assets.mjs +222 -0
  98. package/host-service/lifecycle-continuity-probe.mjs +97 -0
  99. package/host-service/lifecycle-continuity-projection.mjs +29 -0
  100. package/host-service/lifecycle-continuity-runner.mjs +192 -0
  101. package/host-service/lifecycle-continuity.mjs +493 -0
  102. package/host-service/lifecycle-service-render-bundle.mjs +120 -0
  103. package/host-service/lifecycle-user-path.mjs +34 -0
  104. package/host-service/link-annotations.mjs +10 -0
  105. package/host-service/matrix-credential-issuer.mjs +32 -1
  106. package/host-service/matrix-event-correlator.mjs +166 -27
  107. package/host-service/matrix-facade.mjs +96 -19
  108. package/host-service/matrix-room-binding.mjs +24 -0
  109. package/host-service/matrix-transcript-read-adapter.mjs +17 -3
  110. package/host-service/model-discovery-guardian.mjs +40 -0
  111. package/host-service/model-discovery-process.mjs +141 -0
  112. package/host-service/onboard-lifecycle-status.mjs +28 -0
  113. package/host-service/onboard.mjs +479 -0
  114. package/host-service/package.json +2 -1
  115. package/host-service/portable-scheduler-definitions.mjs +28 -0
  116. package/host-service/product-runtime-host-origin.mjs +22 -0
  117. package/host-service/provider-model-params.mjs +14 -17
  118. package/host-service/provider-model-refresh-runner.mjs +124 -169
  119. package/host-service/provider-model-refresh-schedule.mjs +11 -0
  120. package/host-service/provider-resume-contract.mjs +9 -3
  121. package/host-service/restart-restore-state.mjs +25 -0
  122. package/host-service/retained-host-state-holder.mjs +57 -0
  123. package/host-service/routec-matrix-sqlite-store.mjs +155 -10
  124. package/host-service/routec-matrix-storage-adapter.mjs +208 -25
  125. package/host-service/routec-matrix-storage-paths.mjs +12 -0
  126. package/host-service/routec-request-delay-window.mjs +55 -0
  127. package/host-service/scheduler-acp-provider-runner.mjs +2 -1
  128. package/host-service/scheduler-direct-provider-runner.mjs +71 -9
  129. package/host-service/scheduler-run-timeout-policy.mjs +85 -0
  130. package/host-service/scheduler-service.mjs +96 -9
  131. package/host-service/scheduler-store.mjs +9 -2
  132. package/host-service/server.mjs +2202 -552
  133. package/host-service/service-definition-migration.mjs +10 -3
  134. package/host-service/session-collection-service.mjs +320 -0
  135. package/host-service/session-history.mjs +99 -15
  136. package/host-service/session-list-preview-service.mjs +87 -12
  137. package/host-service/session-list-projection-service.mjs +182 -0
  138. package/host-service/session-loop-definitions.mjs +702 -75
  139. package/host-service/session-manager.mjs +473 -38
  140. package/host-service/session-switcher-projection.mjs +11 -3
  141. package/host-service/session-tags.mjs +451 -0
  142. package/host-service/session-transcript-target.mjs +103 -0
  143. package/host-service/setup-input-file.mjs +91 -0
  144. package/host-service/setup-provider-model-defaults.mjs +53 -0
  145. package/host-service/setup.mjs +133 -101
  146. package/host-service/stopped-user-host-package.mjs +60 -0
  147. package/host-service/test-fixtures/p236-navigation-original.mjs +500 -0
  148. package/host-service/update-reminder-state.mjs +7 -0
  149. package/host-service/voice-input-receipt-store.mjs +60 -0
  150. package/package.json +1 -1
  151. package/static/oysterun-social-preview.png +0 -0
  152. package/static/site/assets/oysterun-social-preview.png +0 -0
  153. package/static/site/blog/en/index.html +17 -0
  154. package/static/site/blog/generated_manifest.json +9 -9
  155. package/static/site/blog/index.html +18 -0
  156. package/static/site/blog/voice-input-model-experiment/en/index.html +17 -0
  157. package/static/site/blog/voice-input-model-experiment/zh/index.html +17 -0
  158. package/static/site/blog/zh/index.html +17 -0
  159. package/static/site/docs/choose-and-recover-a-provider/en/index.html +17 -0
  160. package/static/site/docs/choose-and-recover-a-provider/zh/index.html +17 -0
  161. package/static/site/docs/configure-session-files-and-settings/en/index.html +17 -0
  162. package/static/site/docs/configure-session-files-and-settings/zh/index.html +17 -0
  163. package/static/site/docs/en/index.html +17 -0
  164. package/static/site/docs/generated_manifest.json +60 -60
  165. package/static/site/docs/host-authentication-and-connection/en/index.html +17 -0
  166. package/static/site/docs/host-authentication-and-connection/zh/index.html +17 -0
  167. package/static/site/docs/index.html +18 -0
  168. package/static/site/docs/install-and-use-a-skill/en/index.html +17 -0
  169. package/static/site/docs/install-and-use-a-skill/zh/index.html +17 -0
  170. package/static/site/docs/read-large-tool-results/en/index.html +17 -0
  171. package/static/site/docs/read-large-tool-results/zh/index.html +17 -0
  172. package/static/site/docs/recover-from-a-host-runtime-error/en/index.html +17 -0
  173. package/static/site/docs/recover-from-a-host-runtime-error/zh/index.html +17 -0
  174. package/static/site/docs/restore-work-after-a-host-restart/en/index.html +17 -0
  175. package/static/site/docs/restore-work-after-a-host-restart/zh/index.html +17 -0
  176. package/static/site/docs/send-images-and-media/en/index.html +17 -0
  177. package/static/site/docs/send-images-and-media/zh/index.html +17 -0
  178. package/static/site/docs/set-up-and-understand-a-host/en/index.html +17 -0
  179. package/static/site/docs/set-up-and-understand-a-host/zh/index.html +17 -0
  180. package/static/site/docs/understand-agent-activity/en/index.html +19 -2
  181. package/static/site/docs/understand-agent-activity/en/index.md +2 -2
  182. package/static/site/docs/understand-agent-activity/zh/index.html +19 -2
  183. package/static/site/docs/understand-agent-activity/zh/index.md +2 -2
  184. package/static/site/docs/use-the-oysterun-cli-safely/en/index.html +17 -0
  185. package/static/site/docs/use-the-oysterun-cli-safely/zh/index.html +17 -0
  186. package/static/site/docs/zh/index.html +17 -0
  187. package/static/site/en/index.html +18 -1
  188. package/static/site/en/index.md +1 -1
  189. package/static/site/faq/en/index.html +17 -0
  190. package/static/site/faq/index.html +18 -0
  191. package/static/site/faq/zh/index.html +17 -0
  192. package/static/site/getting-started/en/index.html +23 -1
  193. package/static/site/getting-started/en/index.md +7 -1
  194. package/static/site/getting-started/index.html +18 -0
  195. package/static/site/getting-started/zh/index.html +23 -1
  196. package/static/site/getting-started/zh/index.md +7 -1
  197. package/static/site/index.html +18 -0
  198. package/static/site/install.sh +676 -127
  199. package/static/site/overview/en/index.html +18 -1
  200. package/static/site/overview/en/index.md +2 -2
  201. package/static/site/overview/index.html +18 -0
  202. package/static/site/overview/zh/index.html +18 -1
  203. package/static/site/overview/zh/index.md +2 -2
  204. package/static/site/privacy/en/index.html +17 -0
  205. package/static/site/privacy/index.html +18 -0
  206. package/static/site/privacy/zh/index.html +17 -0
  207. package/static/site/zh/index.html +18 -1
  208. package/static/site/zh/index.md +1 -1
  209. package/static/site_source/content/tutorials/published/understand-agent-activity/en/article.html +2 -2
  210. package/static/site_source/content/tutorials/published/understand-agent-activity/zh/article.html +2 -2
  211. package/static/site_source/downloads/install.sh +676 -127
  212. package/static/site_source/manifests/host_install.json +2 -10
  213. package/static/site_source/pages/getting-started/en.html +6 -1
  214. package/static/site_source/pages/getting-started/zh.html +6 -1
  215. package/static/site_source/pages/home/en.html +1 -1
  216. package/static/site_source/pages/home/zh.html +1 -1
  217. package/static/site_source/pages/home_main.html +3 -2
  218. package/static/site_source/pages/overview/en.html +1 -1
  219. package/static/site_source/pages/overview/zh.html +1 -1
  220. package/tool_scripts/install_oysterun_update.mjs +78 -13
  221. package/tool_scripts/oysterun_continuity_transaction.mjs +438 -0
  222. package/tool_scripts/oysterun_npm_package_manifest.mjs +32 -0
  223. package/tool_scripts/oysterun_release_service.sh +5 -0
  224. package/tool_scripts/oysterun_service_common.sh +59 -24
  225. package/tool_scripts/oysterun_setup_input.sh +97 -0
  226. package/tool_scripts/oysterun_update_restart_runner.mjs +19 -3
  227. package/tool_scripts/p233_legacy_bootstrap.mjs +254 -0
  228. package/tool_scripts/resolve_user_host_port.mjs +15 -0
  229. package/tool_scripts/restart_oysterun.sh +8 -2
  230. package/tool_scripts/stack_dashboard_credentials.mjs +2 -1
  231. package/tool_scripts/status_oysterun_launch_agents.sh +13 -1
  232. package/tool_scripts/sync_host_stack_config.mjs +9 -6
  233. package/tool_scripts/test_environment_remote_cloud_deprovision.mjs +419 -0
  234. package/tool_scripts/uninstall_oysterun_launch_agents.sh +3 -5
  235. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js +0 -129
  236. package/dev/client/web-chat/dist/assets/index-Ce9NZBZf.js.map +0 -1
  237. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js +0 -2
  238. package/dev/client/web-chat/dist/assets/index-DzIClZ3T.js.map +0 -1
  239. package/dev/client/web-chat/dist/assets/index-NYnaSGBb.css +0 -1
  240. package/dev/client/web-chat/dist/assets/pdf-ZjtalfgI.js.map +0 -1
  241. package/dev/client/web-chat/dist/assets/web-BzHcZK2M.js +0 -1
  242. package/dev/client/web-chat/dist/assets/web-c70YaskK.js +0 -1
@@ -0,0 +1,493 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync,
4
+ readFileSync, renameSync, writeFileSync,
5
+ } from "node:fs";
6
+ import { dirname, isAbsolute, join, resolve } from "node:path";
7
+ import { compareOysterunSemver } from "./npm-update-policy.mjs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { sanitizeLifecycleDiagnostic } from "./lifecycle-diagnostics.mjs";
10
+ import { retireHostRestartRestoreState } from "./restart-restore-state.mjs";
11
+ import { channel } from "node:diagnostics_channel";
12
+
13
+ // One operation-owned record shared by normal updates and the bounded legacy
14
+ // adapter. Lower-case lifecycle/UI statuses remain projections of this record.
15
+ export const CONTINUITY_STATES = Object.freeze([
16
+ "PREPARED", "PACKAGE_SWITCHED", "SERVICE_RESTARTED", "HOST_QUALIFIED",
17
+ "SESSIONS_QUALIFIED", "TUNNEL_QUALIFIED", "COMMITTED", "CLEANED",
18
+ ]);
19
+ export const CONTINUITY_SCHEMA = "oysterun.lifecycle_continuity.v1";
20
+ export const RECOVERY_IDENTITY_SCHEMA = "oysterun.recovery_identity.v1";
21
+ const durableBoundaryChannel = channel("oysterun.lifecycle.continuity.durable");
22
+ function publishDurableBoundary(path, receipt) {
23
+ durableBoundaryChannel.publish({ path, operation_id: receipt.operation_id, state: receipt.state });
24
+ return receipt;
25
+ }
26
+
27
+ export function continuityFailure(stage, check, message) {
28
+ const error = new Error(message);
29
+ error.code = `p233_${check}`;
30
+ error.lifecycle_diagnostic = { stage, check, code: error.code, result: "failed" };
31
+ return error;
32
+ }
33
+
34
+ function requireValue(condition, stage, check, message) {
35
+ if (!condition) throw continuityFailure(stage, check, message);
36
+ }
37
+
38
+ const identityFields = ["session_id", "provider", "provider_resume_id", "provider_thread_id",
39
+ "agent_id", "agent_folder", "matrix_binding_host_session_id", "matrix_room_id", "resume_identity_kind"];
40
+ const baselineHostFields = new Set(["version", "pid", "port", "stack", "package_root", "process_cwd",
41
+ "node_version", "runtime_identity_digest", "device_id", "config_dir", "host_folder",
42
+ "browser_root", "connection_mode", "managed_endpoint", "direct_host_url", "service_definition_sha256",
43
+ "service_definition_path"]);
44
+ const baselineSessionFields = new Set([...identityFields, "adapter_resume_identity_kind", "adapter_registered",
45
+ "alive", "ready", "history_anchors", "observation_error"]);
46
+ const requiredIdentityFields = ["session_id", "provider", "agent_id", "agent_folder",
47
+ "matrix_binding_host_session_id", "matrix_room_id"];
48
+ const realProviders = new Set(["codex", "claude", "xai"]);
49
+ const bounded = (value) => typeof value === "string" && value.length <= 2048 && value.trim() === value && value.length > 0 ? value : null;
50
+
51
+ // Only these fields can affect recovery identity. The adapter's declared mode
52
+ // is captured by the Host, never inferred from the provider name or a Session ID.
53
+ export function canonicalizeContinuityIdentity(row) {
54
+ const identity = Object.fromEntries(identityFields.map((key) => [key, bounded(row?.[key])]));
55
+ identity.schema = RECOVERY_IDENTITY_SCHEMA;
56
+ const adapterKind = bounded(row?.adapter_resume_identity_kind);
57
+ const missing = requiredIdentityFields.find((key) => !identity[key]);
58
+ if (missing) return { identity, error: `session_${missing}` };
59
+ if (identity.matrix_binding_host_session_id !== identity.session_id) return { identity, error: "session_matrix_binding_host_session_id" };
60
+ const fixture = adapterKind === "host_session" && row?.adapter_registered === true &&
61
+ !realProviders.has(identity.provider);
62
+ if (identity.resume_identity_kind === "host_session") {
63
+ if (!fixture || identity.provider_resume_id ||
64
+ (identity.provider_thread_id && identity.provider_thread_id !== identity.session_id))
65
+ return { identity, error: "session_resume_identity_kind" };
66
+ } else if (identity.resume_identity_kind === "provider_metadata" || identity.resume_identity_kind === null) {
67
+ if (!realProviders.has(identity.provider) || adapterKind !== "provider_metadata" ||
68
+ row?.adapter_registered !== true || !identity.provider_resume_id ||
69
+ !identity.provider_thread_id || identity.provider_resume_id === identity.session_id ||
70
+ identity.provider_thread_id === identity.session_id)
71
+ return { identity, error: "session_provider_resume_id" };
72
+ // The sole legacy representation exception is an absent source mode with
73
+ // provider-owned identity already present. Both observations use this mode.
74
+ identity.resume_identity_kind = "provider_metadata";
75
+ } else return { identity, error: "session_resume_identity_kind" };
76
+ return { identity, error: null };
77
+ }
78
+
79
+ export function hasCompleteContinuityResumeIdentity(row) {
80
+ return canonicalizeContinuityIdentity(row).error === null;
81
+ }
82
+
83
+ function boundedIdentityObservation(rows) {
84
+ return Array.isArray(rows) ? rows.slice(0, 256).map((row) => ({
85
+ ...Object.fromEntries(identityFields.map((key) => [key, bounded(row?.[key])])),
86
+ adapter_resume_identity_kind: bounded(row?.adapter_resume_identity_kind),
87
+ adapter_registered: row?.adapter_registered === true,
88
+ alive: row?.alive === true, ready: row?.ready === true,
89
+ history_anchors: Array.isArray(row?.history_anchors) ? row.history_anchors.slice(0, 8).map((anchor) => ({
90
+ event_id: bounded(anchor?.event_id), sha256: /^[a-f0-9]{64}$/i.test(anchor?.sha256 || "") ? anchor.sha256 : null,
91
+ })) : null,
92
+ })) : null;
93
+ }
94
+
95
+ function boundedBaseline(baseline) {
96
+ const host = Object.fromEntries(Object.entries(baseline.host).filter(([key]) => baselineHostFields.has(key))
97
+ .map(([key, value]) => [key, ["pid", "port"].includes(key)
98
+ ? (Number.isSafeInteger(value) && value > 0 ? value : null) : bounded(value)]));
99
+ const sessions = baseline.sessions.slice(0, 256).map((row) => Object.fromEntries(
100
+ Object.entries(row).filter(([key]) => baselineSessionFields.has(key)).map(([key, value]) => [key,
101
+ key === "history_anchors" ? (Array.isArray(value) ? value.slice(0, 8).map((anchor) => ({
102
+ event_id: bounded(anchor?.event_id), sha256: bounded(anchor?.sha256),
103
+ })) : null) : ["alive", "ready", "adapter_registered"].includes(key)
104
+ ? value === true : bounded(value)])));
105
+ return { host, sessions, captured_at: bounded(baseline.captured_at) };
106
+ }
107
+
108
+ export function compareContinuityIdentities(expected, observed, { path = null, operationId = null, stage = "session_qualification" } = {}) {
109
+ const source = boundedIdentityObservation(expected), target = boundedIdentityObservation(observed);
110
+ const normalizedSource = source?.map(canonicalizeContinuityIdentity) || [];
111
+ const normalizedTarget = target?.map(canonicalizeContinuityIdentity) || [];
112
+ let check = !source || !target ? "roster_missing" :
113
+ source.length !== expected.length || target.length !== observed.length ? "roster_limit" : null;
114
+ if (!check && new Set(target.map((row) => row.session_id)).size !== target.length) check = "duplicate_runtime";
115
+ if (!check && source.length !== target.length) check = "roster_changed";
116
+ for (let index = 0; !check && index < source.length; index++) {
117
+ const before = normalizedSource[index];
118
+ const afterIndex = target.findIndex((row) => row.session_id === source[index].session_id);
119
+ if (afterIndex < 0) { check = "session_missing"; break; }
120
+ const after = normalizedTarget[afterIndex];
121
+ if (before.error || after.error) { check = before.error || after.error; break; }
122
+ for (const key of identityFields) {
123
+ if (before.identity[key] !== after.identity[key]) { check = `session_${key}`; break; }
124
+ }
125
+ }
126
+ const evidence = { schema: RECOVERY_IDENTITY_SCHEMA, stage, source, target,
127
+ normalized_source: normalizedSource.map((row) => row.identity),
128
+ normalized_target: normalizedTarget.map((row) => row.identity),
129
+ verdict: { passed: !check, check } };
130
+ persistContinuityIdentityEvidence(evidence, { path, operationId });
131
+ if (check) throw continuityFailure(stage, check, `Continuity identity failed ${check}.`);
132
+ return evidence;
133
+ }
134
+
135
+ function persistContinuityIdentityEvidence(evidence, { path = null, operationId = null } = {}) {
136
+ if (path) {
137
+ const receipt = readContinuity(path, operationId);
138
+ receipt.identity_comparisons ||= [];
139
+ receipt.identity_comparisons.push(evidence);
140
+ // Keep retries bounded while preserving the first terminal failure ahead
141
+ // of a transient pending observation. A pending subset must never evict
142
+ // the raw/normalized evidence that caused the actual rollback.
143
+ if (receipt.identity_comparisons.length > 12) {
144
+ const firstFailure = receipt.identity_comparisons.find((row) =>
145
+ row.verdict.passed === false && row.verdict.pending !== true);
146
+ const firstPending = receipt.identity_comparisons.find((row) => row.verdict.pending === true);
147
+ const retainedPriority = firstFailure || firstPending;
148
+ const tail = receipt.identity_comparisons.slice(-12);
149
+ receipt.identity_comparisons = retainedPriority && !tail.includes(retainedPriority)
150
+ ? [retainedPriority, ...tail.slice(-11)] : tail;
151
+ }
152
+ writeContinuityAtomic(path, receipt);
153
+ }
154
+ }
155
+
156
+ // A restart receipt can be complete before every restored provider runtime is
157
+ // observable in the live roster. Preserve the full bounded observation before
158
+ // waiting, while a separate exact-subset qualification validates every runtime
159
+ // that is already visible. This pending verdict is not a continuity PASS.
160
+ export function recordContinuityRosterPending(expected, observed,
161
+ { path = null, operationId = null, stage = "session_qualification" } = {}) {
162
+ const source = boundedIdentityObservation(expected), target = boundedIdentityObservation(observed);
163
+ const normalizedSource = source?.map(canonicalizeContinuityIdentity) || [];
164
+ const normalizedTarget = target?.map(canonicalizeContinuityIdentity) || [];
165
+ const evidence = { schema: RECOVERY_IDENTITY_SCHEMA, stage, source, target,
166
+ normalized_source: normalizedSource.map((row) => row.identity),
167
+ normalized_target: normalizedTarget.map((row) => row.identity),
168
+ verdict: { passed: false, pending: true, check: "roster_changed" } };
169
+ persistContinuityIdentityEvidence(evidence, { path, operationId });
170
+ return evidence;
171
+ }
172
+
173
+ export function continuityDigest(value) {
174
+ return createHash("sha256").update(typeof value === "string" || Buffer.isBuffer(value)
175
+ ? value : JSON.stringify(value)).digest("hex");
176
+ }
177
+
178
+ export function writeContinuityBytesAtomic(path, bytes, { mode = 0o600 } = {}) {
179
+ requireValue(isAbsolute(path), "receipt", "absolute_path", "Continuity receipt requires an absolute path.");
180
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
181
+ if (existsSync(path)) {
182
+ const stat = lstatSync(path);
183
+ requireValue(stat.isFile() && !stat.isSymbolicLink(), "receipt", "regular_file", "Continuity receipt must be a regular file.");
184
+ }
185
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
186
+ const fd = openSync(temporary, "wx", mode);
187
+ try {
188
+ writeFileSync(fd, bytes);
189
+ fsyncSync(fd);
190
+ } finally { closeSync(fd); }
191
+ renameSync(temporary, path);
192
+ const directory = openSync(dirname(path), "r");
193
+ try { fsyncSync(directory); } finally { closeSync(directory); }
194
+ return path;
195
+ }
196
+
197
+ export function writeContinuityAtomic(path, value) {
198
+ writeContinuityBytesAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
199
+ return value;
200
+ }
201
+
202
+ export function continuityReceiptPath(configDir, operationId) {
203
+ requireValue(/^[a-zA-Z0-9-]+$/.test(operationId || ""), "receipt", "operation_id", "Invalid lifecycle operation identity.");
204
+ return join(resolve(configDir), "operation_logs", `continuity-${operationId}.json`);
205
+ }
206
+
207
+ export function readContinuity(path, operationId) {
208
+ const stat = lstatSync(path);
209
+ requireValue(stat.isFile() && !stat.isSymbolicLink(), "receipt", "regular_file", "Continuity receipt must be a regular file.");
210
+ const receipt = JSON.parse(readFileSync(path, "utf8"));
211
+ requireValue(receipt.schema === CONTINUITY_SCHEMA && receipt.operation_id === operationId,
212
+ "receipt", "operation_binding", "Continuity receipt belongs to a different operation.");
213
+ requireValue(Array.isArray(receipt.transitions) && receipt.transitions.length > 0,
214
+ "receipt", "transition_history", "Continuity transition history is missing.");
215
+ requireValue(receipt.baseline_digest === continuityDigest(receipt.baseline),
216
+ "receipt", "baseline_identity", "Retained continuity baseline changed.");
217
+ let previous = null;
218
+ for (const [index, transition] of receipt.transitions.entries()) {
219
+ const next = CONTINUITY_STATES[CONTINUITY_STATES.indexOf(previous) + 1];
220
+ const recovery = ["RECOVERY_REQUIRED", "ROLLED_BACK_HEALTHY"].includes(transition.state);
221
+ requireValue(transition.sequence === index + 1 && Number.isFinite(Date.parse(transition.at)) &&
222
+ (index === 0 ? transition.state === "PREPARED" :
223
+ recovery ? !["COMMITTED", "CLEANED", "ROLLED_BACK_HEALTHY"].includes(previous) :
224
+ CONTINUITY_STATES.includes(previous) && transition.state === next),
225
+ "receipt", "transition_history", "Continuity transition history is inconsistent.");
226
+ previous = transition.state;
227
+ if (transition.state.endsWith("_QUALIFIED")) requireValue(transition.evidence?.passed === true &&
228
+ receipt.qualifications?.[transition.state]?.passed === true, "receipt", "qualification_evidence", "Durable qualification has no positive evidence.");
229
+ }
230
+ requireValue(previous === receipt.state, "receipt", "transition_history", "Continuity state differs from its durable history.");
231
+ if (receipt.state === "ROLLED_BACK_HEALTHY") requireValue(["host", "sessions", "tunnel"].every((key) =>
232
+ receipt.recovery_qualification?.[key]?.passed === true), "receipt", "rollback_evidence", "Healthy rollback requires the full prior-state qualification.");
233
+ return receipt;
234
+ }
235
+
236
+ // The final live Sessions are captured by the running coordinator immediately before
237
+ // its handoff gate closes. Installation may take minutes; the initial live Sessions
238
+ // must never silently stand in for this fresh capture.
239
+ export function assertFreshContinuitySnapshot(baseline, restartId, restartState, evidence = {}) {
240
+ requireValue(restartId && Array.isArray(baseline?.sessions), "preparation", "fresh_roster_identity", "Fresh live Session identity is missing.");
241
+ const ids = baseline.sessions.map((row) => row.session_id);
242
+ const saved = restartState?.runtime_snapshot?.sessions?.filter((row) => row.alive_before_restart || row.ready_before_restart);
243
+ requireValue(restartState?.state === "prepared" && restartState.restart_id === restartId && Array.isArray(saved),
244
+ "preparation", "fresh_snapshot_roster", "Fresh live Sessions differ from the exact prepared Session snapshot; stop before handoff.");
245
+ compareContinuityIdentities(saved.map((row) => ({ ...row,
246
+ provider_thread_id: row.provider_thread_id || row.provider_resume_id || null })),
247
+ baseline.sessions, { ...evidence, stage: "preparation" });
248
+ requireValue(new Set(ids).size === ids.length && baseline.sessions.every((row) => row.session_id &&
249
+ hasCompleteContinuityResumeIdentity(row) && !row.observation_error && Array.isArray(row.history_anchors)),
250
+ "preparation", "fresh_roster_complete", "Fresh Session resume/history identities are incomplete.");
251
+ }
252
+
253
+ export function bindFreshContinuityBaseline(path, operationId, baseline, restartId, restartState) {
254
+ const receipt = readContinuity(path, operationId);
255
+ requireValue((["PREPARED", "PACKAGE_SWITCHED"].includes(receipt.state) ||
256
+ (receipt.state === "RECOVERY_REQUIRED" && !receipt.restart_id)) && !receipt.restart_attempted,
257
+ "preparation", "fresh_roster_boundary", "The fresh live Sessions cannot change after restart begins.");
258
+ requireValue(baseline?.host?.pid === receipt.baseline.host.pid,
259
+ "preparation", "fresh_roster_identity", "Fresh live Sessions must come from the retained running Host.");
260
+ assertFreshContinuitySnapshot(baseline, restartId, restartState, { path, operationId });
261
+ requireValue(baseline.sessions.length <= 256, "preparation", "roster_limit", "Too many live Sessions for bounded continuity evidence.");
262
+ receipt.initial_baseline_digest ||= receipt.baseline_digest;
263
+ receipt.baseline = boundedBaseline(baseline);
264
+ receipt.baseline_digest = continuityDigest(receipt.baseline);
265
+ receipt.restart_id = restartId;
266
+ return writeContinuityAtomic(path, receipt);
267
+ }
268
+
269
+ // Stable releases that predate the explicit route-mode field still expose the
270
+ // selected mode on their loopback health response. The target runner may bind
271
+ // that value exactly once, while the original PID is still the sole listener
272
+ // and before any restart attempt. This does not reinterpret a receipt after
273
+ // handoff and cannot change a receipt that already declared its mode.
274
+ export function bindLegacyContinuityConnectionMode(path, operationId, connectionMode, proof) {
275
+ const receipt = readContinuity(path, operationId);
276
+ requireValue(!Object.hasOwn(receipt.baseline.host, "connection_mode"),
277
+ "preparation", "connection_mode_already_bound", "Lifecycle route mode is already retained.");
278
+ requireValue(receipt.state === "PACKAGE_SWITCHED" && receipt.restart_attempted === false,
279
+ "preparation", "connection_mode_binding_boundary", "Legacy route mode must bind before the first restart attempt.");
280
+ requireValue(["cloud", "direct"].includes(connectionMode),
281
+ "preparation", "connection_mode_invalid", "Legacy Host route mode is invalid.");
282
+ requireValue(proof?.pid === receipt.baseline.host.pid &&
283
+ proof?.device_id === receipt.baseline.host.device_id && proof?.sole_listener === true,
284
+ "preparation", "connection_mode_live_identity", "Legacy route mode does not belong to the retained live Host.");
285
+ receipt.initial_baseline_digest ||= receipt.baseline_digest;
286
+ receipt.baseline.host.connection_mode = connectionMode;
287
+ receipt.baseline_digest = continuityDigest(receipt.baseline);
288
+ receipt.legacy_connection_mode_binding = {
289
+ connection_mode: connectionMode,
290
+ pid: proof.pid,
291
+ device_id: proof.device_id,
292
+ sole_listener: true,
293
+ observed_at: bounded(proof.observed_at),
294
+ };
295
+ return writeContinuityAtomic(path, receipt);
296
+ }
297
+
298
+ export function prepareContinuity({ path, operationId, source, target, baseline, operationRoot = null, transactionKind = "update", authority = {} }) {
299
+ requireValue(!existsSync(path), "receipt", "already_prepared", "Continuity operation is already prepared; recover that operation.");
300
+ requireValue(source?.version && target?.version && source?.runtime_identity_digest &&
301
+ source.runtime_identity_digest === target.runtime_identity_digest,
302
+ "preparation", "source_target_identity", "Source and target runtime identities must be complete and equal.");
303
+ requireValue(baseline?.host && Array.isArray(baseline.sessions) && baseline.captured_at,
304
+ "preparation", "fresh_live_sessions", "A fresh Host and dynamic Session baseline is required.");
305
+ requireValue(baseline.sessions.length <= 256, "preparation", "roster_limit", "Too many live Sessions for bounded continuity evidence.");
306
+ const ids = baseline.sessions.map((row) => row.session_id);
307
+ requireValue(ids.every(Boolean) && new Set(ids).size === ids.length,
308
+ "preparation", "unique_roster", "The fresh live Sessions have missing or duplicate identities.");
309
+ const retainedBaseline = boundedBaseline(baseline);
310
+ return publishDurableBoundary(path, writeContinuityAtomic(path, {
311
+ ...authority, schema: CONTINUITY_SCHEMA, operation_id: operationId, transaction_kind: transactionKind,
312
+ source, target, baseline: retainedBaseline, baseline_digest: continuityDigest(retainedBaseline), operation_root: operationRoot,
313
+ state: "PREPARED", restart_attempted: false, first_failure: null, qualifications: {},
314
+ transitions: [{ sequence: 1, state: "PREPARED", at: new Date().toISOString() }],
315
+ }));
316
+ }
317
+
318
+ export function advanceContinuity(path, operationId, state, evidence = {}) {
319
+ const receipt = readContinuity(path, operationId);
320
+ if (receipt.state === state) return receipt;
321
+ const currentIndex = CONTINUITY_STATES.indexOf(receipt.state);
322
+ const expected = currentIndex >= 0 ? CONTINUITY_STATES[currentIndex + 1] : null;
323
+ requireValue(expected === state, "receipt", "transition_order",
324
+ `Cannot advance lifecycle from ${receipt.state} to ${state}.`);
325
+ if (state === "COMMITTED") {
326
+ for (const required of ["HOST_QUALIFIED", "SESSIONS_QUALIFIED", "TUNNEL_QUALIFIED"]) {
327
+ requireValue(receipt.qualifications[required]?.passed === true,
328
+ "commit", "qualification_required", `${required} must pass before commit.`);
329
+ }
330
+ }
331
+ if (state.endsWith("_QUALIFIED")) {
332
+ requireValue(evidence.passed === true, "qualification", "positive_proof_required", `${state} requires positive proof.`);
333
+ receipt.qualifications[state] = evidence;
334
+ }
335
+ if (state === "COMMITTED") {
336
+ retireHostRestartRestoreState({ configDir: receipt.baseline.host.config_dir, restartId: receipt.restart_id });
337
+ receipt.snapshot_replay_retired_at = new Date().toISOString();
338
+ receipt.user_path_verification = continuityUserPathExpectation(
339
+ evidence.final_qualification?.tunnel || receipt.qualifications.TUNNEL_QUALIFIED
340
+ );
341
+ }
342
+ receipt.state = state;
343
+ receipt.transitions.push({ sequence: receipt.transitions.length + 1, state, at: new Date().toISOString(), evidence });
344
+ return publishDurableBoundary(path, writeContinuityAtomic(path, receipt));
345
+ }
346
+
347
+ export function recordContinuityRestartAttempt(path, operationId) {
348
+ const receipt = readContinuity(path, operationId);
349
+ requireValue(!["COMMITTED", "CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state),
350
+ "restart", "terminal_operation", "A terminal operation cannot restart again.");
351
+ receipt.restart_attempted = true;
352
+ receipt.restart_attempted_at ||= new Date().toISOString();
353
+ return writeContinuityAtomic(path, receipt);
354
+ }
355
+
356
+
357
+ function continuityUserPathExpectation(tunnel) {
358
+ const verified = Boolean(tunnel?.host?.pid && tunnel.authenticated_user_path?.pid === tunnel.host.pid);
359
+ return { status: verified ? "verified" : "pending", origin: tunnel?.origin || null,
360
+ pid: tunnel?.host?.pid || null,
361
+ observed_at: verified ? tunnel.authenticated_user_path.observed_at || null : null };
362
+ }
363
+
364
+ export function recordContinuityRecovery(path, operationId, error, qualification = null) {
365
+ const receipt = readContinuity(path, operationId);
366
+ if (receipt.state === "ROLLED_BACK_HEALTHY") return receipt;
367
+ requireValue(!["COMMITTED", "CLEANED"].includes(receipt.state), "recovery", "already_committed", "A committed operation cannot roll back.");
368
+ const diagnostic = error?.lifecycle_diagnostic;
369
+ receipt.first_failure ||= {
370
+ stage: diagnostic?.stage || "recovery", check: diagnostic?.check || "transaction_failed",
371
+ code: error?.code || "p233_transaction_failed", message: sanitizeLifecycleDiagnostic(error?.message || String(error)),
372
+ restart_attempted: receipt.restart_attempted,
373
+ at: new Date().toISOString(),
374
+ };
375
+ const qualified = qualification?.host?.passed === true && qualification?.sessions?.passed === true && qualification?.tunnel?.passed === true;
376
+ if (qualified) {
377
+ retireHostRestartRestoreState({ configDir: receipt.baseline.host.config_dir, restartId: receipt.restart_id });
378
+ receipt.snapshot_replay_retired_at = new Date().toISOString();
379
+ receipt.user_path_verification = continuityUserPathExpectation(qualification.tunnel);
380
+ }
381
+ receipt.state = qualified ? "ROLLED_BACK_HEALTHY" : "RECOVERY_REQUIRED";
382
+ receipt.recovery_qualification = qualification;
383
+ receipt.transitions.push({ sequence: receipt.transitions.length + 1, state: receipt.state, at: new Date().toISOString() });
384
+ return writeContinuityAtomic(path, receipt);
385
+ }
386
+
387
+ export function assertContinuityFinalizable(path, operationId) {
388
+ const receipt = readContinuity(path, operationId);
389
+ requireValue(["COMMITTED", "CLEANED"].includes(receipt.state), "cleanup", "commit_required",
390
+ "Rollback resources are retained until the lifecycle receipt is COMMITTED.");
391
+ return receipt;
392
+ }
393
+
394
+ export function assertConfirmedUpdateTarget({ currentVersion, targetVersion, expectedCurrentVersion, expectedTargetVersion, source }) {
395
+ if (source !== "terminal") {
396
+ requireValue(expectedCurrentVersion === currentVersion && expectedTargetVersion === targetVersion,
397
+ "confirmation", "stale_target", "The confirmed update changed. Check for updates and confirm the exact version again.");
398
+ }
399
+ const comparison = compareOysterunSemver(targetVersion, currentVersion);
400
+ requireValue(comparison === 1, "confirmation", comparison === -1 ? "downgrade_rejected" : "no_newer_version",
401
+ "No newer Oysterun version is available. Reinstall and downgrade are not update operations.");
402
+ return { current_version: currentVersion, target_version: targetVersion };
403
+ }
404
+
405
+ export function qualifyContinuityHost(baseline, observed, { version, runtimeIdentityDigest, expectNewPid = true, definitionDigest }) {
406
+ requireValue(observed && observed.version === version, "host_qualification", "package_version", "Running Host version differs from the transaction target.");
407
+ requireValue(observed.runtime_identity_digest === runtimeIdentityDigest && /^v?22\./.test(observed.node_version),
408
+ "host_qualification", "node_identity", "Running Host Node 22 identity differs from the retained runtime.");
409
+ for (const key of ["device_id", "config_dir", "browser_root", "host_folder", "port", "stack", "managed_endpoint", "direct_host_url"]) {
410
+ requireValue(Object.hasOwn(baseline, key) && baseline[key] === observed[key],
411
+ "host_qualification", `host_${key}`, `Retained Host ${key} changed.`);
412
+ }
413
+ if (Object.hasOwn(baseline, "connection_mode")) {
414
+ requireValue(baseline.connection_mode === observed.connection_mode,
415
+ "host_qualification", "host_connection_mode", "Retained Host connection_mode changed.");
416
+ }
417
+ requireValue(Number.isInteger(observed.pid) && observed.pid > 0 && (!expectNewPid || observed.pid !== baseline.pid),
418
+ "host_qualification", "replacement_pid", "Expected one new Host process identity.");
419
+ if (baseline.package_root) {
420
+ requireValue(observed.package_root === baseline.package_root && observed.process_cwd === join(baseline.package_root, "host-service"),
421
+ "host_qualification", "running_package_path", "Running Host cwd/package does not match the retained active root.");
422
+ }
423
+ requireValue(observed.service_definition_sha256 === definitionDigest,
424
+ "host_qualification", "service_definition", "Service definition differs from the receipt-bound target.");
425
+ return { passed: true, pid: observed.pid, port: observed.port, version, runtime_identity_digest: runtimeIdentityDigest, service_definition_sha256: definitionDigest };
426
+ }
427
+
428
+ export function qualifyContinuitySessions(expected, observed, restoreResult, { requireRestore = true, path = null, operationId = null } = {}) {
429
+ compareContinuityIdentities(expected, observed, { path, operationId });
430
+ requireValue(Array.isArray(expected) && Array.isArray(observed), "session_qualification", "roster_missing", "Session qualification requires both exact live Sessions.");
431
+ if (requireRestore) {
432
+ qualifyContinuityRestoreResult(expected, restoreResult);
433
+ }
434
+ const observedIds = observed.map((row) => row.session_id);
435
+ requireValue(new Set(observedIds).size === observedIds.length, "session_qualification", "duplicate_runtime", "Duplicate live Session identity.");
436
+ requireValue(observed.length === expected.length, "session_qualification", "roster_changed", "Current live Sessions differ from the freshly retained live Sessions.");
437
+ for (const before of expected) {
438
+ const after = observed.find((row) => row.session_id === before.session_id);
439
+ requireValue(after, "session_qualification", "session_missing", `Session ${before.session_id} is missing.`);
440
+ requireValue(!after.observation_error, "session_qualification", after.observation_error || "session_observation",
441
+ `Session ${before.session_id} runtime/history observation failed.`);
442
+ requireValue(after.alive === true && after.ready === true && hasCompleteContinuityResumeIdentity(before),
443
+ "session_qualification", "runtime_not_ready", `Session ${before.session_id} has no ready resumed provider runtime.`);
444
+ if (requireRestore) {
445
+ const restored = restoreResult.restored.find((row) => row.session_id === before.session_id);
446
+ requireValue(restored?.restored_as_runtime === true && restored.prompt_replay === false,
447
+ "session_qualification", "runtime_restore_unproven", `Session ${before.session_id} was not restored without prompt replay.`);
448
+ }
449
+ requireValue(Array.isArray(before.history_anchors) && Array.isArray(after.history_anchors),
450
+ "session_qualification", "history_missing", `Session ${before.session_id} history proof is missing.`);
451
+ for (const anchor of before.history_anchors) {
452
+ requireValue(after.history_anchors.some((row) => row.event_id === anchor.event_id && row.sha256 === anchor.sha256),
453
+ "session_qualification", "history_anchor_changed", `Session ${before.session_id} lost or changed a committed history anchor.`);
454
+ }
455
+ }
456
+ return { passed: true, expected_count: expected.length, session_ids: expected.map((row) => row.session_id), prompt_replay: false, restore_required: requireRestore };
457
+ }
458
+
459
+ export function qualifyContinuityRestoreResult(expected, restoreResult) {
460
+ requireValue(Array.isArray(expected), "session_qualification", "roster_missing",
461
+ "Session restore qualification requires the exact source Sessions.");
462
+ requireValue(restoreResult && Array.isArray(restoreResult.restored) &&
463
+ Array.isArray(restoreResult.failed) && Array.isArray(restoreResult.skipped),
464
+ "session_qualification", "restore_result_missing", "Consumed state has no complete Session restore result.");
465
+ const expectedIds = new Set(expected.map((row) => row.session_id));
466
+ const unresolvedSkipped = restoreResult.skipped.filter((row) =>
467
+ expectedIds.has(row.session_id) || row.reason !== "not_live_or_ready_before_restart");
468
+ requireValue(restoreResult.failed.length === 0 && unresolvedSkipped.length === 0,
469
+ "session_qualification", "restore_incomplete", "Session restore contains unresolved failed or skipped Sessions.");
470
+ for (const before of expected) {
471
+ const restored = restoreResult.restored.find((row) => row.session_id === before.session_id);
472
+ requireValue(restored?.restored_as_runtime === true && restored.prompt_replay === false,
473
+ "session_qualification", "runtime_restore_unproven", `Session ${before.session_id} was not restored without prompt replay.`);
474
+ }
475
+ return { passed: true, expected_count: expected.length };
476
+ }
477
+
478
+ export function qualifyContinuityTunnel(expected, observed) {
479
+ requireValue(expected?.origin && observed?.origin === expected.origin && observed.device_id === expected.device_id,
480
+ "tunnel_qualification", "route_identity", "Existing authenticated Host route identity changed.");
481
+ requireValue(observed.authenticated === true && observed.http_status === 200 && observed.operation_id === expected.operation_id,
482
+ "tunnel_qualification", "authenticated_path", "The existing authenticated user route has not qualified for this operation.");
483
+ return { passed: true, ...observed };
484
+ }
485
+
486
+ // Internal single-restart marker. This is not a new public Oysterun command.
487
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
488
+ const [action, path, operationId, ...extra] = process.argv.slice(2);
489
+ if (action !== "mark-restart" || !path || !operationId || extra.length) {
490
+ throw new Error("Usage: lifecycle-continuity.mjs mark-restart <absolute-receipt> <operation-id>");
491
+ }
492
+ recordContinuityRestartAttempt(path, operationId);
493
+ }
@@ -0,0 +1,120 @@
1
+ // P226 render-bundle construction, extracted without changing its invariants.
2
+ import { spawnSync } from "node:child_process";
3
+ import { readFileSync, realpathSync } from "node:fs";
4
+ import { join, resolve, dirname } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { createHash } from "node:crypto";
7
+ import { buildNodeNpmChildEnvironment } from "./node-npm-runtime-identity.mjs";
8
+ import { sanitizeLifecycleDiagnostic } from "./lifecycle-diagnostics.mjs";
9
+
10
+ export function getServiceDefinitionIdentity({ stackName, configDir: inputConfigDir, homeDir = homedir() }) {
11
+ const production = stackName === "production";
12
+ const configDir = resolve(inputConfigDir);
13
+ const stackRoot = production ? configDir : dirname(configDir);
14
+ const logDir = production ? join(configDir, "logs") : join(stackRoot, "logs");
15
+ const serviceLabel = production ? "com.oysterun.host" : `com.oysterun.host.${stackName}`;
16
+ const stackKind = ["production", "staging", "test1", "test2", "test3", "test4"].includes(stackName)
17
+ ? "preset"
18
+ : "custom";
19
+ return {
20
+ production,
21
+ configDir,
22
+ stackRoot,
23
+ logDir,
24
+ hostLog: join(logDir, stackKind === "custom" ? `oysterun-host-${stackName}.log` : "oysterun-host.log"),
25
+ serviceLabel,
26
+ stackKind,
27
+ definitionPath: join(homeDir, "Library", "LaunchAgents", `${serviceLabel}.plist`),
28
+ };
29
+ }
30
+
31
+ function resolveFrozenProviderLaunchPath({ toolchain, packageRoot, configPath, environment }) {
32
+ const result = spawnSync(
33
+ toolchain.node_path,
34
+ [
35
+ join(packageRoot, "host-service", "provider-command-detection.mjs"),
36
+ "--print-launch-path",
37
+ "--config-path",
38
+ configPath,
39
+ "--node-bin",
40
+ toolchain.node_path,
41
+ ],
42
+ {
43
+ cwd: packageRoot,
44
+ env: buildNodeNpmChildEnvironment(toolchain, environment),
45
+ encoding: "utf8",
46
+ stdio: "pipe",
47
+ }
48
+ );
49
+ if (result.error) throw result.error;
50
+ if (result.status !== 0) {
51
+ throw new Error(`target provider resolution failed: ${sanitizeLifecycleDiagnostic(result.stderr || result.stdout)}`);
52
+ }
53
+ const pathEntries = String(result.stdout || "").trim().split(":").filter(Boolean);
54
+ if (pathEntries.length === 0) throw new Error("target provider resolution returned an empty PATH");
55
+ return pathEntries;
56
+ }
57
+
58
+ export function buildServiceRenderBundle({
59
+ toolchain,
60
+ stackName,
61
+ registry,
62
+ packageRoot, packageVersion, configDir, configPath, port,
63
+ environment = process.env, homeDir = homedir(),
64
+ }) {
65
+ const identity = getServiceDefinitionIdentity({ stackName, configDir, homeDir });
66
+ const manifestRaw = readFileSync(join(packageRoot, "package.json"), "utf8");
67
+ const manifest = JSON.parse(manifestRaw);
68
+ const versionOwned = manifest.oysterun_service_definition || {};
69
+ return {
70
+ schema_version: 1,
71
+ artifact: {
72
+ package_root: resolve(packageRoot),
73
+ package_realpath: realpathSync(packageRoot),
74
+ package_version: packageVersion,
75
+ integrity_sha256: createHash("sha256").update(manifestRaw).digest("hex"),
76
+ },
77
+ host: {
78
+ launch_domain: `gui/${process.getuid?.() ?? 0}`,
79
+ user: environment.USER || "unknown",
80
+ stack_name: stackName,
81
+ port,
82
+ config_dir: identity.configDir,
83
+ stack_root: identity.stackRoot,
84
+ log_dir: identity.logDir,
85
+ host_log: identity.hostLog,
86
+ service_label: identity.serviceLabel,
87
+ stack_kind: identity.stackKind,
88
+ production: identity.production,
89
+ },
90
+ runtime: {
91
+ node_path: toolchain.node_path,
92
+ node_realpath: toolchain.node_realpath,
93
+ node_version: toolchain.node_version,
94
+ node_abi: toolchain.node_abi,
95
+ npm_cli_path: toolchain.npm_cli_path,
96
+ npm_cli_realpath: toolchain.npm_cli_realpath,
97
+ npm_version: toolchain.npm_version,
98
+ platform: toolchain.platform,
99
+ architecture: toolchain.architecture,
100
+ toolchain_identity_digest: toolchain.toolchain_identity_digest,
101
+ },
102
+ update: {
103
+ registry_authority: registry.source,
104
+ registry_url: registry.override_active ? registry.url : null,
105
+ state_path: environment.OYSTERUN_UPDATE_REGISTRY_STATE_PATH || null,
106
+ },
107
+ provider_resolution: {
108
+ path_entries: resolveFrozenProviderLaunchPath({ toolchain, packageRoot, configPath, environment }),
109
+ environment: {},
110
+ },
111
+ version_owned: {
112
+ environment: versionOwned.environment || {},
113
+ top_level: versionOwned.top_level || {},
114
+ run_at_load: versionOwned.run_at_load ?? true,
115
+ keep_alive: versionOwned.keep_alive ?? true,
116
+ throttle_interval: versionOwned.throttle_interval ?? 10,
117
+ },
118
+ };
119
+ }
120
+
@@ -0,0 +1,34 @@
1
+ import { existsSync, lstatSync, readFileSync } from "node:fs";
2
+ import { readContinuity, writeContinuityAtomic } from "./lifecycle-continuity.mjs";
3
+
4
+ // Ordinary Dashboard authentication supplements a completed transaction. Keep
5
+ // this evidence separate so it cannot race a runner's receipt/projection write,
6
+ // reacquire mutation authority, or compare today's roster with an old snapshot.
7
+ export function readContinuityUserPath(path, receipt) {
8
+ const expected = receipt.user_path_verification;
9
+ if (!expected || expected.status === "verified") return expected || null;
10
+ const evidencePath = `${path}.user-path`;
11
+ try {
12
+ if (!existsSync(evidencePath)) return expected;
13
+ const stat = lstatSync(evidencePath);
14
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) return expected;
15
+ const proof = JSON.parse(readFileSync(evidencePath, "utf8"));
16
+ if (proof.operation_id === receipt.operation_id && proof.baseline_digest === receipt.baseline_digest &&
17
+ proof.origin === expected.origin && proof.pid === expected.pid &&
18
+ proof.status === "verified" && Number.isFinite(Date.parse(proof.observed_at))) return proof;
19
+ } catch { /* Missing supplementary evidence does not reopen a transaction. */ }
20
+ return expected;
21
+ }
22
+
23
+ export function recordContinuityUserPath(path, operationId, { origin, pid, observed_at }) {
24
+ const receipt = readContinuity(path, operationId);
25
+ const expected = readContinuityUserPath(path, receipt);
26
+ if (!["COMMITTED", "CLEANED", "ROLLED_BACK_HEALTHY"].includes(receipt.state) ||
27
+ !receipt.snapshot_replay_retired_at || expected?.status !== "pending" ||
28
+ expected.origin !== origin || expected.pid !== pid || !Number.isFinite(Date.parse(observed_at))) return false;
29
+ writeContinuityAtomic(`${path}.user-path`, {
30
+ operation_id: operationId, baseline_digest: receipt.baseline_digest,
31
+ status: "verified", origin, pid, observed_at,
32
+ });
33
+ return true;
34
+ }