@maudecode/talaria-web 1.17.2 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (623) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.d.ts +1 -1
  3. package/dist/api/auth-raw.js +20 -20
  4. package/dist/api/auth-raw.js.map +1 -1
  5. package/dist/api/automation-raw.d.ts +7 -1
  6. package/dist/api/automation-raw.js +74 -27
  7. package/dist/api/automation-raw.js.map +1 -1
  8. package/dist/api/automation-router.d.ts +128 -23
  9. package/dist/api/automation-router.js +29 -13
  10. package/dist/api/automation-router.js.map +1 -1
  11. package/dist/api/chat-router.d.ts +1845 -40
  12. package/dist/api/chat-router.js +196 -114
  13. package/dist/api/chat-router.js.map +1 -1
  14. package/dist/api/git-router.d.ts +37 -3
  15. package/dist/api/git-router.js +47 -48
  16. package/dist/api/git-router.js.map +1 -1
  17. package/dist/api/raw-routes.d.ts +9 -1
  18. package/dist/api/raw-routes.js +286 -73
  19. package/dist/api/raw-routes.js.map +1 -1
  20. package/dist/api/router.d.ts +16 -6
  21. package/dist/api/router.js +28 -26
  22. package/dist/api/router.js.map +1 -1
  23. package/dist/api/session-visibility.d.ts +4 -4
  24. package/dist/api/session-visibility.js +26 -16
  25. package/dist/api/session-visibility.js.map +1 -1
  26. package/dist/api/sessions-router.d.ts +3312 -131
  27. package/dist/api/sessions-router.js +287 -157
  28. package/dist/api/sessions-router.js.map +1 -1
  29. package/dist/api/settings-router.d.ts +457 -8
  30. package/dist/api/settings-router.js +54 -33
  31. package/dist/api/settings-router.js.map +1 -1
  32. package/dist/api/sse-routes.d.ts +7 -2
  33. package/dist/api/sse-routes.js +109 -62
  34. package/dist/api/sse-routes.js.map +1 -1
  35. package/dist/api/tools-raw.d.ts +5 -0
  36. package/dist/api/tools-raw.js +63 -55
  37. package/dist/api/tools-raw.js.map +1 -1
  38. package/dist/api/tools-router.d.ts +998 -85
  39. package/dist/api/tools-router.js +447 -61
  40. package/dist/api/tools-router.js.map +1 -1
  41. package/dist/api/transcribe-stream.d.ts +16 -0
  42. package/dist/api/transcribe-stream.js +172 -0
  43. package/dist/api/transcribe-stream.js.map +1 -0
  44. package/dist/app.d.ts +4 -1
  45. package/dist/app.js +98 -59
  46. package/dist/app.js.map +1 -1
  47. package/dist/auth/gate.js +15 -11
  48. package/dist/auth/gate.js.map +1 -1
  49. package/dist/auth/oidc.d.ts +5 -2
  50. package/dist/auth/oidc.js +8 -7
  51. package/dist/auth/oidc.js.map +1 -1
  52. package/dist/auth/passkeys.d.ts +15 -14
  53. package/dist/auth/passkeys.js +89 -91
  54. package/dist/auth/passkeys.js.map +1 -1
  55. package/dist/auth/store.d.ts +17 -7
  56. package/dist/auth/store.js +93 -52
  57. package/dist/auth/store.js.map +1 -1
  58. package/dist/bin/talaria-web.js +12 -81
  59. package/dist/bin/talaria-web.js.map +1 -1
  60. package/dist/cli/ctl.js +2 -1
  61. package/dist/cli/ctl.js.map +1 -1
  62. package/dist/cli/dotenv.d.ts +5 -0
  63. package/dist/cli/dotenv.js +15 -2
  64. package/dist/cli/dotenv.js.map +1 -1
  65. package/dist/cli/launcher.js +5 -5
  66. package/dist/cli/launcher.js.map +1 -1
  67. package/dist/cli/main.d.ts +1 -0
  68. package/dist/cli/main.js +85 -0
  69. package/dist/cli/main.js.map +1 -0
  70. package/dist/cli/supervise.d.ts +2 -0
  71. package/dist/cli/supervise.js +67 -4
  72. package/dist/cli/supervise.js.map +1 -1
  73. package/dist/config/agent-config.d.ts +34 -11
  74. package/dist/config/agent-config.js +116 -60
  75. package/dist/config/agent-config.js.map +1 -1
  76. package/dist/config.d.ts +4 -0
  77. package/dist/config.js +19 -6
  78. package/dist/config.js.map +1 -1
  79. package/dist/fs/atomic.d.ts +1 -0
  80. package/dist/fs/atomic.js +4 -1
  81. package/dist/fs/atomic.js.map +1 -1
  82. package/dist/fs/stat.d.ts +1 -0
  83. package/dist/fs/stat.js +11 -0
  84. package/dist/fs/stat.js.map +1 -0
  85. package/dist/http/context.d.ts +31 -12
  86. package/dist/http/context.js +60 -24
  87. package/dist/http/context.js.map +1 -1
  88. package/dist/http/csp.d.ts +2 -1
  89. package/dist/http/csp.js +3 -2
  90. package/dist/http/csp.js.map +1 -1
  91. package/dist/onboarding.d.ts +2 -2
  92. package/dist/onboarding.js +10 -8
  93. package/dist/onboarding.js.map +1 -1
  94. package/dist/profiles/profiles.d.ts +20 -4
  95. package/dist/profiles/profiles.js +115 -30
  96. package/dist/profiles/profiles.js.map +1 -1
  97. package/dist/projects.d.ts +15 -6
  98. package/dist/projects.js +55 -47
  99. package/dist/projects.js.map +1 -1
  100. package/dist/providers/catalog.d.ts +84 -14
  101. package/dist/providers/catalog.js +474 -151
  102. package/dist/providers/catalog.js.map +1 -1
  103. package/dist/providers/env-file.d.ts +10 -3
  104. package/dist/providers/env-file.js +24 -75
  105. package/dist/providers/env-file.js.map +1 -1
  106. package/dist/providers/runtime-env.d.ts +4 -2
  107. package/dist/providers/runtime-env.js +9 -6
  108. package/dist/providers/runtime-env.js.map +1 -1
  109. package/dist/providers/tables.d.ts +3 -1
  110. package/dist/providers/tables.js +4 -2
  111. package/dist/providers/tables.js.map +1 -1
  112. package/dist/raster-image.d.ts +7 -0
  113. package/dist/raster-image.js +267 -0
  114. package/dist/raster-image.js.map +1 -0
  115. package/dist/redact.js +342 -46
  116. package/dist/redact.js.map +1 -1
  117. package/dist/release.d.ts +7 -0
  118. package/dist/release.js +14 -0
  119. package/dist/release.js.map +1 -1
  120. package/dist/runtime.d.ts +5 -1
  121. package/dist/runtime.js +305 -96
  122. package/dist/runtime.js.map +1 -1
  123. package/dist/server.js +15 -0
  124. package/dist/server.js.map +1 -1
  125. package/dist/sessions/anchor.d.ts +9 -3
  126. package/dist/sessions/anchor.js +28 -36
  127. package/dist/sessions/anchor.js.map +1 -1
  128. package/dist/sessions/background-events.d.ts +2 -0
  129. package/dist/sessions/background-events.js +10 -0
  130. package/dist/sessions/background-events.js.map +1 -0
  131. package/dist/sessions/background-tasks.d.ts +26 -27
  132. package/dist/sessions/background-tasks.js +89 -97
  133. package/dist/sessions/background-tasks.js.map +1 -1
  134. package/dist/sessions/background-updates.d.ts +15 -8
  135. package/dist/sessions/background-updates.js +101 -25
  136. package/dist/sessions/background-updates.js.map +1 -1
  137. package/dist/sessions/cli-sessions.d.ts +27 -6
  138. package/dist/sessions/cli-sessions.js +152 -42
  139. package/dist/sessions/cli-sessions.js.map +1 -1
  140. package/dist/sessions/completions.d.ts +77 -9
  141. package/dist/sessions/completions.js +482 -45
  142. package/dist/sessions/completions.js.map +1 -1
  143. package/dist/sessions/compress.d.ts +8 -0
  144. package/dist/sessions/compress.js +2 -2
  145. package/dist/sessions/compress.js.map +1 -1
  146. package/dist/sessions/drafts.d.ts +14 -10
  147. package/dist/sessions/drafts.js +38 -43
  148. package/dist/sessions/drafts.js.map +1 -1
  149. package/dist/sessions/export.d.ts +5 -0
  150. package/dist/sessions/export.js +30 -0
  151. package/dist/sessions/export.js.map +1 -1
  152. package/dist/sessions/gateway-sessions.d.ts +46 -0
  153. package/dist/sessions/gateway-sessions.js +378 -0
  154. package/dist/sessions/gateway-sessions.js.map +1 -0
  155. package/dist/sessions/gateway-watcher.d.ts +7 -3
  156. package/dist/sessions/gateway-watcher.js +31 -19
  157. package/dist/sessions/gateway-watcher.js.map +1 -1
  158. package/dist/sessions/goals.d.ts +31 -0
  159. package/dist/sessions/goals.js +75 -0
  160. package/dist/sessions/goals.js.map +1 -0
  161. package/dist/sessions/handoff.d.ts +32 -0
  162. package/dist/sessions/handoff.js +114 -0
  163. package/dist/sessions/handoff.js.map +1 -0
  164. package/dist/sessions/journal.d.ts +5 -79
  165. package/dist/sessions/journal.js +15 -606
  166. package/dist/sessions/journal.js.map +1 -1
  167. package/dist/sessions/list.d.ts +31 -16
  168. package/dist/sessions/list.js +139 -129
  169. package/dist/sessions/list.js.map +1 -1
  170. package/dist/sessions/merge.d.ts +69 -13
  171. package/dist/sessions/merge.js +265 -64
  172. package/dist/sessions/merge.js.map +1 -1
  173. package/dist/sessions/notices.d.ts +22 -0
  174. package/dist/sessions/notices.js +72 -0
  175. package/dist/sessions/notices.js.map +1 -0
  176. package/dist/sessions/pending.d.ts +10 -4
  177. package/dist/sessions/pending.js +83 -7
  178. package/dist/sessions/pending.js.map +1 -1
  179. package/dist/sessions/prefix-index.d.ts +82 -0
  180. package/dist/sessions/prefix-index.js +232 -0
  181. package/dist/sessions/prefix-index.js.map +1 -0
  182. package/dist/sessions/recovery.d.ts +19 -0
  183. package/dist/sessions/recovery.js +322 -0
  184. package/dist/sessions/recovery.js.map +1 -0
  185. package/dist/sessions/relay.d.ts +38 -19
  186. package/dist/sessions/relay.js +302 -79
  187. package/dist/sessions/relay.js.map +1 -1
  188. package/dist/sessions/service.d.ts +260 -55
  189. package/dist/sessions/service.js +1448 -462
  190. package/dist/sessions/service.js.map +1 -1
  191. package/dist/sessions/session.d.ts +24 -2
  192. package/dist/sessions/session.js +69 -7
  193. package/dist/sessions/session.js.map +1 -1
  194. package/dist/sessions/shares.d.ts +7 -6
  195. package/dist/sessions/shares.js +22 -45
  196. package/dist/sessions/shares.js.map +1 -1
  197. package/dist/sessions/source-kind.js +1 -0
  198. package/dist/sessions/source-kind.js.map +1 -1
  199. package/dist/sessions/state-db-identity.d.ts +18 -0
  200. package/dist/sessions/state-db-identity.js +67 -0
  201. package/dist/sessions/state-db-identity.js.map +1 -0
  202. package/dist/sessions/state-db-worker.d.ts +1 -0
  203. package/dist/sessions/state-db-worker.js +35 -0
  204. package/dist/sessions/state-db-worker.js.map +1 -0
  205. package/dist/sessions/state-db.d.ts +113 -7
  206. package/dist/sessions/state-db.js +861 -44
  207. package/dist/sessions/state-db.js.map +1 -1
  208. package/dist/sessions/storage/backend.d.ts +162 -0
  209. package/dist/sessions/storage/backend.js +19 -0
  210. package/dist/sessions/storage/backend.js.map +1 -0
  211. package/dist/sessions/storage/conformance.d.ts +20 -0
  212. package/dist/sessions/storage/conformance.js +237 -0
  213. package/dist/sessions/storage/conformance.js.map +1 -0
  214. package/dist/sessions/storage/document.d.ts +16 -0
  215. package/dist/sessions/storage/document.js +44 -0
  216. package/dist/sessions/storage/document.js.map +1 -0
  217. package/dist/sessions/storage/json-documents.d.ts +23 -0
  218. package/dist/sessions/storage/json-documents.js +233 -0
  219. package/dist/sessions/storage/json-documents.js.map +1 -0
  220. package/dist/sessions/storage/json-files.d.ts +12 -0
  221. package/dist/sessions/storage/json-files.js +124 -0
  222. package/dist/sessions/storage/json-files.js.map +1 -0
  223. package/dist/sessions/storage/json-installation.d.ts +3 -0
  224. package/dist/sessions/storage/json-installation.js +31 -0
  225. package/dist/sessions/storage/json-installation.js.map +1 -0
  226. package/dist/sessions/storage/json-journal.d.ts +114 -0
  227. package/dist/sessions/storage/json-journal.js +686 -0
  228. package/dist/sessions/storage/json-journal.js.map +1 -0
  229. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  230. package/dist/sessions/storage/json-recovery.js +195 -0
  231. package/dist/sessions/storage/json-recovery.js.map +1 -0
  232. package/dist/sessions/storage/json.d.ts +68 -0
  233. package/dist/sessions/storage/json.js +258 -0
  234. package/dist/sessions/storage/json.js.map +1 -0
  235. package/dist/sessions/storage/row.d.ts +6 -0
  236. package/dist/sessions/storage/row.js +49 -0
  237. package/dist/sessions/storage/row.js.map +1 -0
  238. package/dist/sessions/storage/search-text.d.ts +4 -0
  239. package/dist/sessions/storage/search-text.js +16 -0
  240. package/dist/sessions/storage/search-text.js.map +1 -0
  241. package/dist/sessions/store.d.ts +65 -68
  242. package/dist/sessions/store.js +305 -602
  243. package/dist/sessions/store.js.map +1 -1
  244. package/dist/sessions/streams.d.ts +4 -0
  245. package/dist/sessions/streams.js +9 -1
  246. package/dist/sessions/streams.js.map +1 -1
  247. package/dist/sessions/titles.d.ts +2 -0
  248. package/dist/sessions/titles.js +5 -1
  249. package/dist/sessions/titles.js.map +1 -1
  250. package/dist/sessions/todo.d.ts +5 -0
  251. package/dist/sessions/todo.js +28 -21
  252. package/dist/sessions/todo.js.map +1 -1
  253. package/dist/sessions/tool-display.d.ts +5 -4
  254. package/dist/sessions/tool-display.js +30 -50
  255. package/dist/sessions/tool-display.js.map +1 -1
  256. package/dist/sessions/turn-context.d.ts +11 -2
  257. package/dist/sessions/turn-context.js +18 -11
  258. package/dist/sessions/turn-context.js.map +1 -1
  259. package/dist/sessions/turn.d.ts +122 -17
  260. package/dist/sessions/turn.js +939 -358
  261. package/dist/sessions/turn.js.map +1 -1
  262. package/dist/sessions/wakeup-pause.d.ts +54 -0
  263. package/dist/sessions/wakeup-pause.js +163 -0
  264. package/dist/sessions/wakeup-pause.js.map +1 -0
  265. package/dist/sessions/window.js +13 -12
  266. package/dist/sessions/window.js.map +1 -1
  267. package/dist/settings.d.ts +19 -7
  268. package/dist/settings.js +178 -184
  269. package/dist/settings.js.map +1 -1
  270. package/dist/sidecar/client.d.ts +17 -3
  271. package/dist/sidecar/client.js +78 -8
  272. package/dist/sidecar/client.js.map +1 -1
  273. package/dist/sidecar/discover.d.ts +2 -1
  274. package/dist/sidecar/discover.js +93 -10
  275. package/dist/sidecar/discover.js.map +1 -1
  276. package/dist/sidecar/env.d.ts +4 -0
  277. package/dist/sidecar/env.js +3 -0
  278. package/dist/sidecar/env.js.map +1 -0
  279. package/dist/sidecar/fake.d.ts +2 -2
  280. package/dist/sidecar/fake.js +19 -2
  281. package/dist/sidecar/fake.js.map +1 -1
  282. package/dist/startup.d.ts +13 -5
  283. package/dist/startup.js +48 -0
  284. package/dist/startup.js.map +1 -1
  285. package/dist/test/env-file.d.ts +5 -0
  286. package/dist/test/env-file.js +88 -0
  287. package/dist/test/env-file.js.map +1 -0
  288. package/dist/test/harness.d.ts +1 -0
  289. package/dist/test/harness.js +1 -1
  290. package/dist/test/harness.js.map +1 -1
  291. package/dist/test/passkeys.d.ts +22 -0
  292. package/dist/test/passkeys.js +62 -0
  293. package/dist/test/passkeys.js.map +1 -0
  294. package/dist/test/storage.d.ts +15 -0
  295. package/dist/test/storage.js +26 -0
  296. package/dist/test/storage.js.map +1 -0
  297. package/dist/test/ts-worker-hooks.d.ts +1 -0
  298. package/dist/test/ts-worker-hooks.js +24 -0
  299. package/dist/test/ts-worker-hooks.js.map +1 -0
  300. package/dist/text/diff.d.ts +9 -14
  301. package/dist/text/diff.js +74 -108
  302. package/dist/text/diff.js.map +1 -1
  303. package/dist/tools/commands.d.ts +6 -1
  304. package/dist/tools/commands.js +8 -2
  305. package/dist/tools/commands.js.map +1 -1
  306. package/dist/tools/connectors.d.ts +33 -0
  307. package/dist/tools/connectors.js +121 -0
  308. package/dist/tools/connectors.js.map +1 -0
  309. package/dist/tools/crons.d.ts +38 -1
  310. package/dist/tools/crons.js +122 -18
  311. package/dist/tools/crons.js.map +1 -1
  312. package/dist/tools/dashboard-plugins.d.ts +53 -0
  313. package/dist/tools/dashboard-plugins.js +188 -0
  314. package/dist/tools/dashboard-plugins.js.map +1 -0
  315. package/dist/tools/extensions.d.ts +18 -5
  316. package/dist/tools/extensions.js +344 -266
  317. package/dist/tools/extensions.js.map +1 -1
  318. package/dist/tools/gateway-restart.d.ts +62 -0
  319. package/dist/tools/gateway-restart.js +142 -0
  320. package/dist/tools/gateway-restart.js.map +1 -0
  321. package/dist/tools/health.d.ts +44 -0
  322. package/dist/tools/health.js +201 -46
  323. package/dist/tools/health.js.map +1 -1
  324. package/dist/tools/hygiene.d.ts +4 -3
  325. package/dist/tools/hygiene.js +23 -9
  326. package/dist/tools/hygiene.js.map +1 -1
  327. package/dist/tools/kanban.d.ts +4 -2
  328. package/dist/tools/kanban.js +28 -19
  329. package/dist/tools/kanban.js.map +1 -1
  330. package/dist/tools/mcp.d.ts +13 -0
  331. package/dist/tools/mcp.js +88 -0
  332. package/dist/tools/mcp.js.map +1 -1
  333. package/dist/tools/memory-masks.d.ts +5 -0
  334. package/dist/tools/memory-masks.js +256 -0
  335. package/dist/tools/memory-masks.js.map +1 -0
  336. package/dist/tools/memory.d.ts +29 -3
  337. package/dist/tools/memory.js +69 -27
  338. package/dist/tools/memory.js.map +1 -1
  339. package/dist/tools/plugin-settings.d.ts +15 -0
  340. package/dist/tools/plugin-settings.js +90 -0
  341. package/dist/tools/plugin-settings.js.map +1 -0
  342. package/dist/tools/project-os.d.ts +11 -0
  343. package/dist/tools/project-os.js +267 -0
  344. package/dist/tools/project-os.js.map +1 -0
  345. package/dist/tools/skills.d.ts +14 -5
  346. package/dist/tools/skills.js +49 -16
  347. package/dist/tools/skills.js.map +1 -1
  348. package/dist/tools/update-notifications.d.ts +34 -15
  349. package/dist/tools/update-notifications.js +141 -57
  350. package/dist/tools/update-notifications.js.map +1 -1
  351. package/dist/tools/update-rollback.d.ts +11 -0
  352. package/dist/tools/update-rollback.js +28 -0
  353. package/dist/tools/update-rollback.js.map +1 -0
  354. package/dist/tools/updates.d.ts +110 -32
  355. package/dist/tools/updates.js +1112 -294
  356. package/dist/tools/updates.js.map +1 -1
  357. package/dist/tools/wiki.d.ts +68 -0
  358. package/dist/tools/wiki.js +413 -0
  359. package/dist/tools/wiki.js.map +1 -0
  360. package/dist/util.d.ts +2 -0
  361. package/dist/util.js +5 -0
  362. package/dist/util.js.map +1 -1
  363. package/dist/workspace/escape.d.ts +61 -0
  364. package/dist/workspace/escape.js +149 -0
  365. package/dist/workspace/escape.js.map +1 -0
  366. package/dist/workspace/extract.d.ts +22 -0
  367. package/dist/workspace/extract.js +308 -0
  368. package/dist/workspace/extract.js.map +1 -0
  369. package/dist/workspace/fs.d.ts +12 -0
  370. package/dist/workspace/fs.js +92 -23
  371. package/dist/workspace/fs.js.map +1 -1
  372. package/dist/workspace/git.d.ts +34 -1
  373. package/dist/workspace/git.js +328 -35
  374. package/dist/workspace/git.js.map +1 -1
  375. package/dist/workspace/media.d.ts +16 -13
  376. package/dist/workspace/media.js +43 -61
  377. package/dist/workspace/media.js.map +1 -1
  378. package/dist/workspace/rollback.d.ts +36 -22
  379. package/dist/workspace/rollback.js +178 -272
  380. package/dist/workspace/rollback.js.map +1 -1
  381. package/dist/workspace/unzip.js +4 -1
  382. package/dist/workspace/unzip.js.map +1 -1
  383. package/dist/workspace/upload.d.ts +7 -4
  384. package/dist/workspace/upload.js +5 -2
  385. package/dist/workspace/upload.js.map +1 -1
  386. package/dist/workspace/workspaces.d.ts +15 -19
  387. package/dist/workspace/workspaces.js +55 -127
  388. package/dist/workspace/workspaces.js.map +1 -1
  389. package/dist/workspace/worktrees.d.ts +2 -1
  390. package/dist/workspace/worktrees.js +179 -42
  391. package/dist/workspace/worktrees.js.map +1 -1
  392. package/package.json +7 -3
  393. package/sidecar/agent_dependency.json +4 -4
  394. package/sidecar/agent_support.json +26 -0
  395. package/sidecar/scripts/record-fixtures.py +12 -3
  396. package/sidecar/scripts/replay_sidecar.py +32 -0
  397. package/sidecar/scripts/suite_runner.py +302 -0
  398. package/sidecar/scripts/test.sh +12 -4
  399. package/sidecar/talaria_sidecar/__init__.py +6 -1
  400. package/sidecar/talaria_sidecar/__main__.py +4 -2
  401. package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
  402. package/sidecar/talaria_sidecar/agent_services.py +1053 -0
  403. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  404. package/sidecar/talaria_sidecar/home.py +56 -3
  405. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  406. package/sidecar/talaria_sidecar/methods/aux.py +166 -24
  407. package/sidecar/talaria_sidecar/methods/chat.py +852 -135
  408. package/sidecar/talaria_sidecar/methods/checkpoints.py +89 -0
  409. package/sidecar/talaria_sidecar/methods/commands.py +294 -4
  410. package/sidecar/talaria_sidecar/methods/config.py +123 -4
  411. package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
  412. package/sidecar/talaria_sidecar/methods/cron.py +395 -36
  413. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  414. package/sidecar/talaria_sidecar/methods/gateway.py +212 -55
  415. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
  416. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  417. package/sidecar/talaria_sidecar/methods/kanban.py +108 -34
  418. package/sidecar/talaria_sidecar/methods/mcp.py +1 -1
  419. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  420. package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
  421. package/sidecar/talaria_sidecar/methods/process.py +218 -15
  422. package/sidecar/talaria_sidecar/methods/profiles.py +121 -13
  423. package/sidecar/talaria_sidecar/methods/providers.py +217 -28
  424. package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
  425. package/sidecar/talaria_sidecar/methods/skills.py +67 -4
  426. package/sidecar/talaria_sidecar/methods/state_db.py +316 -75
  427. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  428. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  429. package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
  430. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  431. package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
  432. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  433. package/sidecar/talaria_sidecar/rpc.py +45 -4
  434. package/sidecar/talaria_sidecar/runtime.py +98 -13
  435. package/sidecar/talaria_sidecar/speed.py +35 -0
  436. package/static/dist/FILES.txt +126 -126
  437. package/static/dist/assets/AppShell-cey33v9s.js +8 -0
  438. package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
  439. package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
  440. package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
  441. package/static/dist/assets/Field-j8uqoigz.js +9 -0
  442. package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
  443. package/static/dist/assets/Markdown-lahuichm.js +598 -0
  444. package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
  445. package/static/dist/assets/Select-gqatm2e0.js +1 -0
  446. package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
  447. package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
  448. package/static/dist/assets/States-e7dkgtxf.js +1 -0
  449. package/static/dist/assets/Toaster-bfqqy786.js +1 -0
  450. package/static/dist/assets/_app-n1n6pfx1.js +1 -0
  451. package/static/dist/assets/{_app.ext._extensionId-opyh9y0v.js → _app.ext._extensionId-buxjxav3.js} +1 -1
  452. package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
  453. package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
  454. package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
  455. package/static/dist/assets/_app.logs-dm873z90.js +1 -0
  456. package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
  457. package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
  458. package/static/dist/assets/{_app.session._sessionId-bfd4h3id.js → _app.session._sessionId-g2vzghvc.js} +1 -1
  459. package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
  460. package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
  461. package/static/dist/assets/_app.skills-cii852yq.js +1 -0
  462. package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
  463. package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
  464. package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
  465. package/static/dist/assets/{abnfDiagram-VCTEODGH-b2x1lqot.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
  466. package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
  467. package/static/dist/assets/{architectureDiagram-5GKGNRK7-h38o5l7b.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
  468. package/static/dist/assets/archive-b1j004vn.js +1 -0
  469. package/static/dist/assets/back-g74gkozu.js +1 -0
  470. package/static/dist/assets/{blockDiagram-I7D4REHJ-bqquxv2u.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
  471. package/static/dist/assets/{c4Diagram-7LVT6UL2-f3fb8w6z.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
  472. package/static/dist/assets/channel-es95o4y8.js +1 -0
  473. package/static/dist/assets/{chunk-4HAMMTFA-bzfyyzp3.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
  474. package/static/dist/assets/{chunk-75Z2AOVW-ly99rfx5.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
  475. package/static/dist/assets/{chunk-DU6HZSFF-bg6ecfqf.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
  476. package/static/dist/assets/{chunk-F27PBJKO-e8k7mmqw.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
  477. package/static/dist/assets/{chunk-GMAD6QVW-cmalnoaj.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
  478. package/static/dist/assets/{chunk-GVQU2GXP-gmif5o01.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
  479. package/static/dist/assets/{chunk-IMKFNOWR-f1nwwh6u.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
  480. package/static/dist/assets/{chunk-L3NEJ4N5-oktfh5v7.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
  481. package/static/dist/assets/{chunk-OSK3NFVY-b1sdx96z.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
  482. package/static/dist/assets/{chunk-P2QGCYS3-fhu4nzxt.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
  483. package/static/dist/assets/{chunk-POPQ4Y6H-em31rnef.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
  484. package/static/dist/assets/{chunk-PWAF6VOD-bxhhyftw.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
  485. package/static/dist/assets/{chunk-SHT3W25Y-fmrvwcav.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
  486. package/static/dist/assets/{chunk-SVP7TREG-lymukl2j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
  487. package/static/dist/assets/{chunk-TICWLB2K-lmza6k23.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
  488. package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
  489. package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
  490. package/static/dist/assets/copied-bli9tbcx.js +1 -0
  491. package/static/dist/assets/{copy-fw5b1pc6.js → copy-jamtd1pf.js} +1 -1
  492. package/static/dist/assets/{cynefin-OW5HDTMX-czlhbyhs.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
  493. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-gq7mvswb.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
  494. package/static/dist/assets/{dagre-GXQ25YYZ-cqdbf7dr.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
  495. package/static/dist/assets/{diagram-S7CK7UJ4-o6arxa58.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
  496. package/static/dist/assets/{diagram-UQ7AKVKN-nhnd68tz.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
  497. package/static/dist/assets/{diagram-VSXAHHWV-dk2sclzn.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
  498. package/static/dist/assets/{diagram-VX7I27RA-n3k5nyx3.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
  499. package/static/dist/assets/{diagram-Z3DM3KII-m6q9cwdh.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
  500. package/static/dist/assets/{ebnfDiagram-PWID7BFC-lsa5641p.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
  501. package/static/dist/assets/{erDiagram-RLTQ6QDP-fo2ckbe6.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
  502. package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
  503. package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
  504. package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
  505. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-nzqz6x9g.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
  506. package/static/dist/assets/{gitGraph-4MIJSDKK-gukvcxe6.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
  507. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-lqhdp8f0.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
  508. package/static/dist/assets/{highlighted-body-KPVGNVTW-igw6cp39.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
  509. package/static/dist/assets/host-hzm8n85n.js +1 -0
  510. package/static/dist/assets/index-drdcaqeq.css +2 -0
  511. package/static/dist/assets/index-jzm844he.js +11 -0
  512. package/static/dist/assets/{info-A6RAGUB7-gvu7a93l.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
  513. package/static/dist/assets/{infoDiagram-27XIBGKW-m8t8g6w1.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
  514. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-l07vkbgj.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
  515. package/static/dist/assets/{journeyDiagram-3NMN7TZE-hybkgj8l.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
  516. package/static/dist/assets/{kanban-definition-UXKFOSKX-by13uvtn.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
  517. package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
  518. package/static/dist/assets/{line-osxq6fq7.js → line-h0ojzsvs.js} +1 -1
  519. package/static/dist/assets/link-k01ryaf9.js +1 -0
  520. package/static/dist/assets/login-bsae49ew.js +1 -0
  521. package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
  522. package/static/dist/assets/{mermaid-parser.core-i91oxmou.js → mermaid-parser.core-bnre27u8.js} +3 -3
  523. package/static/dist/assets/{mindmap-definition-YA3MSWOX-m82mx8ww.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
  524. package/static/dist/assets/onboarding-c65tysya.js +1 -0
  525. package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
  526. package/static/dist/assets/{packet-AYTQ26CC-nbtgge2o.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
  527. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  528. package/static/dist/assets/{pegDiagram-XKGWAZYB-f1zxzga1.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
  529. package/static/dist/assets/{pie-WAS4IAKB-of8vuhxs.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
  530. package/static/dist/assets/{pieDiagram-E7YTZNPT-o7y8ftio.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
  531. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-iqiwia1t.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
  532. package/static/dist/assets/{radar-RG4KPBEZ-delnsy2v.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
  533. package/static/dist/assets/{railroad-74A4TZTK-nspnyq9u.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
  534. package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
  535. package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
  536. package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
  537. package/static/dist/assets/{railroadDiagram-O6MQD6OU-z41tqx5z.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
  538. package/static/dist/assets/{refresh-cw-nt1dtsx4.js → refresh-cw-mqbkv2a3.js} +1 -1
  539. package/static/dist/assets/refresh-l29ljp76.js +1 -0
  540. package/static/dist/assets/{requirementDiagram-BXWQKSXE-bdvif194.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
  541. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-h73e0rol.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
  542. package/static/dist/assets/saved-ndd4t2xb.js +1 -0
  543. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-iv95mb1z.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
  544. package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
  545. package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
  546. package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
  547. package/static/dist/assets/{stateDiagram-D77RDMKH-k417ixmf.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
  548. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
  549. package/static/dist/assets/{swimlanes-42K2YHIH-esola70w.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
  550. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
  551. package/static/dist/assets/{timeline-definition-24CTP7MA-getrz2y8.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
  552. package/static/dist/assets/toast-i3h34v6o.js +64 -0
  553. package/static/dist/assets/{treeView-Q6P3EWNA-hc7ap1q7.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
  554. package/static/dist/assets/{treemap-WGGIJYW6-b7k1syy5.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
  555. package/static/dist/assets/untitled-oaoya4qj.js +1 -0
  556. package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
  557. package/static/dist/assets/{useParams-lhg566ew.js → useParams-gjc70qru.js} +1 -1
  558. package/static/dist/assets/{useSearch-lbrh6ejn.js → useSearch-e6ae539u.js} +1 -1
  559. package/static/dist/assets/{vennDiagram-4TSXK5OY-n6pcy5qb.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
  560. package/static/dist/assets/{wardley-WFR3VGLG-lpa18vz1.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
  561. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-kpclwwg7.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
  562. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-paa2n8y0.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
  563. package/static/dist/index.html +1 -1
  564. package/static/dist/sw.js +1 -1
  565. package/static/dist/assets/AppShell-fb2td9zi.js +0 -1
  566. package/static/dist/assets/ChatPage-cj96kiy9.js +0 -25
  567. package/static/dist/assets/Dialog-kxcg7ard.js +0 -1
  568. package/static/dist/assets/ExtensionRoute-jf7h0754.js +0 -1
  569. package/static/dist/assets/HubRoute-gmanpd5y.js +0 -2
  570. package/static/dist/assets/Markdown-mdwlvj3w.js +0 -598
  571. package/static/dist/assets/RightPanel-ftvrjlcp.js +0 -1
  572. package/static/dist/assets/Select-bilaypej.js +0 -1
  573. package/static/dist/assets/SessionListPanel-og8n36qs.js +0 -1
  574. package/static/dist/assets/SettingsLayout-jonjefmj.js +0 -1
  575. package/static/dist/assets/States-nmejrwrm.js +0 -1
  576. package/static/dist/assets/Toaster-nnl8zkt9.js +0 -1
  577. package/static/dist/assets/_app-gdidzd94.js +0 -1
  578. package/static/dist/assets/_app.index-hbarzbjx.js +0 -1
  579. package/static/dist/assets/_app.insights-luywlarz.js +0 -1
  580. package/static/dist/assets/_app.kanban-kincnbr8.js +0 -1
  581. package/static/dist/assets/_app.logs-oi2gr3gs.js +0 -1
  582. package/static/dist/assets/_app.memory-b6jfivt6.js +0 -1
  583. package/static/dist/assets/_app.profiles-lpvixmqp.js +0 -1
  584. package/static/dist/assets/_app.settings-vowx3iq0.js +0 -1
  585. package/static/dist/assets/_app.settings._section-cnntg1tu.js +0 -3
  586. package/static/dist/assets/_app.skills-fb57lpau.js +0 -1
  587. package/static/dist/assets/_app.tasks-eszpx1q7.js +0 -2
  588. package/static/dist/assets/_app.todos-nr05ac6w.js +0 -1
  589. package/static/dist/assets/_app.workspaces-d2luomu6.js +0 -1
  590. package/static/dist/assets/architecture-7GRP2DOG-hd0o7bde.js +0 -1
  591. package/static/dist/assets/back-bemaer09.js +0 -1
  592. package/static/dist/assets/cancel-drd0g6d9.js +0 -1
  593. package/static/dist/assets/channel-cpdgf867.js +0 -1
  594. package/static/dist/assets/classDiagram-ZZMXUADV-ko09sguk.js +0 -1
  595. package/static/dist/assets/classDiagram-v2-VYDZK3BY-ko09sguk.js +0 -1
  596. package/static/dist/assets/copied-ima3pysb.js +0 -1
  597. package/static/dist/assets/endpoints-d33siokx.js +0 -64
  598. package/static/dist/assets/eventmodeling-NTZA5JFV-emctlo64.js +0 -1
  599. package/static/dist/assets/flowDiagram-HODETNUW-bfi3moqa.js +0 -1
  600. package/static/dist/assets/index-cxq58xb1.js +0 -11
  601. package/static/dist/assets/index-hgtx08n5.css +0 -2
  602. package/static/dist/assets/lazyRouteComponent-af0lvgjx.js +0 -1
  603. package/static/dist/assets/link-jyxicdhg.js +0 -1
  604. package/static/dist/assets/login-ec21t39x.js +0 -1
  605. package/static/dist/assets/mermaid-HWGCJPDP-gfl8z3ep.js +0 -1
  606. package/static/dist/assets/onboarding-btnltvyz.js +0 -1
  607. package/static/dist/assets/onboarding_model_label-dp7xffoh.js +0 -1
  608. package/static/dist/assets/railroad-abnf-HS5TGJTU-nst1qxyn.js +0 -1
  609. package/static/dist/assets/railroad-ebnf-LZEXJU2U-ci37x8qy.js +0 -1
  610. package/static/dist/assets/railroad-peg-WCYAUIDC-aimkk07z.js +0 -1
  611. package/static/dist/assets/refresh-b4uams1i.js +0 -1
  612. package/static/dist/assets/saved-kg52csy7.js +0 -1
  613. package/static/dist/assets/settings_section_extensions_title-ej0iapxe.js +0 -1
  614. package/static/dist/assets/share._token-f97e94r0.js +0 -2
  615. package/static/dist/assets/sliders-horizontal-fj18it47.js +0 -1
  616. package/static/dist/assets/sse-fwpqtzbg.js +0 -1
  617. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hwn4apct.js +0 -1
  618. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-d1h97oyg.js +0 -8
  619. package/static/dist/assets/todoStore-eginf9h4.js +0 -1
  620. package/static/dist/assets/tts-lxdxnmjd.js +0 -1
  621. package/static/dist/assets/untitled-ovpfthg4.js +0 -1
  622. package/static/dist/assets/useForm-bcblv1rx.js +0 -1
  623. package/static/dist/assets/useLocale-fi5jb8ma.js +0 -1
@@ -0,0 +1,1053 @@
1
+ """Service identities and backend recovery extending agent_mutation's native lease.
2
+
3
+ Bare/Windows gateways keep the native journal. A checkout-locked companion owns
4
+ POSIX supervisor obligations and verifies actual gateway/backend readiness.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import contextlib
10
+ import hashlib
11
+ import http.client
12
+ import ipaddress
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ import plistlib
17
+ import re
18
+ import shlex
19
+ import signal
20
+ import stat
21
+ import subprocess
22
+ import sys
23
+ import tempfile
24
+ import threading
25
+ import time
26
+
27
+ STOP_SECONDS = 30.0
28
+ READY_SECONDS = 30.0
29
+ SNAPSHOT_ATTEMPTS = 4
30
+
31
+
32
+ @contextlib.contextmanager
33
+ def gateway_fence(entry):
34
+ from gateway import status
35
+ home = Path(entry['home'])
36
+ path = status._get_gateway_lock_path(home / 'gateway.pid')
37
+ with os.fdopen(os.open(path, os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK), 'r+') as fence:
38
+ info = os.fstat(fence.fileno())
39
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
40
+ raise RuntimeError(f'{_name(entry)} gateway lock is not an exclusive regular file; recovery remains owed.')
41
+ if not status._try_acquire_file_lock(fence):
42
+ yield None
43
+ return
44
+ try:
45
+ # A live, explicitly non-gateway owner keeps native stale-PID cleanup
46
+ # from replacing this held lock inode. The companion debt owns recovery.
47
+ opened, current = os.fstat(fence.fileno()), path.stat()
48
+ if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
49
+ raise RuntimeError(f'{_name(entry)} gateway runtime fence changed; recovery remains owed.')
50
+ marker = {**status._build_pid_record(), 'kind': 'talaria-update-fence', 'hermes_home': str(home)}
51
+ if marker.get('start_time') is None:
52
+ raise RuntimeError(f'{_name(entry)} updater birth identity is unverified; recovery remains owed.')
53
+ fence.seek(0)
54
+ fence.truncate()
55
+ json.dump(marker, fence)
56
+ fence.flush()
57
+ os.fsync(fence.fileno())
58
+ yield fence
59
+ finally:
60
+ status._release_file_lock(fence)
61
+
62
+
63
+ def _verify_fence(entry, fence):
64
+ from gateway import status
65
+ path = status._get_gateway_lock_path(Path(entry['home']) / 'gateway.pid')
66
+ try:
67
+ opened, current = os.fstat(fence.fileno()), path.stat()
68
+ if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino) or current.st_nlink != 1:
69
+ raise ValueError('replaced or linked inode')
70
+ marker = json.loads(path.read_text())
71
+ if (marker.get('kind') != 'talaria-update-fence' or marker.get('pid') != os.getpid()
72
+ or marker.get('start_time') != status.get_process_start_time(os.getpid())):
73
+ raise ValueError('changed owner')
74
+ except (OSError, ValueError, TypeError, AttributeError) as exc:
75
+ raise RuntimeError(f'{_name(entry)} gateway runtime fence changed; recovery remains owed.') from exc
76
+
77
+
78
+ def recovery_path(root, home):
79
+ key = hashlib.sha256(os.fsencode(Path(root).resolve())).hexdigest()
80
+ return Path(home) / '.talaria-agent-services' / (key + '.json')
81
+
82
+
83
+ def _process_table():
84
+ result = subprocess.run(['ps', '-A', '-o', 'pid=,ppid='], capture_output=True, text=True, timeout=10, check=True)
85
+ parents = {}
86
+ for line in result.stdout.splitlines():
87
+ fields = line.split()
88
+ if len(fields) != 2 or not all(f.isdigit() for f in fields):
89
+ raise RuntimeError('The process ancestry inventory is unreadable.')
90
+ parents[int(fields[0])] = int(fields[1])
91
+ if not parents:
92
+ raise RuntimeError('The process ancestry inventory is empty.')
93
+ return parents
94
+
95
+
96
+ def _ancestors(pid, parents):
97
+ result = []
98
+ while pid > 1:
99
+ if pid in result or pid not in parents:
100
+ raise RuntimeError(f'Process {pid} ancestry could not be proved.')
101
+ result.append(pid)
102
+ pid = parents[pid]
103
+ return result
104
+
105
+
106
+ def _descendants(root, parents):
107
+ found, pending = set(), [root]
108
+ while pending:
109
+ pid = pending.pop()
110
+ if pid in found:
111
+ raise RuntimeError('The process inventory contains an ancestry cycle.')
112
+ found.add(pid)
113
+ pending.extend(p for p, parent in parents.items() if parent == pid)
114
+ return found
115
+
116
+
117
+ def _name(entry):
118
+ return entry.get('label') or entry.get('unit') or f"PID {entry['pid']}"
119
+
120
+
121
+ def _service_key(entry):
122
+ return (entry['kind'], entry.get('domain') or entry.get('scope'), entry.get('label') or entry.get('unit'))
123
+
124
+
125
+ def snapshot(entry, *, records=None, expected=None):
126
+ """Attest a complete live tree, retrying only proved exits/new discoveries."""
127
+ from gateway.status import get_process_start_time, _pid_exists
128
+ service, pid = entry.get('service_pid', entry['pid']), entry['pid']
129
+ anchors = {service, pid}
130
+ observed = dict(expected or {})
131
+ seen_parents = {int(p): proof['parent'] for p, proof in observed.items() if 'parent' in proof}
132
+ departed = set()
133
+
134
+ def exited(p):
135
+ if _pid_exists(p) is False:
136
+ if p in anchors:
137
+ raise RuntimeError(f'{_name(entry)} anchor process {p} disappeared during discovery; source was not changed.')
138
+ departed.add(p)
139
+ return True
140
+ return False
141
+
142
+ def inventory():
143
+ parents = _process_table()
144
+ if service not in _ancestors(pid, parents):
145
+ raise RuntimeError(f'{_name(entry)} does not own backend/gateway PID {pid}.')
146
+ if service in _ancestors(os.getpid(), parents):
147
+ raise RuntimeError(f'{_name(entry)} owns the updater or an ancestor; source was not changed.')
148
+ for p, parent in seen_parents.items():
149
+ if parents.get(p) != parent:
150
+ if p not in parents and exited(p):
151
+ continue
152
+ raise RuntimeError(f'{_name(entry)} process {p} identity/ancestry changed during discovery; source was not changed.')
153
+ descendants = _descendants(service, parents)
154
+ seen_parents.update({p: parents[p] for p in descendants})
155
+ # Zombies may remain in ps. Recheck departed PIDs at every inventory so
156
+ # neither a recycled PID nor unknown liveness can be silently excluded.
157
+ if any(_pid_exists(p) is not False for p in departed):
158
+ raise RuntimeError(f'{_name(entry)} a departed descendant PID became live or unreadable; source was not changed.')
159
+ return parents, descendants - departed
160
+
161
+ def read(p, parents):
162
+ if exited(p):
163
+ return None
164
+ proof = {'birth': get_process_start_time(p), 'parent': parents[p]}
165
+ if records is not None:
166
+ proof['ct'] = records.identity(p)['ct']
167
+ if proof['birth'] is None or (records is not None and proof['ct'] is None):
168
+ if exited(p):
169
+ return None
170
+ raise RuntimeError(f'{_name(entry)} process {p} birth identity is live or unreadable; source was not changed.')
171
+ if any(proof.get(key) != value for key, value in observed.get(str(p), {}).items()):
172
+ raise RuntimeError(f'{_name(entry)} process {p} identity/ancestry changed during discovery; source was not changed.')
173
+ observed[str(p)] = proof
174
+ return proof
175
+
176
+ for _ in range(SNAPSHOT_ATTEMPTS):
177
+ parents, live = inventory()
178
+ proofs = {str(p): read(p, parents) for p in sorted(live)}
179
+ after, remaining = inventory()
180
+ for p in sorted(live & remaining):
181
+ read(p, after)
182
+ _, final = inventory()
183
+ if live & departed or remaining != live or final != live:
184
+ continue
185
+ return proofs
186
+ raise RuntimeError(f'{_name(entry)} process inventory kept changing after {SNAPSHOT_ATTEMPTS} samples; retry when descendants settle. Source was not changed and recovery remains owed.')
187
+
188
+
189
+ def _gateway_home_matches(home, runtime, pause):
190
+ """Keep native identity policy, decoding only native-recognized inline launchers."""
191
+ from gateway import status
192
+ home = Path(home).resolve()
193
+ observed = pause._home_of(runtime[0])
194
+ command = status._read_process_cmdline(runtime[0])
195
+ if not observed or Path(observed).resolve() != home or not command:
196
+ return False
197
+ try:
198
+ from gateway.status_inline_source import command_line_runs_inline_source, inline_bootstrap_argv
199
+ except ImportError:
200
+ return False # Older Agents cannot prove this launcher; retain recovery debt.
201
+ try:
202
+ tokens = [t.strip("\"'").replace('\\', '/') for t in shlex.split(command, posix=False)]
203
+ except ValueError:
204
+ return False
205
+ if not command_line_runs_inline_source(tokens):
206
+ return pause._gateway_on_home(str(home), set()) == runtime[0]
207
+ argv = inline_bootstrap_argv(tokens)
208
+ if argv is None:
209
+ return False
210
+ for index, arg in enumerate(argv):
211
+ if arg.startswith('HERMES_HOME='):
212
+ if not status._same_hermes_home(arg.removeprefix('HERMES_HOME='), home):
213
+ return False
214
+ argv[index] = 'HERMES_HOME=' + str(home)
215
+ effective = ' '.join(argv)
216
+ if (not status.looks_like_gateway_command_line(effective)
217
+ or not status._command_line_belongs_to_profile(effective, home)
218
+ or status._process_is_stopped(runtime[0])):
219
+ return False
220
+ profile = status.profile_flag_value(effective)
221
+ if profile is not None and profile != (status._profile_name_for_home(home) or 'default'):
222
+ return False
223
+ pid_path = home / 'gateway.pid'
224
+ records = [status._read_gateway_lock_record(status._get_gateway_lock_path(pid_path))]
225
+ if pid_path.exists():
226
+ records.append(status._read_pid_record(pid_path))
227
+ if any(not record or status.recorded_gateway_home_conflicts(record, expected_home=home) for record in records):
228
+ return False
229
+ return status.get_running_pid_identity_strict(pid_path) == runtime
230
+
231
+
232
+ def gateway_entries(entries, homes, pause, *, allow_starting=False):
233
+ if not entries:
234
+ return []
235
+ from gateway.status import get_running_pid_identity_strict
236
+ found = {}
237
+ for original in entries:
238
+ entry = dict(original)
239
+ home = Path(entry['home']).resolve() if entry.get('home') else None
240
+ if home not in homes:
241
+ raise RuntimeError(f'{_name(entry)} gateway owning profile is unknown or outside this Web instance.')
242
+ runtime = get_running_pid_identity_strict(home / 'gateway.pid')
243
+ if runtime is None and allow_starting:
244
+ continue # Lock admission can precede PID publication; callers retain custody while waiting.
245
+ if runtime is None or not _gateway_home_matches(home, runtime, pause):
246
+ raise RuntimeError(f'{_name(entry)} has no verified gateway in its owning profile.')
247
+ entry.update(service_pid=entry['pid'], pid=runtime[0], ct=runtime[1], home=str(home))
248
+ entry['processes'] = snapshot(entry, expected={str(entry['pid']): {'birth': entry['ct']}})
249
+ key = _service_key(entry)
250
+ if key in found and found[key] != entry:
251
+ raise RuntimeError(f'{_name(entry)} has conflicting discovery identities.')
252
+ found[key] = entry
253
+ if len({e['pid'] for e in found.values()}) != len(found):
254
+ raise RuntimeError('More than one service claims the same gateway process.')
255
+ return list(found.values())
256
+
257
+
258
+ def _plist(domain, label, argv, dashboard):
259
+ matches = []
260
+ for kind, directory in dashboard._launchd_plist_dirs():
261
+ if (domain == 'system') != (kind == 'daemon'):
262
+ continue
263
+ path = directory / (label + '.plist')
264
+ try:
265
+ data = plistlib.loads(path.read_bytes())
266
+ except FileNotFoundError:
267
+ continue
268
+ if data.get('Label') == label and data.get('ProgramArguments') == argv:
269
+ matches.append(path.resolve())
270
+ if len(set(matches)) != 1:
271
+ raise RuntimeError(f'{label} original launchd configuration could not be proved.')
272
+ return str(matches[0])
273
+
274
+
275
+ def _backend_identity(row, homes, identity):
276
+ from hermes_cli import dashboard_procs, update_inventory, update_receipt
277
+ if row.get('create_time') is None or identity._pid_alive_matches(row['pid'], row['create_time'], strict=True) is not True:
278
+ raise RuntimeError(f"Agent backend PID {row['pid']} birth identity is unverified.")
279
+ env = dashboard_procs._pid_environ(row['pid'])
280
+ if env is None:
281
+ raise RuntimeError(f"Agent backend PID {row['pid']} ownership environment is unreadable.")
282
+ # Native is_desktop_owned_backend is bound to the caller's environment, not this target PID.
283
+ desktop = env.get('HERMES_DESKTOP') == '1' and bool(env.get('HERMES_DASHBOARD_SESSION_TOKEN'))
284
+ if desktop or row.get('isolated') or update_inventory._is_desktop_ssh_ledger_entry(row):
285
+ raise RuntimeError(f"Agent backend PID {row['pid']} is Desktop/isolated-owned; its owner must stop it.")
286
+ observed = dashboard_procs._hermes_home_for_pid(row['pid'])
287
+ home = Path(observed).resolve() if observed else None
288
+ if home not in homes or (row.get('hermes_home') and Path(row['hermes_home']).resolve() != home):
289
+ raise RuntimeError(f"Agent backend PID {row['pid']} has an unknown or foreign owning profile.")
290
+ profiles = {name for name, path in update_receipt._profile_homes() if Path(path).resolve() == home}
291
+ if len(profiles) != 1 or (row.get('profile') and row['profile'] not in profiles):
292
+ raise RuntimeError(f"Agent backend PID {row['pid']} has conflicting or ambiguous profile ownership.")
293
+ if identity._pid_alive_matches(row['pid'], row['create_time'], strict=True) is not True:
294
+ raise RuntimeError(f"Agent backend PID {row['pid']} birth identity changed during discovery.")
295
+ return home, profiles.pop()
296
+
297
+
298
+ def backend_entry(row, homes, pause, identity):
299
+ from hermes_cli import main_dashboard as dashboard
300
+ from hermes_cli import update_inventory
301
+ home, profile = _backend_identity(row, homes, identity)
302
+ parents = _process_table()
303
+ ancestors = _ancestors(row['pid'], parents)
304
+ jobs = dashboard._loaded_launchd_backend_jobs()
305
+ # Native ownership also admits argv-only matches; this lease requires a live ancestry witness.
306
+ candidates = [job for job in jobs if job[3] in ancestors]
307
+ owner = dashboard._launchd_job_owning_backend(row['pid'], shlex.split(row.get('argv') or ''), candidates, ancestors=ancestors)
308
+ entry = dict(pid=row['pid'], purpose=row['purpose'], home=str(home), profile=profile,
309
+ host=row.get('host'), port=row.get('port'))
310
+ if owner:
311
+ domain, label, service_pid = owner
312
+ if len({(j[0], j[1]) for j in candidates}) != 1:
313
+ raise RuntimeError(f"Backend PID {row['pid']} has ambiguous launchd ownership.")
314
+ argv = next(j[2] for j in candidates if j[:2] == owner[:2])
315
+ entry.update(kind='launchd', domain=domain, label=label, service_pid=service_pid,
316
+ plist=_plist(domain, label, argv, dashboard))
317
+ else:
318
+ service_pid = next((p for p in ancestors if dashboard._get_systemd_service_for_pid(p)), None)
319
+ if service_pid is None:
320
+ raise RuntimeError(f"Agent backend PID {row['pid']} has no manageable supervised service; stop it through its owner.")
321
+ unit = dashboard._get_systemd_service_for_pid(service_pid)
322
+ cgroup = dashboard._get_pid_cgroup_path(service_pid)
323
+ scope = dashboard._extract_scope_from_cgroup(cgroup or '')
324
+ if scope not in {'user', 'system'} or pause._manage_cmd(scope) is None:
325
+ raise RuntimeError(f'{unit} scope/management authority is unverified.')
326
+ from hermes_cli.update_fleet_scope import systemd_unit_hermes_home
327
+ scope_cmd = ['systemctl', '--user'] if scope == 'user' else ['systemctl']
328
+ declared_home = systemd_unit_hermes_home(scope_cmd, unit)
329
+ if (not update_inventory._serve_unit_matches_profile(entry['profile'], unit)
330
+ or not declared_home or Path(declared_home).resolve() != home):
331
+ raise RuntimeError(f'{unit} does not prove ownership of this backend profile; source was not changed.')
332
+ entry.update(kind='systemd', unit=unit, scope=scope, cgroup=cgroup, service_pid=service_pid)
333
+ _endpoint(entry)
334
+ entry['processes'] = snapshot(entry)
335
+ if identity._pid_alive_matches(row['pid'], row['create_time'], strict=True) is not True:
336
+ raise RuntimeError(f"Agent backend PID {row['pid']} birth identity changed during discovery.")
337
+ return entry
338
+
339
+
340
+ def _endpoint(entry):
341
+ host, port = entry.get('host'), entry.get('port')
342
+ if not isinstance(host, str) or not isinstance(port, int) or isinstance(port, bool) or not 0 < port <= 65535:
343
+ raise RuntimeError(f'{_name(entry)} backend endpoint is unknown.')
344
+ if host == 'localhost':
345
+ return '127.0.0.1', port
346
+ try:
347
+ address = ipaddress.ip_address(host)
348
+ except ValueError as exc:
349
+ raise RuntimeError(f'{_name(entry)} backend bind address is not a verified IP address.') from exc
350
+ return ('::1' if address.version == 6 else '127.0.0.1') if address.is_unspecified else host, port
351
+
352
+
353
+ def _service_pid(entry):
354
+ if entry['kind'] == 'launchd':
355
+ from hermes_cli.gateway import _launchd_print_service_pid
356
+ loaded, pid = _launchd_print_service_pid(entry['domain'], entry['label'])
357
+ return loaded, pid
358
+ from hermes_cli.update_cmd_fleet import _systemctl, _unit_main_pid
359
+ cmd = ['systemctl', '--user'] if entry['scope'] == 'user' else ['systemctl']
360
+ state = _systemctl(cmd + ['show', entry['unit'], '--property=ActiveState', '--value'], timeout=10)
361
+ if state.returncode:
362
+ raise RuntimeError(f'{_name(entry)} supervisor state is unreadable.')
363
+ active = state.stdout.strip() not in {'inactive', 'failed'}
364
+ return active, _unit_main_pid(cmd, entry['unit']) or None
365
+
366
+
367
+ def verify_processes(entry, records, *, stopping=False, fenced=False):
368
+ from gateway.status import get_process_start_time, _pid_exists
369
+ parents = _process_table()
370
+ departed = set()
371
+ for pid, expected in entry['processes'].items():
372
+ pid = int(pid)
373
+ if _pid_exists(pid) is False:
374
+ if stopping:
375
+ departed.add(pid)
376
+ continue
377
+ raise RuntimeError(f'{_name(entry)} process {pid} disappeared before its stop.')
378
+ if (get_process_start_time(pid) != expected['birth'] or records.identity(pid)['ct'] != expected.get('ct')
379
+ or parents.get(pid) != expected['parent']):
380
+ if stopping and _pid_exists(pid) is False:
381
+ departed.add(pid)
382
+ continue
383
+ raise RuntimeError(f'{_name(entry)} process {pid} identity/ancestry changed; source was not changed.')
384
+ _, service_pid = _service_pid(entry)
385
+ if service_pid and service_pid != entry['service_pid']:
386
+ if fenced:
387
+ return False
388
+ raise RuntimeError(f'{_name(entry)} supervisor respawned during quiescence; source was not changed.')
389
+ parents = _process_table()
390
+ if any(_pid_exists(pid) is not False for pid in departed):
391
+ raise RuntimeError(f'{_name(entry)} process identity/ancestry changed after departure; source was not changed.')
392
+ for pid, expected in entry['processes'].items():
393
+ if parents.get(int(pid)) != expected['parent']:
394
+ if stopping and _pid_exists(int(pid)) is False:
395
+ continue
396
+ raise RuntimeError(f'{_name(entry)} process {pid} identity/ancestry changed; source was not changed.')
397
+ if service_pid:
398
+ for pid in _descendants(service_pid, parents):
399
+ if str(pid) not in entry['processes']:
400
+ if fenced:
401
+ return False
402
+ raise RuntimeError(f'{_name(entry)} acquired an unrecorded descendant {pid}; source was not changed.')
403
+ return True
404
+
405
+
406
+ def bind_processes(entry, records):
407
+ if entry.get('processes'):
408
+ entry['processes'] = snapshot(entry, records=records, expected=entry['processes'])
409
+ verify_processes(entry, records)
410
+
411
+
412
+ def service_entry(entry):
413
+ return {**entry, 'pid': entry.get('service_pid', entry['pid']), 'runtime_pid': entry['pid']}
414
+
415
+
416
+ def wait_stopped(entry, records, stopped, *, writers_only=False):
417
+ pids = {pid: p for pid, p in entry['processes'].items()
418
+ if not writers_only or int(pid) != entry['service_pid'] or int(pid) == entry['pid']}
419
+ deadline = time.monotonic() + STOP_SECONDS
420
+ while any(records.identity_is_live({'pid': int(pid), 'ct': p['ct']}) for pid, p in pids.items()):
421
+ if stopped.wait(.05) or time.monotonic() >= deadline:
422
+ raise RuntimeError(f'{_name(entry)} graceful stop has not completed; source was not changed and recovery remains owed.')
423
+ if not writers_only and any(_service_pid(entry)):
424
+ raise RuntimeError(f'{_name(entry)} supervisor is still active; source was not changed and recovery remains owed.')
425
+
426
+
427
+ def verify_listener(entry):
428
+ """A legacy HTTP response has no PID identity; require OS socket ownership as well."""
429
+ _endpoint(entry)
430
+ pid, port = entry['pid'], entry['port']
431
+ if sys.platform.startswith('linux'):
432
+ owned = set()
433
+ for path in Path(f'/proc/{pid}/fd').iterdir():
434
+ with contextlib.suppress(FileNotFoundError):
435
+ owned.add(os.readlink(path))
436
+ listeners = set()
437
+ for table in ('tcp', 'tcp6'):
438
+ try:
439
+ rows = Path('/proc/net/' + table).read_text().splitlines()[1:]
440
+ except FileNotFoundError:
441
+ if table == 'tcp6':
442
+ continue
443
+ raise
444
+ for line in rows:
445
+ fields = line.split()
446
+ if int(fields[1].split(':')[1], 16) == port and fields[3] == '0A':
447
+ listeners.add('socket:[' + fields[9] + ']')
448
+ matched = bool(listeners) and listeners <= owned
449
+ elif sys.platform == 'darwin':
450
+ result = subprocess.run(['/usr/sbin/lsof', '-nP', f'-iTCP:{port}', '-sTCP:LISTEN', '-Fp'],
451
+ capture_output=True, text=True, timeout=10, check=False)
452
+ matched = result.returncode == 0 and {int(line[1:]) for line in result.stdout.splitlines() if line.startswith('p')} == {pid}
453
+ else:
454
+ matched = False
455
+ if not matched:
456
+ raise RuntimeError(f'{_name(entry)} legacy listener ownership could not be proved; source was not changed.')
457
+
458
+
459
+ def _auth(entry):
460
+ from gateway import host_rendezvous as host
461
+ from hermes_cli.dashboard_procs import _pid_environ
462
+ env = _pid_environ(entry['pid']) or {}
463
+ token = env.get('HERMES_DASHBOARD_SESSION_TOKEN') or ''
464
+ record = host.read_record(host.ROLE_SERVE)
465
+ if (record is not None and record.pid == entry['pid'] and record.home
466
+ and Path(record.home).resolve() == Path(entry['home']).resolve()
467
+ and record.port == entry['port'] and record.host == entry['host']
468
+ and host.liveness_is_proven(record) and host.record_token_is_consistent(record)):
469
+ token = host.read_token(host.ROLE_SERVE)
470
+ return token
471
+
472
+
473
+ def _local_session(entry):
474
+ """Use the owning user's existing native basic-auth key for a short-lived local lifecycle call."""
475
+ from hermes_cli.dashboard_procs import _pid_environ
476
+ try:
477
+ from plugins.dashboard_auth import basic
478
+ except ImportError:
479
+ return None
480
+ from .home import scoped_home
481
+ from agent import secret_scope
482
+ from hermes_cli.env_loader import hydrate_profile_secret_sources
483
+ env = _pid_environ(entry['pid'])
484
+ if env is None:
485
+ return None
486
+ home = Path(entry['home'])
487
+ with scoped_home(home):
488
+ hydrate_profile_secret_sources(home)
489
+ # The launch scope also contains updater credentials; use only the owner's file-backed scope.
490
+ scope = secret_scope.build_profile_secret_scope(home)
491
+ scope.update({name: env[name] for name in ('HERMES_DASHBOARD_BASIC_AUTH_SECRET', 'HERMES_DASHBOARD_BASIC_AUTH_USERNAME')
492
+ if env.get(name, '').strip()})
493
+ token = secret_scope.set_secret_scope(scope)
494
+ # Config ${...} references must also refuse launch-environment fallback, including on the main profile.
495
+ multiplex = secret_scope.set_multiplex_context(True)
496
+ try:
497
+ section = basic._load_config_basic_auth_section()
498
+ finally:
499
+ secret_scope.reset_multiplex_context(multiplex)
500
+ secret_scope.reset_secret_scope(token)
501
+
502
+ def setting(name, key):
503
+ return str(scope.get(name) or '').strip() or str(section.get(key) or '').strip()
504
+
505
+ secret = setting('HERMES_DASHBOARD_BASIC_AUTH_SECRET', 'secret')
506
+ username = setting('HERMES_DASHBOARD_BASIC_AUTH_USERNAME', 'username')
507
+ if not secret or not username:
508
+ return None # A process-random key is not reproducible; never manufacture a replacement key.
509
+ # Native _resolve_secret reads os.environ directly. Match its base64/hex/raw order without mutating it.
510
+ key = secret.encode('utf-8')
511
+ for decoder in (base64.b64decode, bytes.fromhex):
512
+ try:
513
+ decoded = decoder(secret)
514
+ if len(decoded) >= 16:
515
+ key = decoded
516
+ break
517
+ except (ValueError, TypeError):
518
+ pass
519
+ return basic._sign({'sub': username, 'kind': 'access', 'exp': int(time.time()) + 60}, key)
520
+
521
+
522
+ class BackendLifecycleUnsupported(RuntimeError):
523
+ """Terminal capability refusal, distinct from retryable recovery readiness."""
524
+
525
+
526
+ def backend_request(entry, action=None, permit=None):
527
+ host, port = _endpoint(entry)
528
+ token = _auth(entry)
529
+ headers = {'X-Hermes-Session-Token': token, 'Authorization': 'Bearer ' + token}
530
+
531
+ def request(path, body=None, *, authenticated=True, limit=65536):
532
+ for attempt in range(2):
533
+ connection = http.client.HTTPConnection(host, port, timeout=2)
534
+ try:
535
+ sent = {**headers, 'Content-Type': 'application/json'} if authenticated else {}
536
+ connection.request('GET' if body is None else 'POST', path, body, sent)
537
+ response = connection.getresponse()
538
+ data = response.read(limit + 1)
539
+ if authenticated and response.status == 401 and attempt == 0:
540
+ credential = _local_session(entry)
541
+ if credential:
542
+ headers['Authorization'] = 'Bearer ' + credential
543
+ continue
544
+ if authenticated and response.status in {401, 403}:
545
+ raise RuntimeError(f'{_name(entry)} refused authenticated backend lifecycle access (HTTP {response.status}); source was not changed.')
546
+ if len(data) > limit:
547
+ raise ValueError('oversized response')
548
+ return response.status, json.loads(data) if response.status == 200 else None
549
+ finally:
550
+ connection.close()
551
+
552
+ def verified_identity(status, result):
553
+ return (status == 200 and isinstance(result, dict) and result.get('pid') == entry['pid']
554
+ and result.get('role') == 'serve' and result.get('ok') is True)
555
+
556
+ def capability_check(identity_status, identity):
557
+ # The schema belongs to the running endpoint, never the checkout on disk.
558
+ status, schema = request('/openapi.json', limit=4 * 1024 * 1024)
559
+ if (status != 200 or not isinstance(schema, dict) or not isinstance(schema.get('paths'), dict)
560
+ or not isinstance(schema.get('openapi'), str) or not schema['openapi'].startswith('3.')):
561
+ raise RuntimeError(f'{_name(entry)} backend lifecycle/readiness schema could not be verified.')
562
+ paths = schema['paths']
563
+ for path, method in (('/api/host/identity', 'get'), ('/api/health/retirement', 'post')):
564
+ route = paths.get(path, {})
565
+ if not isinstance(route, dict) or (method in route and not isinstance(route[method], dict)):
566
+ raise ValueError('malformed lifecycle schema')
567
+ missing_identity = 'get' not in paths.get('/api/host/identity', {})
568
+ missing_retirement = 'post' not in paths.get('/api/health/retirement', {})
569
+ if not missing_identity and not missing_retirement:
570
+ return
571
+ if verified_identity(identity_status, identity):
572
+ if missing_identity or not verified_identity(*request('/api/host/identity')):
573
+ raise RuntimeError(f'{_name(entry)} backend lifecycle identity/schema changed or disagrees.')
574
+ elif identity_status in {404, 405, 501} and missing_identity:
575
+ challenge, _ = request('/api/sessions?limit=1', authenticated=False)
576
+ accepted, sessions = request('/api/sessions?limit=1')
577
+ if (challenge not in {401, 403} or accepted != 200 or not isinstance(sessions, dict)
578
+ or not isinstance(sessions.get('sessions'), list)
579
+ or request('/api/host/identity')[0] != identity_status):
580
+ return
581
+ else:
582
+ return
583
+ name = ''.join(c for c in str(_name(entry)) if c.isprintable())
584
+ unverified = ' Backend listener identity remains unverified.' if missing_identity else ''
585
+ raise BackendLifecycleUnsupported(
586
+ f'{name} running backend lifecycle APIs are unsupported for safe Agent update custody.' + unverified
587
+ + ' Finish active work, then have the owner gracefully refresh this service through its original supervisor.'
588
+ + ' Verify the successor lifecycle APIs and intended installation/profile/PID, then retry the Agent update.'
589
+ + ' Any retained service recovery remains owed.')
590
+
591
+ try:
592
+ body = None if action is None else json.dumps(dict(action=action, **({'token': permit} if permit else {})))
593
+ status, result = request('/api/host/identity' if action is None else '/api/health/retirement', body)
594
+ if action is None:
595
+ if status == 200 and not verified_identity(status, result):
596
+ raise RuntimeError(f'{_name(entry)} endpoint is not the expected backend process.')
597
+ capability_check(status, result)
598
+ elif status in {404, 405, 501}:
599
+ capability_check(*request('/api/host/identity'))
600
+ if status != 200:
601
+ raise RuntimeError(f'{_name(entry)} backend lifecycle/readiness could not be verified (HTTP {status}).')
602
+ if not isinstance(result, dict):
603
+ raise ValueError('not an object')
604
+ return result
605
+ except (OSError, ValueError, TypeError, http.client.HTTPException) as exc:
606
+ raise RuntimeError(f'{_name(entry)} backend lifecycle/readiness could not be verified.') from exc
607
+
608
+
609
+ def _config_stamp(entry):
610
+ if entry['kind'] == 'launchd':
611
+ return hashlib.sha256(Path(entry['plist']).read_bytes()).hexdigest()
612
+ from hermes_cli.update_cmd_fleet import _systemctl
613
+ cmd = ['systemctl', '--user'] if entry['scope'] == 'user' else ['systemctl']
614
+ result = _systemctl(cmd + ['cat', entry['unit']], timeout=10)
615
+ if result.returncode:
616
+ raise RuntimeError(f'{_name(entry)} original service configuration is unreadable.')
617
+ return hashlib.sha256(result.stdout.encode()).hexdigest()
618
+
619
+
620
+ def _start(entry, pause):
621
+ if _config_stamp(entry) != entry['config_stamp']:
622
+ raise RuntimeError(f'{_name(entry)} service configuration changed; backend recovery remains owed.')
623
+ if entry['kind'] == 'launchd':
624
+ # The native bootstrap helper boots out a concurrently loaded replacement on EIO.
625
+ # This lease must adopt that replacement instead of killing its newly admitted work.
626
+ if not _service_pid(entry)[0]:
627
+ bootstrapped = subprocess.run(['launchctl', 'bootstrap', entry['domain'], entry['plist']],
628
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30, check=False)
629
+ if bootstrapped.returncode and not _service_pid(entry)[0]:
630
+ raise RuntimeError(f'{_name(entry)} supervisor failed to load its original configuration.')
631
+ result = subprocess.run(['launchctl', 'kickstart', f"{entry['domain']}/{entry['label']}"],
632
+ stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=30, check=False)
633
+ else:
634
+ from hermes_cli.update_cmd_fleet import _systemctl
635
+ cmd = pause._manage_cmd(entry['scope'])
636
+ if cmd is None:
637
+ raise RuntimeError(f'{_name(entry)} cannot be resumed with current service privileges.')
638
+ result = _systemctl(cmd + ['start', entry['unit']], timeout=30)
639
+ if result.returncode:
640
+ raise RuntimeError(f'{_name(entry)} supervisor failed to start its backend.')
641
+
642
+
643
+ class ServiceRecovery:
644
+ """Companion to the native pause token; all calls hold its existing checkout lock."""
645
+ def __init__(self, root, home, records, identity, pause):
646
+ self.root, self.records, self.identity, self.pause = root, records, identity, pause
647
+ self.path = recovery_path(root, home)
648
+ self._refresh_starts = set() # Finally recovery must not retry a failed bootstrap in this worker.
649
+ try:
650
+ self.state = json.loads(self.path.read_text())
651
+ except FileNotFoundError:
652
+ self.state = {'schema': 1, 'root': str(root), 'entries': [], 'tree': {}, 'sync_failed': False}
653
+ except (OSError, ValueError) as exc:
654
+ raise RuntimeError('Backend recovery evidence is unreadable; source was not changed.') from exc
655
+ if (not isinstance(self.state, dict) or self.state.get('schema') != 1 or self.state.get('root') != str(root)
656
+ or not isinstance(self.state.get('entries'), list) or not isinstance(self.state.get('tree'), dict)
657
+ or ('sync_failed' in self.state and not isinstance(self.state['sync_failed'], bool))):
658
+ raise RuntimeError('Backend recovery evidence has an unknown identity or format; source was not changed.')
659
+ if self.state['entries'] and (not self.state['tree'].get('baselines') or any(
660
+ not isinstance(e, dict) or e.get('purpose') not in {'gateway', 'dashboard', 'serve'}
661
+ or e.get('phase') not in {'recorded', 'retiring', 'stopped', 'refreshing'}
662
+ or (e.get('phase') == 'refreshing' and e.get('legacy_refresh') is not True) or not isinstance(e.get('processes'), dict)
663
+ for e in self.state['entries'])):
664
+ raise RuntimeError('Service recovery lacks complete source/process custody evidence; source was not changed.')
665
+
666
+ transaction = self.state.get('transaction')
667
+ if transaction is not None and (
668
+ not isinstance(transaction, dict) or not re.fullmatch(r'[a-f0-9]{40}', str(transaction.get('revision', '')))
669
+ or not isinstance(transaction.get('homes'), list) or not transaction['homes']
670
+ or any(not isinstance(home, str) or not Path(home).is_absolute() for home in transaction['homes'])
671
+ or any(key in transaction and not isinstance(transaction[key], bool) for key in ('dependencies_verified',))
672
+ or ('runtime_revision' in transaction and not re.fullmatch(r'[a-f0-9]{40}', str(transaction['runtime_revision'])))):
673
+ raise RuntimeError('Service recovery transaction has an unknown identity or format; its evidence is retained.')
674
+
675
+ def save(self, token=None):
676
+ if not self.state['entries'] and not self.state.get('sync_failed') and not self.state.get('transaction'):
677
+ if self.path.exists():
678
+ self.path.unlink()
679
+ self._sync_directory()
680
+ return
681
+ if token is not None:
682
+ self.state['tree'] = json.loads(json.dumps({key: token[key] for key in ('pause_id', 'baselines') if key in token}))
683
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
684
+ fd, temporary = tempfile.mkstemp(dir=self.path.parent, prefix=self.path.name + '.')
685
+ try:
686
+ with os.fdopen(fd, 'w') as handle:
687
+ json.dump(self.state, handle)
688
+ handle.flush()
689
+ os.fsync(handle.fileno())
690
+ os.replace(temporary, self.path)
691
+ self._sync_directory()
692
+ finally:
693
+ with contextlib.suppress(FileNotFoundError):
694
+ os.unlink(temporary)
695
+
696
+ def _sync_directory(self):
697
+ if os.name != 'nt':
698
+ fd = os.open(self.path.parent, os.O_RDONLY)
699
+ try:
700
+ os.fsync(fd)
701
+ finally:
702
+ os.close(fd)
703
+
704
+ def prepare(self, entries, token):
705
+ for entry in entries:
706
+ old = next((e for e in self.state['entries'] if _service_key(e) == _service_key(entry)), None)
707
+ if old is not None:
708
+ if old['pid'] != entry['pid']:
709
+ raise RuntimeError(f'{_name(entry)} has unresolved prior backend recovery debt.')
710
+ continue
711
+ self.state['entries'].append({**entry, 'phase': 'recorded', 'config_stamp': _config_stamp(entry)})
712
+ self.save(token)
713
+ gateways = [e for e in self.state['entries'] if e['purpose'] == 'gateway']
714
+ if gateways:
715
+ # Persist custody here before retiring overlapping native obligations. Native replay
716
+ # cannot distinguish a wrapper from its gateway and can rewrite the configured launcher.
717
+ keys = {_service_key(e) for e in gateways}
718
+ pids = {pid for e in gateways for pid in e['processes']}
719
+ transferred = [e for e in token.get('posix_units', []) if _service_key(e) in keys]
720
+ pids.update(str(e['pid']) for e in transferred)
721
+ token['posix_units'] = [e for e in token.get('posix_units', []) if _service_key(e) not in keys]
722
+ token['unmapped'] = [e for e in token.get('unmapped', []) if str(e['pid']) not in pids]
723
+ token['unmapped_pids'] = [e['pid'] for e in token['unmapped']]
724
+ token['identities'] = {pid: ct for pid, ct in token.get('identities', {}).items() if str(pid) not in pids}
725
+ token['resume_needed'] = bool(token['posix_units'] or token['unmapped'] or token.get('profiles') or token.get('services'))
726
+ self.records.write(token)
727
+ self.save(token)
728
+
729
+ def stop(self, entry, stopped):
730
+ owed = next(e for e in self.state['entries'] if _service_key(e) == _service_key(entry))
731
+ verify_processes(owed, self.records)
732
+ backend_request(owed)
733
+ permit = backend_request(owed, 'prepare')
734
+ if permit.get('ok') is not True or permit.get('idle') is not True or not isinstance(permit.get('token'), str):
735
+ if owed['phase'] == 'recorded':
736
+ self.state['entries'].remove(owed) # The native refusal created no retirement permit or stop.
737
+ self.save()
738
+ raise RuntimeError(f'{_name(owed)} has active work or cannot prove idle; source was not changed.')
739
+ try:
740
+ owed['phase'] = 'retiring'
741
+ self.save() # A lost commit reply still owes a full stop/resume of this incarnation.
742
+ except BaseException:
743
+ backend_request(owed, 'cancel', permit['token'])
744
+ raise
745
+ verify_processes(owed, self.records)
746
+ if backend_request(owed, 'commit', permit['token']).get('ok') is not True:
747
+ raise RuntimeError(f'{_name(owed)} did not commit its backend retirement; recovery remains owed.')
748
+ owed['backend_committed'] = True
749
+ self.save()
750
+ self._stop_backend_supervisor(owed, stopped)
751
+
752
+ def _stop_backend_supervisor(self, owed, stopped, *, stopping=False):
753
+ # Disarm through the original manager before its KeepAlive can admit a successor.
754
+ owed['backend_stop_requested'] = True
755
+ self.save()
756
+ verify_processes(owed, self.records, stopping=stopping)
757
+ if _config_stamp(owed) != owed['config_stamp']:
758
+ raise RuntimeError(f'{_name(owed)} service configuration changed before backend stop; recovery remains owed.')
759
+ if stopped.is_set():
760
+ raise RuntimeError(f'{_name(owed)} backend stop was cancelled; recovery remains owed.')
761
+ self.pause._disarm(service_entry(owed))
762
+ wait_stopped(owed, self.records, stopped)
763
+ verify_processes(owed, self.records, stopping=True)
764
+ owed['phase'] = 'stopped'
765
+ self.save()
766
+
767
+ def refresh(self, entry, stopped, ledger):
768
+ owed = next(e for e in self.state['entries'] if _service_key(e) == _service_key(entry))
769
+ if owed.get('legacy_refresh'):
770
+ raise RuntimeError(f'{_name(entry)} legacy shutdown remains owed; no signal was replayed.')
771
+ if _config_stamp(owed) != owed['config_stamp']:
772
+ raise RuntimeError(f'{_name(entry)} configuration changed before legacy refresh.')
773
+ verify_processes(owed, self.records)
774
+ verify_listener(owed)
775
+ owed.update(legacy_refresh=True, phase='refreshing')
776
+ self.save() # Older readers reject this phase and retain debt; no reader replays its signal.
777
+ verify_processes(owed, self.records)
778
+ verify_listener(owed)
779
+ if _config_stamp(owed) != owed['config_stamp']:
780
+ raise RuntimeError(f'{_name(owed)} configuration changed before legacy refresh.')
781
+ verify_processes(owed, self.records)
782
+ try:
783
+ if stopped.is_set():
784
+ raise InterruptedError('Legacy refresh was cancelled before interruption.')
785
+ os.kill(owed['pid'], signal.SIGTERM)
786
+ except ProcessLookupError:
787
+ pass # The consented incarnation already exited; only a verified successor can discharge debt.
788
+ except (PermissionError, InterruptedError) as exc:
789
+ self.state['entries'].remove(owed)
790
+ self.save() # The OS definitively refused delivery, so a new confirmation may retry it.
791
+ raise RuntimeError(f'{_name(owed)} graceful refresh was cancelled or its signal was refused; source was not changed.') from exc
792
+ self._refresh_successor(owed, stopped, ledger)
793
+
794
+ def _refresh_successor(self, owed, stopped, ledger):
795
+ wait_stopped(owed, self.records, stopped, writers_only=True)
796
+ deadline = time.monotonic() + READY_SECONDS
797
+ started = False
798
+ while True:
799
+ candidate = self._ready(owed, ledger)
800
+ if candidate:
801
+ if any(self.records.identity_is_live({'pid': int(pid), 'ct': p['ct']})
802
+ for pid, p in owed['processes'].items()
803
+ if int(pid) != owed['service_pid'] or int(pid) == owed['pid']):
804
+ raise RuntimeError(f'{_name(owed)} old writer descendants remain; recovery remains owed.')
805
+ owed.clear()
806
+ owed.update(candidate, phase='recorded', config_stamp=_config_stamp(candidate))
807
+ self.save()
808
+ return
809
+ if not _service_pid(owed)[1] and not started:
810
+ key = _service_key(owed)
811
+ if key in self._refresh_starts:
812
+ raise RuntimeError(f'{_name(owed)} legacy refresh start was already attempted; retry Agent update recovery. Source was not changed and recovery remains owed.')
813
+ whole, why = self.records.tree_is_whole(self.state['tree'], self.root)
814
+ if not whole or self.state.get('sync_failed'):
815
+ raise RuntimeError('Legacy refresh source/dependency recovery remains owed: ' + why)
816
+ self._refresh_starts.add(key)
817
+ _start(owed, self.pause)
818
+ started = True
819
+ if stopped.wait(.05) or time.monotonic() >= deadline:
820
+ raise RuntimeError(f'{_name(owed)} legacy refresh readiness is unverified; source was not changed and recovery remains owed.')
821
+
822
+ def gateway_requested(self, entry, *, acknowledged=False):
823
+ if not entry.get('processes'):
824
+ return
825
+ owed = next(e for e in self.state['entries'] if _service_key(e) == _service_key(entry))
826
+ owed['phase'] = 'retiring'
827
+ if acknowledged:
828
+ owed['drain_acknowledged'] = True
829
+ self.save()
830
+
831
+ def _gateway_candidate(self, entry):
832
+ units, jobs = self.pause._discover_systemd(), self.pause._discover_launchd()
833
+ matches = [e for e in units + jobs if _service_key(e) == _service_key(entry)]
834
+ if not matches:
835
+ return None
836
+ if len(matches) != 1 or not matches[0].get('home') or Path(matches[0]['home']).resolve() != Path(entry['home']).resolve():
837
+ raise RuntimeError(f'{_name(entry)} successor supervisor/profile is unverified; recovery remains owed.')
838
+ candidates = gateway_entries(matches, {Path(entry['home']).resolve()}, self.pause, allow_starting=True)
839
+ if not candidates:
840
+ return None
841
+ candidate = candidates[0]
842
+ if (_config_stamp(candidate) != entry['config_stamp']
843
+ or candidate.get('profile') != entry.get('profile')):
844
+ raise RuntimeError(f'{_name(entry)} successor configuration/profile changed; recovery remains owed.')
845
+ bind_processes(candidate, self.records)
846
+ parents = {int(pid): p['parent'] for pid, p in candidate['processes'].items()}
847
+ chain, pid = set(), candidate['pid']
848
+ while True:
849
+ chain.add(pid)
850
+ if pid == candidate['service_pid']:
851
+ break
852
+ pid = parents[pid]
853
+ if chain | _descendants(candidate['pid'], parents) != set(parents):
854
+ raise RuntimeError(f'{_name(entry)} successor has unrelated descendants; recovery remains owed.')
855
+ for pid, process in candidate['processes'].items():
856
+ old = entry['processes'].get(pid) or entry.get('retired_processes', {}).get(pid)
857
+ if old is not None and old != process:
858
+ raise RuntimeError(f'{_name(entry)} successor reused a recorded process identity; recovery remains owed.')
859
+ return candidate
860
+
861
+ def _gateway_writers_live(self, entry):
862
+ processes = {**entry.get('retired_processes', {}), **entry['processes']}
863
+ return any(self.records.identity_is_live({'pid': int(pid), 'ct': p['ct']})
864
+ for pid, p in processes.items()
865
+ if int(pid) != entry['service_pid'] or int(pid) == entry['pid'])
866
+
867
+ def gateway_deadline(self):
868
+ return time.monotonic() + min(self.pause._drain_budget(), 300)
869
+
870
+ def settle_gateway(self, entry, stopped, *, deadline=None):
871
+ owed = next(e for e in self.state['entries'] if _service_key(e) == _service_key(entry))
872
+ self._settle_gateway(owed, stopped, deadline=deadline)
873
+
874
+ def _settle_gateway(self, entry, stopped=None, *, deadline=None):
875
+ if not self._gateway_writers_live(entry) and not any(_service_pid(entry)):
876
+ if _config_stamp(entry) != entry['config_stamp']:
877
+ raise RuntimeError(f'{_name(entry)} service configuration changed; recovery remains owed.')
878
+ entry['phase'] = 'stopped'
879
+ self.save()
880
+ return
881
+ stopped = stopped or threading.Event()
882
+ if deadline is None:
883
+ deadline = self.gateway_deadline()
884
+ while True:
885
+ if stopped.is_set() or time.monotonic() >= deadline:
886
+ raise RuntimeError(f'{_name(entry)} gateway drain remains owed; retry Agent update recovery.')
887
+ if _config_stamp(entry) != entry['config_stamp']:
888
+ raise RuntimeError(f'{_name(entry)} service configuration changed; recovery remains owed.')
889
+ if self._gateway_writers_live(entry):
890
+ if not entry.get('drain_acknowledged'):
891
+ raise RuntimeError(f'{_name(entry)} gateway drain remains owed; retry Agent update recovery.')
892
+ stopped.wait(.05)
893
+ continue
894
+ entry['runtime_fence_requested'] = True
895
+ self.save()
896
+ with gateway_fence(entry) as fence:
897
+ if fence is not None:
898
+ while True:
899
+ _verify_fence(entry, fence)
900
+ if stopped.is_set() or time.monotonic() >= deadline:
901
+ raise RuntimeError(f'{_name(entry)} gateway startup contenders remain; recovery remains owed.')
902
+ _, service_pid = _service_pid(entry)
903
+ parents = _process_table()
904
+ # Pre-fence starters may still be in native stale-file cleanup.
905
+ # Wait for every unrecorded process to exit, never stop them.
906
+ pending = service_pid and (service_pid != entry['service_pid'] or any(
907
+ str(pid) not in entry['processes'] for pid in _descendants(service_pid, parents)))
908
+ if pending:
909
+ stopped.wait(.05)
910
+ continue
911
+ if not verify_processes(entry, self.records, stopping=True, fenced=True):
912
+ stopped.wait(.05)
913
+ continue
914
+ if self._gateway_writers_live(entry) or _config_stamp(entry) != entry['config_stamp']:
915
+ raise RuntimeError(f'{_name(entry)} gateway custody changed; recovery remains owed.')
916
+ _verify_fence(entry, fence)
917
+ self.pause._disarm(service_entry(entry))
918
+ wait_stopped(entry, self.records, stopped)
919
+ _verify_fence(entry, fence)
920
+ entry['phase'] = 'stopped'
921
+ self.save()
922
+ return
923
+ if not entry.get('drain_acknowledged'):
924
+ raise RuntimeError(f'{_name(entry)} successor appeared without an acknowledged drain; recovery remains owed.')
925
+ candidate = self._gateway_candidate(entry)
926
+ if candidate is not None:
927
+ attempts = entry.get('settle_attempts', 0) + 1
928
+ if attempts > 8:
929
+ raise RuntimeError(f'{_name(entry)} repeatedly respawned during drain; recovery remains owed.')
930
+ retired = {**entry.get('retired_processes', {}), **entry['processes']}
931
+ entry.update(candidate, retired_processes=retired, phase='retiring',
932
+ drain_acknowledged=False, settle_attempts=attempts)
933
+ self.save() # Successor custody precedes its cooperative lifecycle request.
934
+ verify_processes(entry, self.records)
935
+ if _config_stamp(entry) != entry['config_stamp']:
936
+ raise RuntimeError(f'{_name(entry)} service configuration changed; recovery remains owed.')
937
+ if not self.pause._ask_to_drain(entry):
938
+ raise RuntimeError(f'{_name(entry)} successor did not acknowledge cooperative drain; recovery remains owed.')
939
+ entry['drain_acknowledged'] = True
940
+ self.save()
941
+ stopped.wait(.05)
942
+
943
+ def resume(self, ledger, *, gateway_deadline=None):
944
+ if not self.state['entries']:
945
+ return
946
+ whole, why = self.records.tree_is_whole(self.state['tree'], self.root)
947
+ if not whole or self.state.get('sync_failed'):
948
+ raise RuntimeError('Backend recovery remains owed until source/dependency repair completes: ' + why)
949
+ failures = []
950
+ for entry in list(self.state['entries']):
951
+ try:
952
+ if self._ready(entry, ledger):
953
+ self.state['entries'].remove(entry)
954
+ self.save()
955
+ continue
956
+ if entry.get('legacy_refresh'):
957
+ # EOF/crash recovery may adopt/start a successor, but never replay interruption consent.
958
+ if self.records.identity_is_live({'pid': entry['pid'], 'ct': entry['processes'][str(entry['pid'])]['ct']}):
959
+ raise RuntimeError(f'{_name(entry)} legacy shutdown remains owed; no signal was replayed.')
960
+ self._refresh_successor(entry, threading.Event(), ledger)
961
+ self.state['entries'].remove(entry)
962
+ self.save()
963
+ continue
964
+ if entry['purpose'] == 'gateway':
965
+ if gateway_deadline is None:
966
+ gateway_deadline = self.gateway_deadline()
967
+ self._settle_gateway(entry, deadline=gateway_deadline)
968
+ if not _service_pid(entry)[1]:
969
+ _start(entry, self.pause)
970
+ self._wait_ready(entry, ledger)
971
+ continue
972
+ original_live = self.records.identity_is_live({'pid': entry['pid'], 'ct': entry['processes'][str(entry['pid'])]['ct']})
973
+ if original_live:
974
+ if entry['phase'] == 'recorded':
975
+ raise RuntimeError(f'{_name(entry)} uncommitted retirement could not yet be cancelled; recovery remains owed.')
976
+ self.stop(entry, threading.Event())
977
+ elif any(self.records.identity_is_live({'pid': int(pid), 'ct': p['ct']}) for pid, p in entry['processes'].items()):
978
+ verify_processes(entry, self.records, stopping=True)
979
+ if any(self.records.identity_is_live({'pid': int(pid), 'ct': p['ct']}) for pid, p in entry['processes'].items() if int(pid) != entry['service_pid']):
980
+ raise RuntimeError(f'{_name(entry)} still has live writer descendants; recovery remains owed.')
981
+ self._stop_backend_supervisor(entry, threading.Event(), stopping=True)
982
+ _, active = _service_pid(entry)
983
+ if not active:
984
+ _start(entry, self.pause)
985
+ self._wait_ready(entry, ledger)
986
+ except Exception as exc:
987
+ failures.append(str(exc))
988
+ if failures:
989
+ raise RuntimeError('; '.join(failures))
990
+
991
+ def _wait_ready(self, entry, ledger):
992
+ deadline = time.monotonic() + READY_SECONDS
993
+ while True:
994
+ if self._ready(entry, ledger):
995
+ self.state['entries'].remove(entry)
996
+ self.save()
997
+ return
998
+ if time.monotonic() >= deadline:
999
+ raise RuntimeError(f'{_name(entry)} readiness is unverified at its original endpoint; recovery remains owed.')
1000
+ time.sleep(.1)
1001
+
1002
+ def _ready(self, entry, ledger):
1003
+ if _config_stamp(entry) != entry['config_stamp']:
1004
+ raise RuntimeError(f'{_name(entry)} service configuration changed; backend recovery remains owed.')
1005
+ if entry['purpose'] == 'gateway':
1006
+ from gateway.status import get_running_pid_identity_strict
1007
+ runtime = get_running_pid_identity_strict(Path(entry['home']) / 'gateway.pid')
1008
+ if runtime is None:
1009
+ return False
1010
+ if entry['phase'] != 'recorded' and self._gateway_writers_live(entry):
1011
+ return False
1012
+ candidate = self._gateway_candidate(entry)
1013
+ if candidate is None:
1014
+ return False
1015
+ ready = _gateway_home_matches(entry['home'], (candidate['pid'], candidate['ct']), self.pause)
1016
+ verify_processes(candidate, self.records)
1017
+ return ready
1018
+ if entry.get('legacy_refresh') and any(self.records.identity_is_live({'pid': int(pid), 'ct': p['ct']})
1019
+ for pid, p in entry['processes'].items()
1020
+ if int(pid) != entry['service_pid'] or int(pid) == entry['pid']):
1021
+ return False
1022
+ candidates = [row for row, live in ledger(self.root, self.identity) if live is True
1023
+ and row.get('purpose') == entry['purpose']
1024
+ and row.get('host') == entry['host'] and row.get('port') == entry['port']]
1025
+ if len(candidates) != 1:
1026
+ return False
1027
+ row = candidates[0]
1028
+ if (row['pid'] == entry['pid'] and entry['phase'] != 'recorded'
1029
+ and self.records.identity_is_live({'pid': entry['pid'], 'ct': entry['processes'][str(entry['pid'])]['ct']})):
1030
+ return False # A committed retirement on the original process closes admission permanently.
1031
+ candidate = backend_entry(row, {Path(entry['home']).resolve()}, self.pause, self.identity)
1032
+ if (candidate['profile'] != entry['profile'] or _service_key(candidate) != _service_key(entry)
1033
+ or _config_stamp(candidate) != entry['config_stamp']):
1034
+ return False
1035
+ bind_processes(candidate, self.records)
1036
+ try:
1037
+ if entry.get('legacy_refresh'):
1038
+ verify_listener(candidate)
1039
+ backend_request(candidate)
1040
+ if entry['phase'] == 'recorded' and row['pid'] == entry['pid']:
1041
+ permit = backend_request(candidate, 'prepare')
1042
+ if permit.get('ok') is not True or permit.get('idle') is not True or not isinstance(permit.get('token'), str):
1043
+ return False
1044
+ if backend_request(candidate, 'cancel', permit['token']).get('ok') is not True:
1045
+ return False
1046
+ verify_processes(candidate, self.records)
1047
+ if self.identity._pid_alive_matches(row['pid'], row['create_time'], strict=True) is not True:
1048
+ return False
1049
+ except BackendLifecycleUnsupported:
1050
+ raise
1051
+ except RuntimeError:
1052
+ return False
1053
+ return candidate