@huynle/hub 0.6.1 → 0.7.2
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/CHANGELOG.md +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// The typed service-manager client for hub's own user services (the ServiceControl the transaction executors take,
|
|
2
|
+
// transaction/builtin.ts), and the activate-service executor.
|
|
3
|
+
//
|
|
4
|
+
// It names a service and a manager, never a command. Underneath, it makes only fixed calls:
|
|
5
|
+
// systemd --user systemctl --user daemon-reload | enable | disable | start | restart | stop <hub unit>
|
|
6
|
+
// systemctl --user is-active --quiet | is-enabled --quiet <hub unit>
|
|
7
|
+
// systemctl --user show --property=ActiveState,SubState,NRestarts <hub unit>
|
|
8
|
+
// launchd launchctl bootstrap gui/<uid> <the hub agent's plist> | bootout gui/<uid>/<hub label>
|
|
9
|
+
// launchctl print gui/<uid>/<hub label>
|
|
10
|
+
// Every call is checked against that set (checkManagerCall) before it reaches the injected runner, so a bug cannot
|
|
11
|
+
// turn it into anything else; the real runner (spawnManagerRunner) checks again, spawns the absolute program
|
|
12
|
+
// without a shell and with an empty environment (only the user bus address is passed), and reports an exit code;
|
|
13
|
+
// only the two state queries (show, print) also return their (bounded) output, which is parsed for a few state
|
|
14
|
+
// fields here and never passed on. Failures name the unit, its state and the exit, never output. Nothing elevated:
|
|
15
|
+
// only the user's own manager.
|
|
16
|
+
//
|
|
17
|
+
// Loading is not enough: a unit with Restart=on-failure (or KeepAlive) that crashes at once is "loaded" while it
|
|
18
|
+
// crash-loops. So activation then watches the service's state (HEALTH_WINDOW): it must be running, without a
|
|
19
|
+
// restart, for stableMs within timeoutMs. Failed, crash-looping (auto-restart, a respawn after an exit), stopped or
|
|
20
|
+
// never settling is service_failed, with a hint at where its log is (journalctl --user -u <unit>, or the agent's
|
|
21
|
+
// StandardErrorPath).
|
|
22
|
+
//
|
|
23
|
+
// Activation rolls back to the state the service had before: a service that was not loaded is unloaded (and
|
|
24
|
+
// disabled) again; one that was already loaded stays loaded (the engine then restores its previous unit file).
|
|
25
|
+
// After a crash the prior state is unknown, so recovery unloads it: no persistence remains without consent.
|
|
26
|
+
import { spawn as nodeSpawn } from "node:child_process"
|
|
27
|
+
import path from "node:path"
|
|
28
|
+
import { EMBEDDED_ASSETS, isAbsolutePath, isRecord, resolvePathRef, SERVICE_IDS, type ExecutionContext, type RootMapping, type ServiceID, type ServiceManagerID } from "../../contracts/index.ts"
|
|
29
|
+
import type { ServiceControl } from "../../transaction/builtin.ts"
|
|
30
|
+
import type { ActionExecutor } from "../../transaction/executor.ts"
|
|
31
|
+
import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
32
|
+
import { assetIDFor, serviceName, UNIT_ROOT } from "./render.ts"
|
|
33
|
+
|
|
34
|
+
export type ManagerProgram = "systemctl" | "launchctl"
|
|
35
|
+
|
|
36
|
+
/** One call to the user's service manager: the program and its words (never a shell string). */
|
|
37
|
+
export interface ManagerCall {
|
|
38
|
+
readonly program: ManagerProgram
|
|
39
|
+
readonly words: readonly string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Runs one checked call; its exit code comes back, and for the state queries (show, print) its output. */
|
|
43
|
+
export type ManagerRunner = (call: ManagerCall) => Promise<{ readonly exitCode: number; readonly output?: string }>
|
|
44
|
+
|
|
45
|
+
/** How long activation watches a loaded service: running without a restart for stableMs, within timeoutMs. */
|
|
46
|
+
export const HEALTH_WINDOW = Object.freeze({ timeoutMs: 10_000, stableMs: 3_000, pollMs: 500 })
|
|
47
|
+
|
|
48
|
+
/** What a state query says about a service. `detail` is the manager's own state words (never other output). */
|
|
49
|
+
export interface ServiceHealth {
|
|
50
|
+
readonly state: "running" | "starting" | "crash-looping" | "failed" | "stopped"
|
|
51
|
+
/** Restarts the manager counted (systemd NRestarts; launchd runs - 1). */
|
|
52
|
+
readonly restarts: number
|
|
53
|
+
readonly detail: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Clock {
|
|
57
|
+
now(): number
|
|
58
|
+
sleep(ms: number): Promise<void>
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const realClock: Clock = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }
|
|
62
|
+
|
|
63
|
+
export interface ServiceRunState {
|
|
64
|
+
/** systemd: active; launchd: loaded in the user's GUI domain. */
|
|
65
|
+
readonly loaded: boolean
|
|
66
|
+
/** systemd only: enabled (started at login). */
|
|
67
|
+
readonly enabled?: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface NodeServiceControl extends ServiceControl {
|
|
71
|
+
state(service: ServiceID, manager: ServiceManagerID): Promise<TxResult<ServiceRunState>>
|
|
72
|
+
/** Puts a service back into `prior` (the undo of an activation). */
|
|
73
|
+
restore(service: ServiceID, manager: ServiceManagerID, prior: ServiceRunState): Promise<TxResult<void>>
|
|
74
|
+
/** One state query: running, starting, crash-looping, failed or stopped. */
|
|
75
|
+
health(service: ServiceID, manager: ServiceManagerID): Promise<TxResult<ServiceHealth>>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const UNITS = new Set(SERVICE_IDS.map((s) => serviceName(s, "systemd-user")))
|
|
79
|
+
const LABELS = new Set(SERVICE_IDS.map((s) => serviceName(s, "launchd")))
|
|
80
|
+
// eslint-disable-next-line no-control-regex
|
|
81
|
+
const CONTROL = /[\u0000-\u001f\u007f]/
|
|
82
|
+
const DOMAIN = /^gui\/\d{1,10}$/
|
|
83
|
+
const SYSTEMD_UNIT_VERBS = new Set(["enable", "disable", "start", "restart", "stop"])
|
|
84
|
+
const SYSTEMD_QUERIES = new Set(["is-active", "is-enabled"])
|
|
85
|
+
const SHOW_PROPERTIES = "--property=ActiveState,SubState,NRestarts"
|
|
86
|
+
|
|
87
|
+
const labelOfTarget = (word: string | undefined) => {
|
|
88
|
+
const at = word?.lastIndexOf("/") ?? -1
|
|
89
|
+
return at > 0 && DOMAIN.test(word!.slice(0, at)) ? word!.slice(at + 1) : undefined
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Whether `call` is one of the fixed calls this client makes. */
|
|
93
|
+
export function checkManagerCall(call: unknown): call is ManagerCall {
|
|
94
|
+
if (!isRecord(call) || !Array.isArray(call.words) || call.words.length > 4) return false
|
|
95
|
+
const w = call.words as unknown[]
|
|
96
|
+
if (!w.every((x) => typeof x === "string" && x.length > 0 && x.length <= 4096 && !CONTROL.test(x))) return false
|
|
97
|
+
const words = w as string[]
|
|
98
|
+
if (call.program === "systemctl") {
|
|
99
|
+
if (words[0] !== "--user") return false
|
|
100
|
+
const verb = words[1]!
|
|
101
|
+
if (verb === "daemon-reload") return words.length === 2
|
|
102
|
+
if (SYSTEMD_UNIT_VERBS.has(verb)) return words.length === 3 && UNITS.has(words[2]!)
|
|
103
|
+
if (SYSTEMD_QUERIES.has(verb)) return words.length === 4 && words[2] === "--quiet" && UNITS.has(words[3]!)
|
|
104
|
+
if (verb === "show") return words.length === 4 && words[2] === SHOW_PROPERTIES && UNITS.has(words[3]!)
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
if (call.program === "launchctl") {
|
|
108
|
+
const [verb, target, file] = words
|
|
109
|
+
if (verb === "print" || verb === "bootout") return words.length === 2 && LABELS.has(labelOfTarget(target) ?? "")
|
|
110
|
+
if (verb === "bootstrap")
|
|
111
|
+
return words.length === 3 && DOMAIN.test(target!) && isAbsolutePath(file) && LABELS.has(path.basename(file!).replace(/\.plist$/, "")) && file!.endsWith(".plist")
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Each hub unit's absolute path in this context (the manager's root + the asset's file name), when mapped. */
|
|
118
|
+
export function unitPathResolver(roots: readonly RootMapping[], context: ExecutionContext): (service: ServiceID, manager: ServiceManagerID) => string | undefined {
|
|
119
|
+
return (service, manager) => {
|
|
120
|
+
const asset = EMBEDDED_ASSETS[assetIDFor(service, manager)]
|
|
121
|
+
if (!asset) return undefined
|
|
122
|
+
const where = resolvePathRef({ root: UNIT_ROOT[manager], rel: asset.installAs }, roots, context)
|
|
123
|
+
return where.ok ? where.value : undefined
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ServiceControlOptions {
|
|
128
|
+
readonly run: ManagerRunner
|
|
129
|
+
/** The user whose launchd GUI domain the agents load in (process.getuid()). */
|
|
130
|
+
readonly uid: number
|
|
131
|
+
readonly unitPath: (service: ServiceID, manager: ServiceManagerID) => string | undefined
|
|
132
|
+
/** Time for the health window (injected in tests). */
|
|
133
|
+
readonly clock?: Clock
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ok = txOk(undefined)
|
|
137
|
+
|
|
138
|
+
/** The fields of `systemctl show` output (KEY=value lines). */
|
|
139
|
+
function showFields(output: string): Record<string, string> {
|
|
140
|
+
const fields: Record<string, string> = {}
|
|
141
|
+
for (const line of output.split("\n")) {
|
|
142
|
+
const at = line.indexOf("=")
|
|
143
|
+
if (at > 0) fields[line.slice(0, at)] = line.slice(at + 1).trim()
|
|
144
|
+
}
|
|
145
|
+
return fields
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const WORD = /^[A-Za-z0-9 ()_-]{1,40}$/
|
|
149
|
+
/** A state word from the manager, only when it looks like one (so nothing else of its output can leak). */
|
|
150
|
+
const word = (value: string | undefined) => (value !== undefined && WORD.test(value) ? value : "unknown")
|
|
151
|
+
|
|
152
|
+
export function systemdHealth(output: string): ServiceHealth {
|
|
153
|
+
const fields = showFields(output)
|
|
154
|
+
const active = word(fields.ActiveState)
|
|
155
|
+
const sub = word(fields.SubState)
|
|
156
|
+
const restarts = /^\d{1,9}$/.test(fields.NRestarts ?? "") ? Number(fields.NRestarts) : 0
|
|
157
|
+
const detail = `${active}/${sub}`
|
|
158
|
+
if (active === "active") return { state: sub === "running" ? "running" : "starting", restarts, detail }
|
|
159
|
+
if (active === "activating") return { state: sub.startsWith("auto-restart") ? "crash-looping" : "starting", restarts, detail }
|
|
160
|
+
if (active === "reloading") return { state: "starting", restarts, detail }
|
|
161
|
+
if (active === "failed") return { state: "failed", restarts, detail }
|
|
162
|
+
return { state: "stopped", restarts, detail }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The top-level `key = value` of a `launchctl print` (one tab deep), else its first occurrence. */
|
|
166
|
+
const printField = (output: string, key: string) => (output.match(new RegExp(`^\t${key} = (.*)$`, "m")) ?? output.match(new RegExp(`^[ \\t]*${key} = (.*)$`, "m")))?.[1]?.trim()
|
|
167
|
+
|
|
168
|
+
export function launchdHealth(output: string): ServiceHealth {
|
|
169
|
+
const state = word(printField(output, "state"))
|
|
170
|
+
const runs = Number(printField(output, "runs") ?? "")
|
|
171
|
+
const restarts = Number.isInteger(runs) && runs > 0 ? runs - 1 : 0
|
|
172
|
+
const exit = printField(output, "last exit code")
|
|
173
|
+
const exited = exit !== undefined && /^-?\d{1,9}$/.test(exit)
|
|
174
|
+
const detail = exited ? `${state}, last exit code ${exit}` : state
|
|
175
|
+
if (state === "running") return { state: "running", restarts, detail }
|
|
176
|
+
if (!exited) return { state: "starting", restarts, detail }
|
|
177
|
+
return { state: state === "not running" ? "stopped" : "crash-looping", restarts, detail }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const plural = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
|
|
181
|
+
|
|
182
|
+
/** The ServiceControl for hub's user services, over a (checked) fixed-call runner. */
|
|
183
|
+
export function createServiceControl(options: ServiceControlOptions): NodeServiceControl {
|
|
184
|
+
if (!Number.isInteger(options.uid) || options.uid < 0) throw new Error("the service manager's user must be a uid")
|
|
185
|
+
const clock = options.clock ?? realClock
|
|
186
|
+
const checked = (program: ManagerProgram, ...words: string[]): ManagerCall => {
|
|
187
|
+
const c: ManagerCall = { program, words }
|
|
188
|
+
if (!checkManagerCall(c)) throw new Error(`refused a service manager call outside the fixed set (${program} ${words[0] ?? ""})`)
|
|
189
|
+
return c
|
|
190
|
+
}
|
|
191
|
+
const call = async (program: ManagerProgram, ...words: string[]): Promise<number> => (await options.run(checked(program, ...words))).exitCode
|
|
192
|
+
|
|
193
|
+
/** Watches a just-loaded service until it has run, without a restart, for the stable window (HEALTH_WINDOW). */
|
|
194
|
+
const settle = async (probe: () => Promise<TxResult<ServiceHealth>>, name: string, logs: string): Promise<TxResult<void>> => {
|
|
195
|
+
const failed = (what: string) => txFail("service_failed", `${name} ${what}; see ${logs}`)
|
|
196
|
+
const start = clock.now()
|
|
197
|
+
let since: number | undefined
|
|
198
|
+
let baseline = 0
|
|
199
|
+
for (;;) {
|
|
200
|
+
const h = await probe()
|
|
201
|
+
if (!h.ok) return h
|
|
202
|
+
const { state, restarts, detail } = h.value
|
|
203
|
+
const counted = restarts > 0 ? `${detail}, ${plural(restarts, "restart")}` : detail
|
|
204
|
+
const now = clock.now()
|
|
205
|
+
if (state === "crash-looping") return failed(`is crash-looping (${counted})`)
|
|
206
|
+
if (state === "failed") return failed(`failed (${counted})`)
|
|
207
|
+
if (state === "stopped") return failed(`is not running (${counted})`)
|
|
208
|
+
if (state === "running") {
|
|
209
|
+
if (since === undefined) [since, baseline] = [now, restarts]
|
|
210
|
+
else if (restarts > baseline) return failed(`restarted ${plural(restarts - baseline, "time")} while it was watched (${detail})`)
|
|
211
|
+
if (now - since >= HEALTH_WINDOW.stableMs) return ok
|
|
212
|
+
} else since = undefined
|
|
213
|
+
if (now - start >= HEALTH_WINDOW.timeoutMs) return failed(`did not stay running within ${HEALTH_WINDOW.timeoutMs / 1000} s (${detail})`)
|
|
214
|
+
await clock.sleep(HEALTH_WINDOW.pollMs)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const guarded = async <T>(f: () => Promise<TxResult<T>>): Promise<TxResult<T>> => {
|
|
218
|
+
try {
|
|
219
|
+
return await f()
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return txFail("internal", (error as Error).message)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const sctl = (...words: string[]) => call("systemctl", "--user", ...words)
|
|
225
|
+
const step = async (code: Promise<number>, what: string): Promise<TxResult<void>> => {
|
|
226
|
+
const exit = await code
|
|
227
|
+
return exit === 0 ? ok : txFail("internal", `${what} failed (exit ${exit})`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const systemd = {
|
|
231
|
+
async state(service: ServiceID): Promise<TxResult<ServiceRunState>> {
|
|
232
|
+
const unit = serviceName(service, "systemd-user")
|
|
233
|
+
return txOk({ loaded: (await sctl("is-active", "--quiet", unit)) === 0, enabled: (await sctl("is-enabled", "--quiet", unit)) === 0 })
|
|
234
|
+
},
|
|
235
|
+
async activate(service: ServiceID): Promise<TxResult<void>> {
|
|
236
|
+
const unit = serviceName(service, "systemd-user")
|
|
237
|
+
for (const [words, what] of [
|
|
238
|
+
[["daemon-reload"], "systemctl --user daemon-reload"],
|
|
239
|
+
[["enable", unit], `systemctl --user enable ${unit}`],
|
|
240
|
+
[["restart", unit], `systemctl --user restart ${unit}`],
|
|
241
|
+
] as const) {
|
|
242
|
+
const r = await step(sctl(...words), what)
|
|
243
|
+
if (!r.ok) return r
|
|
244
|
+
}
|
|
245
|
+
return settle(() => systemd.health(service), unit, `journalctl --user -u ${unit}`)
|
|
246
|
+
},
|
|
247
|
+
async health(service: ServiceID): Promise<TxResult<ServiceHealth>> {
|
|
248
|
+
const unit = serviceName(service, "systemd-user")
|
|
249
|
+
const r = await options.run(checked("systemctl", "--user", "show", SHOW_PROPERTIES, unit))
|
|
250
|
+
return r.exitCode === 0 ? txOk(systemdHealth(r.output ?? "")) : txOk({ state: "stopped", restarts: 0, detail: `systemctl --user show exit ${r.exitCode}` })
|
|
251
|
+
},
|
|
252
|
+
async restore(service: ServiceID, prior: ServiceRunState): Promise<TxResult<void>> {
|
|
253
|
+
const unit = serviceName(service, "systemd-user")
|
|
254
|
+
const r = await step(sctl(prior.enabled ? "enable" : "disable", unit), `systemctl --user ${prior.enabled ? "enable" : "disable"} ${unit}`)
|
|
255
|
+
if (!r.ok) return r
|
|
256
|
+
if (!prior.loaded) {
|
|
257
|
+
const stopped = await step(sctl("stop", unit), `systemctl --user stop ${unit}`)
|
|
258
|
+
if (!stopped.ok) return stopped
|
|
259
|
+
}
|
|
260
|
+
return step(sctl("daemon-reload"), "systemctl --user daemon-reload")
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const domain = `gui/${options.uid}`
|
|
265
|
+
const launchd = {
|
|
266
|
+
async state(service: ServiceID): Promise<TxResult<ServiceRunState>> {
|
|
267
|
+
return txOk({ loaded: (await call("launchctl", "print", `${domain}/${serviceName(service, "launchd")}`)) === 0 })
|
|
268
|
+
},
|
|
269
|
+
async activate(service: ServiceID): Promise<TxResult<void>> {
|
|
270
|
+
const label = serviceName(service, "launchd")
|
|
271
|
+
const file = options.unitPath(service, "launchd")
|
|
272
|
+
if (!file) return txFail("invalid", `this host has no launch-agents directory for ${label}`)
|
|
273
|
+
await call("launchctl", "bootout", `${domain}/${label}`) // not loaded yet is fine
|
|
274
|
+
const boot = await call("launchctl", "bootstrap", domain, file)
|
|
275
|
+
if (boot !== 0) return txFail("internal", `launchctl bootstrap of ${label} failed (exit ${boot})`)
|
|
276
|
+
return settle(() => launchd.health(service), label, `~/.local/state/hub/${service}.log and launchctl print ${domain}/${label}`)
|
|
277
|
+
},
|
|
278
|
+
async health(service: ServiceID): Promise<TxResult<ServiceHealth>> {
|
|
279
|
+
const r = await options.run(checked("launchctl", "print", `${domain}/${serviceName(service, "launchd")}`))
|
|
280
|
+
return r.exitCode === 0 ? txOk(launchdHealth(r.output ?? "")) : txOk({ state: "stopped", restarts: 0, detail: `not loaded, launchctl print exit ${r.exitCode}` })
|
|
281
|
+
},
|
|
282
|
+
async deactivate(service: ServiceID): Promise<TxResult<void>> {
|
|
283
|
+
const label = serviceName(service, "launchd")
|
|
284
|
+
await call("launchctl", "bootout", `${domain}/${label}`) // already unloaded is fine
|
|
285
|
+
return (await call("launchctl", "print", `${domain}/${label}`)) === 0 ? txFail("internal", `${label} is still loaded after launchctl bootout`) : ok
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const deactivate = (service: ServiceID, manager: ServiceManagerID) =>
|
|
290
|
+
guarded(() => (manager === "launchd" ? launchd.deactivate(service) : systemd.restore(service, { loaded: false, enabled: false })))
|
|
291
|
+
return {
|
|
292
|
+
state: (service, manager) => guarded(() => (manager === "launchd" ? launchd.state(service) : systemd.state(service))),
|
|
293
|
+
activate: (service, manager) => guarded(() => (manager === "launchd" ? launchd.activate(service) : systemd.activate(service))),
|
|
294
|
+
deactivate,
|
|
295
|
+
restore: (service, manager, prior) =>
|
|
296
|
+
prior.loaded ? guarded(async () => (manager === "launchd" ? ok : systemd.restore(service, prior))) : deactivate(service, manager),
|
|
297
|
+
health: (service, manager) => guarded(() => (manager === "launchd" ? launchd.health(service) : systemd.health(service))),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Spawns one process: no shell; resolves its exit code, and with `capture` also its (bounded) stdout. */
|
|
302
|
+
export type Spawner = (
|
|
303
|
+
file: string,
|
|
304
|
+
words: readonly string[],
|
|
305
|
+
options: { readonly shell: false; readonly env: Readonly<Record<string, string>>; readonly timeoutMs: number; readonly capture?: true },
|
|
306
|
+
) => Promise<number | { readonly exitCode: number; readonly output: string }>
|
|
307
|
+
|
|
308
|
+
/** At most this much of a state query's output is kept. */
|
|
309
|
+
const MAX_OUTPUT = 64 * 1024
|
|
310
|
+
/** The calls whose output is read: the state queries. */
|
|
311
|
+
const capturesOutput = (call: ManagerCall) => (call.program === "systemctl" ? call.words[1] === "show" : call.words[0] === "print")
|
|
312
|
+
|
|
313
|
+
export interface SpawnRunnerOptions {
|
|
314
|
+
/** Absolute paths of the two programs (discovery's view of this context). */
|
|
315
|
+
readonly programs: Readonly<Record<ManagerProgram, string>>
|
|
316
|
+
/** The caller's environment; only the user bus address is passed on (systemctl --user needs it). */
|
|
317
|
+
readonly env?: Readonly<Record<string, string | undefined>>
|
|
318
|
+
readonly timeoutMs?: number
|
|
319
|
+
/** Injected in tests; the default spawns with node:child_process (never with a shell). */
|
|
320
|
+
readonly spawn?: Spawner
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const BUS_ENV = ["XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS"] as const
|
|
324
|
+
|
|
325
|
+
const defaultSpawn: Spawner = (file, words, options) =>
|
|
326
|
+
new Promise((resolve) => {
|
|
327
|
+
const child = nodeSpawn(file, [...words], { shell: false, env: { ...options.env }, stdio: ["ignore", options.capture ? "pipe" : "ignore", "ignore"], timeout: options.timeoutMs, killSignal: "SIGKILL" })
|
|
328
|
+
let output = ""
|
|
329
|
+
child.stdout?.setEncoding("utf8")
|
|
330
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
331
|
+
if (output.length < MAX_OUTPUT) output = (output + chunk).slice(0, MAX_OUTPUT)
|
|
332
|
+
})
|
|
333
|
+
const done = (exitCode: number) => resolve(options.capture ? { exitCode, output } : exitCode)
|
|
334
|
+
child.on("error", () => done(127))
|
|
335
|
+
child.on("close", (code, signal) => done(code ?? (signal ? 128 : 1)))
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
/** The real runner: checked calls only, absolute programs, no shell, no inherited environment. */
|
|
339
|
+
export function spawnManagerRunner(options: SpawnRunnerOptions): ManagerRunner {
|
|
340
|
+
for (const program of ["systemctl", "launchctl"] as const) {
|
|
341
|
+
const file = options.programs[program]
|
|
342
|
+
if (!isAbsolutePath(file) || path.basename(file) !== program) throw new Error(`${program} must be given as an absolute path to ${program}`)
|
|
343
|
+
}
|
|
344
|
+
const env: Record<string, string> = {}
|
|
345
|
+
for (const key of BUS_ENV) if (options.env?.[key]) env[key] = options.env[key]!
|
|
346
|
+
const spawn = options.spawn ?? defaultSpawn
|
|
347
|
+
const timeoutMs = options.timeoutMs ?? 30_000
|
|
348
|
+
return async (call: ManagerCall) => {
|
|
349
|
+
if (!checkManagerCall(call)) throw new Error(`refused a service manager call outside the fixed set (${String((call as { program?: unknown }).program)})`)
|
|
350
|
+
const capture = capturesOutput(call)
|
|
351
|
+
const r = await spawn(options.programs[call.program], [...call.words], { shell: false, env, timeoutMs, ...(capture ? { capture: true as const } : {}) })
|
|
352
|
+
if (typeof r === "number") return { exitCode: r }
|
|
353
|
+
return capture ? { exitCode: r.exitCode, output: r.output.slice(0, MAX_OUTPUT) } : { exitCode: r.exitCode }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* activate-service: loads a hub service after the user explicitly confirmed it (the engine also requires
|
|
359
|
+
* --confirm-activation). An external effect; its undo returns the service to the state it had before.
|
|
360
|
+
*/
|
|
361
|
+
export function activateServiceExecutor(control?: NodeServiceControl): ActionExecutor<"activate-service"> {
|
|
362
|
+
const priors = new Map<string, ServiceRunState>()
|
|
363
|
+
return {
|
|
364
|
+
kind: "activate-service",
|
|
365
|
+
plan: () => ({ class: "external" }),
|
|
366
|
+
check: async (action) => {
|
|
367
|
+
if (action.confirm !== "explicit") return txFail("needs_user", `${action.id}: a service is only loaded on explicit confirmation`)
|
|
368
|
+
return control ? ok : txFail("internal", "this hubctl was built without a service manager client")
|
|
369
|
+
},
|
|
370
|
+
change: async (action) => {
|
|
371
|
+
if (!control) return txFail("internal", "this hubctl was built without a service manager client")
|
|
372
|
+
return txOk({
|
|
373
|
+
type: "effect",
|
|
374
|
+
perform: async () => {
|
|
375
|
+
const prior = await control.state(action.service, action.manager)
|
|
376
|
+
if (!prior.ok) return prior
|
|
377
|
+
priors.set(action.id, prior.value)
|
|
378
|
+
const activated = await control.activate(action.service, action.manager)
|
|
379
|
+
if (activated.ok) return activated
|
|
380
|
+
// The engine does not undo a step that failed, so a failed activation puts the service back itself.
|
|
381
|
+
const restored = await control.restore(action.service, action.manager, prior.value)
|
|
382
|
+
const after = !restored.ok ? `undoing it failed: ${restored.message}` : prior.value.loaded ? "it was loaded before and stays loaded" : "it was unloaded again"
|
|
383
|
+
return txFail(activated.code, `${activated.message}; ${after}`)
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
},
|
|
387
|
+
undo: async (action) => {
|
|
388
|
+
if (!control) return txFail("internal", "this hubctl was built without a service manager client")
|
|
389
|
+
return control.restore(action.service, action.manager, priors.get(action.id) ?? { loaded: false, enabled: false })
|
|
390
|
+
},
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The node-services adapter: a persistent launch node (`hub node`) and the harness spokes' user services, planned
|
|
2
|
+
// from the embedded unit templates, loaded only with explicit consent through fixed-argv calls to the user's
|
|
3
|
+
// service manager, and proved by one bounded verification launch.
|
|
4
|
+
//
|
|
5
|
+
// render.ts the units, from EMBEDDED_ASSETS with serviceArgv (golden-tested for systemd --user and launchd)
|
|
6
|
+
// validate.ts narrow node settings (roots, slots, labels) and the same-runtime-context rule
|
|
7
|
+
// plan.ts the typed actions (node.json merge, install-service, activate-service, launch-session)
|
|
8
|
+
// control.ts ServiceControl over systemctl --user / launchctl, and the activate-service executor
|
|
9
|
+
// launch.ts the bounded launch verification and the launch-session executor
|
|
10
|
+
import type { ExecutorMap } from "../../transaction/executor.ts"
|
|
11
|
+
import { activateServiceExecutor, type NodeServiceControl } from "./control.ts"
|
|
12
|
+
import { launchSessionExecutor, type LaunchProbe } from "./launch.ts"
|
|
13
|
+
|
|
14
|
+
export * from "./render.ts"
|
|
15
|
+
export * from "./validate.ts"
|
|
16
|
+
export * from "./plan.ts"
|
|
17
|
+
export * from "./control.ts"
|
|
18
|
+
export * from "./launch.ts"
|
|
19
|
+
|
|
20
|
+
/** The executors this adapter adds to builtinExecutors (pass the same control as BuiltinDeps.services). */
|
|
21
|
+
export function nodeServiceExecutors(deps: { readonly control?: NodeServiceControl; readonly probe?: LaunchProbe }): Pick<ExecutorMap, "activate-service" | "launch-session"> {
|
|
22
|
+
return { "activate-service": activateServiceExecutor(deps.control), "launch-session": launchSessionExecutor(deps.probe) }
|
|
23
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Bounded launch verification: the optional last step of a launch-node setup (launch-session). Through an injected
|
|
2
|
+
// LaunchDriver (HBS-13 wires it to the hub's launch API and the node's state; tests use a fake), it
|
|
3
|
+
// 1. launches one session of the harness in a configured workspace, bounded by maxDurationS,
|
|
4
|
+
// 2. waits for the session's response,
|
|
5
|
+
// 3. waits for it to end idle,
|
|
6
|
+
// 4. checks the workspace holds no launch artifacts afterwards,
|
|
7
|
+
// 5. cleans up, and
|
|
8
|
+
// 6. checks the node's slot is free again.
|
|
9
|
+
// A failure, the user's cancel or the deadline stops it and recovers: cancel the launch, clean up, and check the
|
|
10
|
+
// slot. The report lists each stage; its messages are one line, bounded, and never carry a credential (anything
|
|
11
|
+
// credential-shaped from the driver is redacted).
|
|
12
|
+
import { HARNESS_ORDER, isAbsolutePath, isSecretValue, type ErrorCode, type HarnessID } from "../../contracts/index.ts"
|
|
13
|
+
import type { ActionExecutor } from "../../transaction/executor.ts"
|
|
14
|
+
import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
15
|
+
|
|
16
|
+
export interface LaunchRequest {
|
|
17
|
+
readonly harness: HarnessID
|
|
18
|
+
/** The workspace as the node sees it (absolute, inside a workspace root). */
|
|
19
|
+
readonly workspace: string
|
|
20
|
+
readonly maxDurationS: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The launch operations the verification needs; every wait takes the abort signal of the bound. */
|
|
24
|
+
export interface LaunchDriver {
|
|
25
|
+
start(request: LaunchRequest, signal: AbortSignal): Promise<TxResult<{ readonly launchID: string }>>
|
|
26
|
+
awaitResponse(launchID: string, signal: AbortSignal): Promise<TxResult<void>>
|
|
27
|
+
awaitIdle(launchID: string, signal: AbortSignal): Promise<TxResult<void>>
|
|
28
|
+
/** What the launch left in the workspace that should not be there (relative names). */
|
|
29
|
+
inspect(launchID: string): Promise<TxResult<{ readonly leftovers: readonly string[] }>>
|
|
30
|
+
cleanup(launchID: string): Promise<TxResult<void>>
|
|
31
|
+
cancel(launchID: string): Promise<TxResult<void>>
|
|
32
|
+
/** Whether the node's slot for this launch is free again. */
|
|
33
|
+
slotFree(launchID: string): Promise<TxResult<boolean>>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface LaunchTimer {
|
|
37
|
+
after(ms: number): { readonly promise: Promise<void>; cancel(): void }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const realTimer: LaunchTimer = {
|
|
41
|
+
after(ms) {
|
|
42
|
+
let handle: ReturnType<typeof setTimeout> | undefined
|
|
43
|
+
const promise = new Promise<void>((resolve) => {
|
|
44
|
+
handle = setTimeout(resolve, ms)
|
|
45
|
+
})
|
|
46
|
+
return { promise, cancel: () => clearTimeout(handle) }
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type LaunchStage = "launch" | "response" | "idle" | "artifacts" | "cleanup" | "slot" | "recover"
|
|
51
|
+
|
|
52
|
+
export interface StageResult {
|
|
53
|
+
readonly stage: LaunchStage
|
|
54
|
+
readonly ok: boolean
|
|
55
|
+
readonly detail?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LaunchReport {
|
|
59
|
+
readonly ok: boolean
|
|
60
|
+
readonly launchID?: string
|
|
61
|
+
readonly stages: readonly StageResult[]
|
|
62
|
+
/** After a failure with a launch: whether cancel, cleanup and the slot check all succeeded. */
|
|
63
|
+
readonly recovered?: boolean
|
|
64
|
+
readonly code?: ErrorCode
|
|
65
|
+
readonly message?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VerifyOptions {
|
|
69
|
+
readonly timer?: LaunchTimer
|
|
70
|
+
/** The user's cancel (Ctrl-C). */
|
|
71
|
+
readonly signal?: AbortSignal
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// eslint-disable-next-line no-control-regex
|
|
75
|
+
const CONTROL = /[\u0000-\u001f\u007f]+/g
|
|
76
|
+
|
|
77
|
+
/** One bounded, credential-free line from a driver message. */
|
|
78
|
+
export function scrub(text: string): string {
|
|
79
|
+
const words = text
|
|
80
|
+
.replace(CONTROL, " ")
|
|
81
|
+
.replace(/\bBearer\s+\S+/gi, "[redacted]")
|
|
82
|
+
.split(/(\s+)/)
|
|
83
|
+
.map((w) => (isSecretValue(w) ? "[redacted]" : w))
|
|
84
|
+
.join("")
|
|
85
|
+
const line = words.length > 300 ? `${words.slice(0, 297)}...` : words
|
|
86
|
+
return isSecretValue(line) ? "(details withheld)" : line
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ABORTED = Symbol("aborted")
|
|
90
|
+
|
|
91
|
+
/** Runs the bounded verification. Never throws for a driver failure: everything ends in the report. */
|
|
92
|
+
export async function verifyLaunch(driver: LaunchDriver, request: LaunchRequest, options: VerifyOptions = {}): Promise<LaunchReport> {
|
|
93
|
+
if (!HARNESS_ORDER.includes(request.harness) || !isAbsolutePath(request.workspace) || !Number.isInteger(request.maxDurationS) || request.maxDurationS < 1 || request.maxDurationS > 86_400)
|
|
94
|
+
return { ok: false, stages: [], code: "invalid", message: "a verification launch needs a known harness, an absolute workspace and a bound of 1 s to 24 h" }
|
|
95
|
+
if (options.signal?.aborted) return { ok: false, stages: [], code: "cancelled", message: "cancelled before the launch" }
|
|
96
|
+
|
|
97
|
+
const stages: StageResult[] = []
|
|
98
|
+
const controller = new AbortController()
|
|
99
|
+
let reason: "cancelled" | "timeout" | undefined
|
|
100
|
+
const stop = (why: "cancelled" | "timeout") => {
|
|
101
|
+
reason ??= why
|
|
102
|
+
controller.abort()
|
|
103
|
+
}
|
|
104
|
+
const deadline = (options.timer ?? realTimer).after(request.maxDurationS * 1000)
|
|
105
|
+
void deadline.promise.then(() => stop("timeout"))
|
|
106
|
+
const onCancel = () => stop("cancelled")
|
|
107
|
+
options.signal?.addEventListener("abort", onCancel)
|
|
108
|
+
const aborted = new Promise<typeof ABORTED>((resolve) => controller.signal.addEventListener("abort", () => resolve(ABORTED)))
|
|
109
|
+
const race = <T>(p: Promise<T>) => Promise.race([p, aborted])
|
|
110
|
+
|
|
111
|
+
const stopped = () =>
|
|
112
|
+
reason === "cancelled"
|
|
113
|
+
? { code: "cancelled" as const, message: "the verification launch was cancelled" }
|
|
114
|
+
: { code: "harness_not_ready" as const, message: `the verification launch did not finish within ${request.maxDurationS} s` }
|
|
115
|
+
const failure = (r: { readonly message: string }) => ({ code: "harness_not_ready" as const, message: scrub(r.message) })
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const started = await race(driver.start(request, controller.signal))
|
|
119
|
+
if (started === ABORTED || !started.ok) {
|
|
120
|
+
const why = started === ABORTED ? stopped() : failure(started)
|
|
121
|
+
stages.push({ stage: "launch", ok: false, detail: why.message })
|
|
122
|
+
return { ok: false, stages, ...why }
|
|
123
|
+
}
|
|
124
|
+
const launchID = started.value.launchID
|
|
125
|
+
stages.push({ stage: "launch", ok: true })
|
|
126
|
+
|
|
127
|
+
const fail = async (stage: LaunchStage, why: { code: ErrorCode; message: string }): Promise<LaunchReport> => {
|
|
128
|
+
stages.push({ stage, ok: false, detail: why.message })
|
|
129
|
+
const recovered = await recover(driver, launchID)
|
|
130
|
+
stages.push({ stage: "recover", ok: recovered.ok, ...(recovered.ok ? {} : { detail: recovered.detail }) })
|
|
131
|
+
const message = recovered.ok ? why.message : `${why.message}; recovery failed (${recovered.detail}): launch ${launchID} may still hold its slot and workspace`
|
|
132
|
+
return { ok: false, launchID, stages, recovered: recovered.ok, code: why.code, message }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const [stage, wait] of [
|
|
136
|
+
["response", () => driver.awaitResponse(launchID, controller.signal)],
|
|
137
|
+
["idle", () => driver.awaitIdle(launchID, controller.signal)],
|
|
138
|
+
] as const) {
|
|
139
|
+
const r = await race(wait())
|
|
140
|
+
if (r === ABORTED) return fail(stage, stopped())
|
|
141
|
+
if (!r.ok) return fail(stage, failure(r))
|
|
142
|
+
stages.push({ stage, ok: true })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const inspected = await driver.inspect(launchID)
|
|
146
|
+
if (!inspected.ok) return fail("artifacts", failure(inspected))
|
|
147
|
+
if (inspected.value.leftovers.length > 0) {
|
|
148
|
+
const names = inspected.value.leftovers.slice(0, 5).map(scrub).join(", ")
|
|
149
|
+
return fail("artifacts", { code: "harness_not_ready", message: `the launch left ${inspected.value.leftovers.length} artifacts behind (${names})` })
|
|
150
|
+
}
|
|
151
|
+
stages.push({ stage: "artifacts", ok: true })
|
|
152
|
+
|
|
153
|
+
const cleaned = await driver.cleanup(launchID)
|
|
154
|
+
if (!cleaned.ok) return fail("cleanup", failure(cleaned))
|
|
155
|
+
stages.push({ stage: "cleanup", ok: true })
|
|
156
|
+
|
|
157
|
+
const free = await driver.slotFree(launchID)
|
|
158
|
+
if (!free.ok || !free.value) {
|
|
159
|
+
const message = `the node's slot is still held by launch ${launchID}${free.ok ? "" : ` (${scrub(free.message)})`}`
|
|
160
|
+
stages.push({ stage: "slot", ok: false, detail: message })
|
|
161
|
+
return { ok: false, launchID, stages, code: "harness_not_ready", message }
|
|
162
|
+
}
|
|
163
|
+
stages.push({ stage: "slot", ok: true })
|
|
164
|
+
return { ok: true, launchID, stages }
|
|
165
|
+
} finally {
|
|
166
|
+
deadline.cancel()
|
|
167
|
+
options.signal?.removeEventListener("abort", onCancel)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Cancel, clean up and check the slot; every step runs even when an earlier one failed. */
|
|
172
|
+
async function recover(driver: LaunchDriver, launchID: string): Promise<{ ok: true } | { ok: false; detail: string }> {
|
|
173
|
+
const problems: string[] = []
|
|
174
|
+
const settle = async <T>(what: string, p: Promise<TxResult<T>>): Promise<TxResult<T> | undefined> => {
|
|
175
|
+
try {
|
|
176
|
+
const r = await p
|
|
177
|
+
if (!r.ok) problems.push(`${what}: ${scrub(r.message)}`)
|
|
178
|
+
return r
|
|
179
|
+
} catch (error) {
|
|
180
|
+
problems.push(`${what}: ${scrub((error as Error).message)}`)
|
|
181
|
+
return undefined
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
await settle("cancel", driver.cancel(launchID))
|
|
185
|
+
await settle("cleanup", driver.cleanup(launchID))
|
|
186
|
+
const free = await settle("slot", driver.slotFree(launchID))
|
|
187
|
+
if (free?.ok && !free.value) problems.push("slot: still held")
|
|
188
|
+
return problems.length === 0 ? { ok: true } : { ok: false, detail: problems.join("; ") }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface LaunchProbe {
|
|
192
|
+
readonly driver: LaunchDriver
|
|
193
|
+
readonly timer?: LaunchTimer
|
|
194
|
+
readonly signal?: AbortSignal
|
|
195
|
+
/** Receives every report (the CLI prints its stages). */
|
|
196
|
+
readonly onReport?: (report: LaunchReport) => void
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* launch-session: the bounded verification as an external effect. It recovers by itself, so it has no undo; a
|
|
201
|
+
* failure fails the step and the applier rolls back what came before (an activation, a unit).
|
|
202
|
+
*/
|
|
203
|
+
export function launchSessionExecutor(probe?: LaunchProbe): ActionExecutor<"launch-session"> {
|
|
204
|
+
const missing = () => txFail("internal", "this hubctl was built without a launch driver")
|
|
205
|
+
return {
|
|
206
|
+
kind: "launch-session",
|
|
207
|
+
plan: () => ({ class: "external" }),
|
|
208
|
+
check: async () => (probe ? txOk(undefined) : missing()),
|
|
209
|
+
change: async (action, ctx) => {
|
|
210
|
+
if (!probe) return missing()
|
|
211
|
+
const workspace = ctx.resolve(action.workspace)
|
|
212
|
+
if (!workspace.ok) return workspace
|
|
213
|
+
return txOk({
|
|
214
|
+
type: "effect",
|
|
215
|
+
perform: async () => {
|
|
216
|
+
const report = await verifyLaunch(probe.driver, { harness: action.harness, workspace: workspace.value, maxDurationS: action.maxDurationS }, { timer: probe.timer, signal: probe.signal })
|
|
217
|
+
probe.onReport?.(report)
|
|
218
|
+
return report.ok ? txOk(undefined) : txFail(report.code ?? "harness_not_ready", report.message ?? "the verification launch failed")
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
},
|
|
222
|
+
}
|
|
223
|
+
}
|