@maudecode/talaria-web 1.18.0 → 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.
- package/_release.json +8 -8
- package/dist/api/auth-raw.js +16 -16
- package/dist/api/auth-raw.js.map +1 -1
- package/dist/api/automation-raw.d.ts +2 -2
- package/dist/api/automation-raw.js +32 -32
- package/dist/api/automation-raw.js.map +1 -1
- package/dist/api/automation-router.d.ts +102 -22
- package/dist/api/automation-router.js +15 -13
- package/dist/api/automation-router.js.map +1 -1
- package/dist/api/chat-router.d.ts +1205 -34
- package/dist/api/chat-router.js +158 -78
- package/dist/api/chat-router.js.map +1 -1
- package/dist/api/git-router.d.ts +2 -2
- package/dist/api/git-router.js +29 -29
- package/dist/api/git-router.js.map +1 -1
- package/dist/api/raw-routes.d.ts +3 -3
- package/dist/api/raw-routes.js +63 -61
- package/dist/api/raw-routes.js.map +1 -1
- package/dist/api/router.d.ts +6 -6
- package/dist/api/router.js +22 -22
- package/dist/api/router.js.map +1 -1
- package/dist/api/session-visibility.d.ts +4 -4
- package/dist/api/session-visibility.js +26 -16
- package/dist/api/session-visibility.js.map +1 -1
- package/dist/api/sessions-router.d.ts +1928 -124
- package/dist/api/sessions-router.js +177 -173
- package/dist/api/sessions-router.js.map +1 -1
- package/dist/api/settings-router.d.ts +427 -9
- package/dist/api/settings-router.js +48 -32
- package/dist/api/settings-router.js.map +1 -1
- package/dist/api/sse-routes.d.ts +2 -2
- package/dist/api/sse-routes.js +63 -63
- package/dist/api/sse-routes.js.map +1 -1
- package/dist/api/tools-raw.d.ts +5 -0
- package/dist/api/tools-raw.js +58 -33
- package/dist/api/tools-raw.js.map +1 -1
- package/dist/api/tools-router.d.ts +663 -91
- package/dist/api/tools-router.js +309 -52
- package/dist/api/tools-router.js.map +1 -1
- package/dist/api/transcribe-stream.d.ts +16 -0
- package/dist/api/transcribe-stream.js +172 -0
- package/dist/api/transcribe-stream.js.map +1 -0
- package/dist/app.d.ts +4 -1
- package/dist/app.js +66 -47
- package/dist/app.js.map +1 -1
- package/dist/auth/gate.js +10 -9
- package/dist/auth/gate.js.map +1 -1
- package/dist/auth/passkeys.d.ts +15 -14
- package/dist/auth/passkeys.js +89 -91
- package/dist/auth/passkeys.js.map +1 -1
- package/dist/auth/store.d.ts +17 -7
- package/dist/auth/store.js +93 -52
- package/dist/auth/store.js.map +1 -1
- package/dist/bin/talaria-web.js +12 -81
- package/dist/bin/talaria-web.js.map +1 -1
- package/dist/cli/ctl.js +2 -1
- package/dist/cli/ctl.js.map +1 -1
- package/dist/cli/dotenv.d.ts +5 -0
- package/dist/cli/dotenv.js +14 -2
- package/dist/cli/dotenv.js.map +1 -1
- package/dist/cli/launcher.js +5 -5
- package/dist/cli/launcher.js.map +1 -1
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +85 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/supervise.d.ts +2 -0
- package/dist/cli/supervise.js +67 -4
- package/dist/cli/supervise.js.map +1 -1
- package/dist/config/agent-config.d.ts +34 -11
- package/dist/config/agent-config.js +113 -58
- package/dist/config/agent-config.js.map +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +14 -5
- package/dist/config.js.map +1 -1
- package/dist/fs/atomic.d.ts +1 -0
- package/dist/fs/atomic.js +4 -1
- package/dist/fs/atomic.js.map +1 -1
- package/dist/fs/stat.d.ts +1 -0
- package/dist/fs/stat.js +11 -0
- package/dist/fs/stat.js.map +1 -0
- package/dist/http/context.d.ts +14 -9
- package/dist/http/context.js +29 -20
- package/dist/http/context.js.map +1 -1
- package/dist/onboarding.d.ts +2 -2
- package/dist/onboarding.js +10 -8
- package/dist/onboarding.js.map +1 -1
- package/dist/profiles/profiles.d.ts +10 -4
- package/dist/profiles/profiles.js +77 -30
- package/dist/profiles/profiles.js.map +1 -1
- package/dist/projects.d.ts +15 -6
- package/dist/projects.js +55 -47
- package/dist/projects.js.map +1 -1
- package/dist/providers/catalog.d.ts +78 -12
- package/dist/providers/catalog.js +445 -141
- package/dist/providers/catalog.js.map +1 -1
- package/dist/providers/env-file.d.ts +10 -3
- package/dist/providers/env-file.js +24 -75
- package/dist/providers/env-file.js.map +1 -1
- package/dist/providers/runtime-env.d.ts +4 -2
- package/dist/providers/runtime-env.js +9 -6
- package/dist/providers/runtime-env.js.map +1 -1
- package/dist/providers/tables.d.ts +3 -1
- package/dist/providers/tables.js +4 -2
- package/dist/providers/tables.js.map +1 -1
- package/dist/redact.js +18 -9
- package/dist/redact.js.map +1 -1
- package/dist/release.d.ts +7 -0
- package/dist/release.js +14 -0
- package/dist/release.js.map +1 -1
- package/dist/runtime.d.ts +5 -1
- package/dist/runtime.js +233 -90
- package/dist/runtime.js.map +1 -1
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -1
- package/dist/sessions/anchor.d.ts +9 -3
- package/dist/sessions/anchor.js +28 -36
- package/dist/sessions/anchor.js.map +1 -1
- package/dist/sessions/background-events.d.ts +2 -0
- package/dist/sessions/background-events.js +10 -0
- package/dist/sessions/background-events.js.map +1 -0
- package/dist/sessions/background-tasks.d.ts +26 -20
- package/dist/sessions/background-tasks.js +88 -86
- package/dist/sessions/background-tasks.js.map +1 -1
- package/dist/sessions/background-updates.d.ts +15 -8
- package/dist/sessions/background-updates.js +101 -25
- package/dist/sessions/background-updates.js.map +1 -1
- package/dist/sessions/cli-sessions.d.ts +15 -5
- package/dist/sessions/cli-sessions.js +62 -43
- package/dist/sessions/cli-sessions.js.map +1 -1
- package/dist/sessions/completions.d.ts +30 -2
- package/dist/sessions/completions.js +294 -53
- package/dist/sessions/completions.js.map +1 -1
- package/dist/sessions/compress.d.ts +8 -0
- package/dist/sessions/compress.js +2 -2
- package/dist/sessions/compress.js.map +1 -1
- package/dist/sessions/drafts.d.ts +14 -10
- package/dist/sessions/drafts.js +38 -43
- package/dist/sessions/drafts.js.map +1 -1
- package/dist/sessions/gateway-sessions.d.ts +46 -0
- package/dist/sessions/gateway-sessions.js +378 -0
- package/dist/sessions/gateway-sessions.js.map +1 -0
- package/dist/sessions/goals.d.ts +31 -0
- package/dist/sessions/goals.js +75 -0
- package/dist/sessions/goals.js.map +1 -0
- package/dist/sessions/journal.d.ts +5 -79
- package/dist/sessions/journal.js +15 -606
- package/dist/sessions/journal.js.map +1 -1
- package/dist/sessions/list.d.ts +27 -18
- package/dist/sessions/list.js +133 -131
- package/dist/sessions/list.js.map +1 -1
- package/dist/sessions/merge.d.ts +68 -10
- package/dist/sessions/merge.js +259 -51
- package/dist/sessions/merge.js.map +1 -1
- package/dist/sessions/notices.d.ts +22 -0
- package/dist/sessions/notices.js +72 -0
- package/dist/sessions/notices.js.map +1 -0
- package/dist/sessions/pending.d.ts +10 -4
- package/dist/sessions/pending.js +83 -7
- package/dist/sessions/pending.js.map +1 -1
- package/dist/sessions/prefix-index.d.ts +82 -0
- package/dist/sessions/prefix-index.js +232 -0
- package/dist/sessions/prefix-index.js.map +1 -0
- package/dist/sessions/recovery.d.ts +4 -7
- package/dist/sessions/recovery.js +97 -281
- package/dist/sessions/recovery.js.map +1 -1
- package/dist/sessions/relay.d.ts +20 -20
- package/dist/sessions/relay.js +205 -127
- package/dist/sessions/relay.js.map +1 -1
- package/dist/sessions/service.d.ts +162 -59
- package/dist/sessions/service.js +1133 -521
- package/dist/sessions/service.js.map +1 -1
- package/dist/sessions/session.d.ts +18 -2
- package/dist/sessions/session.js +29 -5
- package/dist/sessions/session.js.map +1 -1
- package/dist/sessions/shares.d.ts +7 -6
- package/dist/sessions/shares.js +22 -45
- package/dist/sessions/shares.js.map +1 -1
- package/dist/sessions/source-kind.js +1 -0
- package/dist/sessions/source-kind.js.map +1 -1
- package/dist/sessions/state-db-identity.d.ts +18 -0
- package/dist/sessions/state-db-identity.js +67 -0
- package/dist/sessions/state-db-identity.js.map +1 -0
- package/dist/sessions/state-db-worker.js +3 -1
- package/dist/sessions/state-db-worker.js.map +1 -1
- package/dist/sessions/state-db.d.ts +53 -8
- package/dist/sessions/state-db.js +576 -50
- package/dist/sessions/state-db.js.map +1 -1
- package/dist/sessions/storage/backend.d.ts +162 -0
- package/dist/sessions/storage/backend.js +19 -0
- package/dist/sessions/storage/backend.js.map +1 -0
- package/dist/sessions/storage/conformance.d.ts +20 -0
- package/dist/sessions/storage/conformance.js +237 -0
- package/dist/sessions/storage/conformance.js.map +1 -0
- package/dist/sessions/storage/document.d.ts +16 -0
- package/dist/sessions/storage/document.js +44 -0
- package/dist/sessions/storage/document.js.map +1 -0
- package/dist/sessions/storage/json-documents.d.ts +23 -0
- package/dist/sessions/storage/json-documents.js +233 -0
- package/dist/sessions/storage/json-documents.js.map +1 -0
- package/dist/sessions/storage/json-files.d.ts +12 -0
- package/dist/sessions/storage/json-files.js +124 -0
- package/dist/sessions/storage/json-files.js.map +1 -0
- package/dist/sessions/storage/json-installation.d.ts +3 -0
- package/dist/sessions/storage/json-installation.js +31 -0
- package/dist/sessions/storage/json-installation.js.map +1 -0
- package/dist/sessions/storage/json-journal.d.ts +114 -0
- package/dist/sessions/storage/json-journal.js +686 -0
- package/dist/sessions/storage/json-journal.js.map +1 -0
- package/dist/sessions/storage/json-recovery.d.ts +4 -0
- package/dist/sessions/storage/json-recovery.js +195 -0
- package/dist/sessions/storage/json-recovery.js.map +1 -0
- package/dist/sessions/storage/json.d.ts +68 -0
- package/dist/sessions/storage/json.js +258 -0
- package/dist/sessions/storage/json.js.map +1 -0
- package/dist/sessions/storage/row.d.ts +6 -0
- package/dist/sessions/storage/row.js +49 -0
- package/dist/sessions/storage/row.js.map +1 -0
- package/dist/sessions/storage/search-text.d.ts +4 -0
- package/dist/sessions/storage/search-text.js +16 -0
- package/dist/sessions/storage/search-text.js.map +1 -0
- package/dist/sessions/store.d.ts +63 -73
- package/dist/sessions/store.js +305 -611
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/streams.d.ts +4 -0
- package/dist/sessions/streams.js +9 -1
- package/dist/sessions/streams.js.map +1 -1
- package/dist/sessions/titles.js +1 -1
- package/dist/sessions/titles.js.map +1 -1
- package/dist/sessions/todo.d.ts +5 -0
- package/dist/sessions/todo.js +28 -21
- package/dist/sessions/todo.js.map +1 -1
- package/dist/sessions/tool-display.d.ts +5 -4
- package/dist/sessions/tool-display.js +22 -16
- package/dist/sessions/tool-display.js.map +1 -1
- package/dist/sessions/turn-context.d.ts +10 -1
- package/dist/sessions/turn-context.js +7 -4
- package/dist/sessions/turn-context.js.map +1 -1
- package/dist/sessions/turn.d.ts +87 -17
- package/dist/sessions/turn.js +795 -347
- package/dist/sessions/turn.js.map +1 -1
- package/dist/sessions/wakeup-pause.d.ts +8 -13
- package/dist/sessions/wakeup-pause.js +31 -49
- package/dist/sessions/wakeup-pause.js.map +1 -1
- package/dist/sessions/window.js +13 -12
- package/dist/sessions/window.js.map +1 -1
- package/dist/settings.d.ts +19 -7
- package/dist/settings.js +179 -183
- package/dist/settings.js.map +1 -1
- package/dist/sidecar/client.d.ts +17 -3
- package/dist/sidecar/client.js +78 -8
- package/dist/sidecar/client.js.map +1 -1
- package/dist/sidecar/discover.d.ts +2 -1
- package/dist/sidecar/discover.js +93 -10
- package/dist/sidecar/discover.js.map +1 -1
- package/dist/sidecar/env.d.ts +4 -0
- package/dist/sidecar/env.js +3 -0
- package/dist/sidecar/env.js.map +1 -0
- package/dist/sidecar/fake.js +19 -2
- package/dist/sidecar/fake.js.map +1 -1
- package/dist/test/env-file.d.ts +5 -0
- package/dist/test/env-file.js +88 -0
- package/dist/test/env-file.js.map +1 -0
- package/dist/test/harness.d.ts +1 -0
- package/dist/test/harness.js +1 -1
- package/dist/test/harness.js.map +1 -1
- package/dist/test/passkeys.d.ts +22 -0
- package/dist/test/passkeys.js +62 -0
- package/dist/test/passkeys.js.map +1 -0
- package/dist/test/storage.d.ts +15 -0
- package/dist/test/storage.js +26 -0
- package/dist/test/storage.js.map +1 -0
- package/dist/tools/commands.d.ts +6 -1
- package/dist/tools/commands.js +8 -2
- package/dist/tools/commands.js.map +1 -1
- package/dist/tools/connectors.d.ts +33 -0
- package/dist/tools/connectors.js +121 -0
- package/dist/tools/connectors.js.map +1 -0
- package/dist/tools/crons.d.ts +38 -1
- package/dist/tools/crons.js +82 -15
- package/dist/tools/crons.js.map +1 -1
- package/dist/tools/dashboard-plugins.d.ts +5 -5
- package/dist/tools/dashboard-plugins.js +14 -14
- package/dist/tools/dashboard-plugins.js.map +1 -1
- package/dist/tools/extensions.d.ts +5 -4
- package/dist/tools/extensions.js +213 -207
- package/dist/tools/extensions.js.map +1 -1
- package/dist/tools/gateway-restart.d.ts +62 -0
- package/dist/tools/gateway-restart.js +142 -0
- package/dist/tools/gateway-restart.js.map +1 -0
- package/dist/tools/health.d.ts +15 -3
- package/dist/tools/health.js +97 -34
- package/dist/tools/health.js.map +1 -1
- package/dist/tools/hygiene.d.ts +4 -3
- package/dist/tools/hygiene.js +23 -9
- package/dist/tools/hygiene.js.map +1 -1
- package/dist/tools/kanban.d.ts +4 -2
- package/dist/tools/kanban.js +28 -19
- package/dist/tools/kanban.js.map +1 -1
- package/dist/tools/memory-masks.d.ts +5 -0
- package/dist/tools/memory-masks.js +256 -0
- package/dist/tools/memory-masks.js.map +1 -0
- package/dist/tools/memory.d.ts +29 -3
- package/dist/tools/memory.js +69 -27
- package/dist/tools/memory.js.map +1 -1
- package/dist/tools/plugin-settings.d.ts +15 -0
- package/dist/tools/plugin-settings.js +90 -0
- package/dist/tools/plugin-settings.js.map +1 -0
- package/dist/tools/skills.d.ts +9 -4
- package/dist/tools/skills.js +44 -13
- package/dist/tools/skills.js.map +1 -1
- package/dist/tools/update-notifications.d.ts +34 -15
- package/dist/tools/update-notifications.js +141 -57
- package/dist/tools/update-notifications.js.map +1 -1
- package/dist/tools/update-rollback.d.ts +11 -0
- package/dist/tools/update-rollback.js +28 -0
- package/dist/tools/update-rollback.js.map +1 -0
- package/dist/tools/updates.d.ts +93 -33
- package/dist/tools/updates.js +1051 -345
- package/dist/tools/updates.js.map +1 -1
- package/dist/util.d.ts +2 -0
- package/dist/util.js +5 -0
- package/dist/util.js.map +1 -1
- package/dist/workspace/git.d.ts +16 -1
- package/dist/workspace/git.js +291 -11
- package/dist/workspace/git.js.map +1 -1
- package/dist/workspace/media.d.ts +12 -9
- package/dist/workspace/media.js +31 -20
- package/dist/workspace/media.js.map +1 -1
- package/dist/workspace/rollback.d.ts +2 -2
- package/dist/workspace/rollback.js +5 -5
- package/dist/workspace/rollback.js.map +1 -1
- package/dist/workspace/workspaces.d.ts +15 -19
- package/dist/workspace/workspaces.js +55 -127
- package/dist/workspace/workspaces.js.map +1 -1
- package/dist/workspace/worktrees.js +170 -32
- package/dist/workspace/worktrees.js.map +1 -1
- package/package.json +5 -3
- package/sidecar/agent_dependency.json +4 -4
- package/sidecar/agent_support.json +26 -0
- package/sidecar/scripts/record-fixtures.py +12 -3
- package/sidecar/scripts/replay_sidecar.py +8 -0
- package/sidecar/scripts/suite_runner.py +302 -0
- package/sidecar/scripts/test.sh +12 -4
- package/sidecar/talaria_sidecar/__init__.py +6 -1
- package/sidecar/talaria_sidecar/__main__.py +4 -2
- package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
- package/sidecar/talaria_sidecar/agent_services.py +1053 -0
- package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
- package/sidecar/talaria_sidecar/home.py +43 -1
- package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
- package/sidecar/talaria_sidecar/methods/aux.py +139 -31
- package/sidecar/talaria_sidecar/methods/chat.py +847 -134
- package/sidecar/talaria_sidecar/methods/commands.py +213 -4
- package/sidecar/talaria_sidecar/methods/config.py +123 -4
- package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
- package/sidecar/talaria_sidecar/methods/cron.py +366 -34
- package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
- package/sidecar/talaria_sidecar/methods/gateway.py +194 -54
- package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
- package/sidecar/talaria_sidecar/methods/goals.py +81 -41
- package/sidecar/talaria_sidecar/methods/kanban.py +80 -27
- package/sidecar/talaria_sidecar/methods/memory.py +84 -0
- package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
- package/sidecar/talaria_sidecar/methods/process.py +160 -11
- package/sidecar/talaria_sidecar/methods/profiles.py +44 -6
- package/sidecar/talaria_sidecar/methods/providers.py +196 -26
- package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
- package/sidecar/talaria_sidecar/methods/skills.py +67 -4
- package/sidecar/talaria_sidecar/methods/state_db.py +277 -70
- package/sidecar/talaria_sidecar/methods/stt.py +159 -78
- package/sidecar/talaria_sidecar/methods/text.py +14 -1
- package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
- package/sidecar/talaria_sidecar/methods/usage.py +113 -14
- package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
- package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
- package/sidecar/talaria_sidecar/rpc.py +45 -4
- package/sidecar/talaria_sidecar/runtime.py +81 -12
- package/sidecar/talaria_sidecar/speed.py +35 -0
- package/static/dist/FILES.txt +126 -127
- package/static/dist/assets/AppShell-cey33v9s.js +8 -0
- package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
- package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
- package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
- package/static/dist/assets/Field-j8uqoigz.js +9 -0
- package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
- package/static/dist/assets/Markdown-lahuichm.js +598 -0
- package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
- package/static/dist/assets/Select-gqatm2e0.js +1 -0
- package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
- package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
- package/static/dist/assets/States-e7dkgtxf.js +1 -0
- package/static/dist/assets/Toaster-bfqqy786.js +1 -0
- package/static/dist/assets/_app-n1n6pfx1.js +1 -0
- package/static/dist/assets/{_app.ext._extensionId-gfbzpoou.js → _app.ext._extensionId-buxjxav3.js} +1 -1
- package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
- package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
- package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
- package/static/dist/assets/_app.logs-dm873z90.js +1 -0
- package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
- package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
- package/static/dist/assets/{_app.session._sessionId-fn1bhde6.js → _app.session._sessionId-g2vzghvc.js} +1 -1
- package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
- package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
- package/static/dist/assets/_app.skills-cii852yq.js +1 -0
- package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
- package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
- package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
- package/static/dist/assets/{abnfDiagram-VCTEODGH-c8kdixke.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
- package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
- package/static/dist/assets/{architectureDiagram-5GKGNRK7-cku06x1p.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
- package/static/dist/assets/archive-b1j004vn.js +1 -0
- package/static/dist/assets/back-g74gkozu.js +1 -0
- package/static/dist/assets/{blockDiagram-I7D4REHJ-jzthp1wa.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
- package/static/dist/assets/{c4Diagram-7LVT6UL2-mwr67mt8.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
- package/static/dist/assets/channel-es95o4y8.js +1 -0
- package/static/dist/assets/{chunk-4HAMMTFA-gpq1y228.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
- package/static/dist/assets/{chunk-75Z2AOVW-ktje58c4.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
- package/static/dist/assets/{chunk-DU6HZSFF-ofajdixh.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
- package/static/dist/assets/{chunk-F27PBJKO-cmev85ti.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
- package/static/dist/assets/{chunk-GMAD6QVW-htjgt22y.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
- package/static/dist/assets/{chunk-GVQU2GXP-qndvoxl7.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
- package/static/dist/assets/{chunk-IMKFNOWR-e5bkd5q6.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
- package/static/dist/assets/{chunk-L3NEJ4N5-mj1155r6.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
- package/static/dist/assets/{chunk-OSK3NFVY-e7gkikcw.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
- package/static/dist/assets/{chunk-P2QGCYS3-f7t54xfm.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
- package/static/dist/assets/{chunk-POPQ4Y6H-i927v9oa.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
- package/static/dist/assets/{chunk-PWAF6VOD-m84lmnyt.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
- package/static/dist/assets/{chunk-SHT3W25Y-mi0tlcu9.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
- package/static/dist/assets/{chunk-SVP7TREG-dnau702j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
- package/static/dist/assets/{chunk-TICWLB2K-edem1gkg.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
- package/static/dist/assets/copied-bli9tbcx.js +1 -0
- package/static/dist/assets/{copy-magtofyp.js → copy-jamtd1pf.js} +1 -1
- package/static/dist/assets/{cynefin-OW5HDTMX-siqlulkl.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
- package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-i648tah9.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
- package/static/dist/assets/{dagre-GXQ25YYZ-mwn6uc17.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
- package/static/dist/assets/{diagram-S7CK7UJ4-l8abxaes.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
- package/static/dist/assets/{diagram-UQ7AKVKN-mekq7zgq.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
- package/static/dist/assets/{diagram-VSXAHHWV-fnky6lvc.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
- package/static/dist/assets/{diagram-VX7I27RA-xe6r5qfw.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
- package/static/dist/assets/{diagram-Z3DM3KII-nq07dg7k.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
- package/static/dist/assets/{ebnfDiagram-PWID7BFC-c62ipm3g.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
- package/static/dist/assets/{erDiagram-RLTQ6QDP-h39kmnzy.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
- package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
- package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
- package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
- package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ngbsyp37.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
- package/static/dist/assets/{gitGraph-4MIJSDKK-fr2i7caa.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
- package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-eravdti4.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
- package/static/dist/assets/{highlighted-body-KPVGNVTW-jwpop9p8.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
- package/static/dist/assets/host-hzm8n85n.js +1 -0
- package/static/dist/assets/index-drdcaqeq.css +2 -0
- package/static/dist/assets/index-jzm844he.js +11 -0
- package/static/dist/assets/{info-A6RAGUB7-h11x0dxo.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
- package/static/dist/assets/{infoDiagram-27XIBGKW-dfh47dw9.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
- package/static/dist/assets/{ishikawaDiagram-5VMMS53U-i6ypq351.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
- package/static/dist/assets/{journeyDiagram-3NMN7TZE-lduf1z95.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
- package/static/dist/assets/{kanban-definition-UXKFOSKX-e18bu4pg.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
- package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
- package/static/dist/assets/{line-meizqsvn.js → line-h0ojzsvs.js} +1 -1
- package/static/dist/assets/link-k01ryaf9.js +1 -0
- package/static/dist/assets/login-bsae49ew.js +1 -0
- package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
- package/static/dist/assets/{mermaid-parser.core-ntne45i9.js → mermaid-parser.core-bnre27u8.js} +3 -3
- package/static/dist/assets/{mindmap-definition-YA3MSWOX-27b26eok.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
- package/static/dist/assets/onboarding-c65tysya.js +1 -0
- package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
- package/static/dist/assets/{packet-AYTQ26CC-l6jdka8p.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
- package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
- package/static/dist/assets/{pegDiagram-XKGWAZYB-g1er3bxj.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
- package/static/dist/assets/{pie-WAS4IAKB-i0ylcrt9.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
- package/static/dist/assets/{pieDiagram-E7YTZNPT-fbz1e55z.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
- package/static/dist/assets/{quadrantDiagram-AXDQQJYC-hhue7d8o.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
- package/static/dist/assets/{radar-RG4KPBEZ-f9b70aa4.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
- package/static/dist/assets/{railroad-74A4TZTK-lgsuoeda.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
- package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
- package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
- package/static/dist/assets/{railroadDiagram-O6MQD6OU-eqpy9fnz.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
- package/static/dist/assets/{refresh-cw-hlwb02qv.js → refresh-cw-mqbkv2a3.js} +1 -1
- package/static/dist/assets/refresh-l29ljp76.js +1 -0
- package/static/dist/assets/{requirementDiagram-BXWQKSXE-ogf6puhi.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
- package/static/dist/assets/{sankeyDiagram-P5KCCOFB-fqb8xcjf.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
- package/static/dist/assets/saved-ndd4t2xb.js +1 -0
- package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-dlcz6qjz.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
- package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
- package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
- package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
- package/static/dist/assets/{stateDiagram-D77RDMKH-fyoznpcs.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
- package/static/dist/assets/{swimlanes-42K2YHIH-oefldhfy.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
- package/static/dist/assets/{timeline-definition-24CTP7MA-b1vl2zil.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
- package/static/dist/assets/toast-i3h34v6o.js +64 -0
- package/static/dist/assets/{treeView-Q6P3EWNA-f7hcci5o.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
- package/static/dist/assets/{treemap-WGGIJYW6-nl85071a.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
- package/static/dist/assets/untitled-oaoya4qj.js +1 -0
- package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
- package/static/dist/assets/{useParams-b4dq6n4q.js → useParams-gjc70qru.js} +1 -1
- package/static/dist/assets/{useSearch-ipfmv3he.js → useSearch-e6ae539u.js} +1 -1
- package/static/dist/assets/{vennDiagram-4TSXK5OY-h5hzbk0n.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
- package/static/dist/assets/{wardley-WFR3VGLG-jftvpm3e.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
- package/static/dist/assets/{wardleyDiagram-VM6X3IG4-hedh85ge.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
- package/static/dist/assets/{xychartDiagram-S5SC5T6Z-e49523ab.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
- package/static/dist/index.html +1 -1
- package/static/dist/sw.js +1 -1
- package/static/dist/assets/AppShell-g3bkgnvt.js +0 -1
- package/static/dist/assets/ChatPage-oo7bdan6.js +0 -25
- package/static/dist/assets/Dialog-g355qxhp.js +0 -1
- package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +0 -1
- package/static/dist/assets/HubRoute-lmkrbw47.js +0 -2
- package/static/dist/assets/Markdown-j4kut4j2.js +0 -598
- package/static/dist/assets/RightPanel-ohlsdzfl.js +0 -1
- package/static/dist/assets/Select-kddq75qc.js +0 -1
- package/static/dist/assets/SessionListPanel-lu3oy0hw.js +0 -1
- package/static/dist/assets/SettingsLayout-o9bv4mgb.js +0 -1
- package/static/dist/assets/States-eq6fe29m.js +0 -1
- package/static/dist/assets/Toaster-pc247u9f.js +0 -1
- package/static/dist/assets/_app-f8n1i0m1.js +0 -1
- package/static/dist/assets/_app.index-bzzv80hk.js +0 -1
- package/static/dist/assets/_app.insights-dzixbryi.js +0 -1
- package/static/dist/assets/_app.kanban-c6hep2us.js +0 -1
- package/static/dist/assets/_app.logs-i69qds98.js +0 -1
- package/static/dist/assets/_app.memory-kl4vf0gd.js +0 -1
- package/static/dist/assets/_app.profiles-b8zinmyp.js +0 -1
- package/static/dist/assets/_app.settings-cg8plwmf.js +0 -1
- package/static/dist/assets/_app.settings._section-bkwbi9zn.js +0 -3
- package/static/dist/assets/_app.skills-cs47sbvs.js +0 -1
- package/static/dist/assets/_app.tasks-dwvk64tc.js +0 -1
- package/static/dist/assets/_app.todos-c8xjm8p3.js +0 -1
- package/static/dist/assets/_app.workspaces-ehvd1gds.js +0 -1
- package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +0 -1
- package/static/dist/assets/back-h7aun7ew.js +0 -1
- package/static/dist/assets/cancel-fhi0vcnu.js +0 -1
- package/static/dist/assets/channel-b2zo73z1.js +0 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +0 -1
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +0 -1
- package/static/dist/assets/copied-hcplv46p.js +0 -1
- package/static/dist/assets/endpoints-klbxod4t.js +0 -64
- package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +0 -1
- package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +0 -1
- package/static/dist/assets/host-i828xuo7.js +0 -1
- package/static/dist/assets/index-eh8zilnv.css +0 -2
- package/static/dist/assets/index-r648f4o1.js +0 -11
- package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +0 -1
- package/static/dist/assets/link-oot2yn73.js +0 -1
- package/static/dist/assets/login-gu28l9ou.js +0 -1
- package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +0 -1
- package/static/dist/assets/onboarding-lkphzu1s.js +0 -1
- package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +0 -1
- package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +0 -1
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +0 -1
- package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +0 -1
- package/static/dist/assets/refresh-abtu93ke.js +0 -1
- package/static/dist/assets/saved-girnabkf.js +0 -1
- package/static/dist/assets/settings_section_extensions_title-h14cwk6f.js +0 -1
- package/static/dist/assets/share._token-kl0r7ido.js +0 -2
- package/static/dist/assets/sliders-horizontal-n1zm34qq.js +0 -1
- package/static/dist/assets/sse-iwku0sjo.js +0 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +0 -1
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +0 -8
- package/static/dist/assets/todoStore-jjr5yr78.js +0 -1
- package/static/dist/assets/units-kgxzxfhi.js +0 -1
- package/static/dist/assets/untitled-cikfjntn.js +0 -1
- package/static/dist/assets/useForm-kqoqg0k9.js +0 -1
- package/static/dist/assets/useLocale-o59tbiea.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
|