@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,219 @@
|
|
|
1
|
+
// Escalations (design §5, §12.1; decision wvqeuis0 §7; exploration hcw63slg Security): what the acting engine's
|
|
2
|
+
// needs_user becomes. An agent asked to approve a permission request outside its grant; the user decides.
|
|
3
|
+
//
|
|
4
|
+
// - ONE Decision per (holder, request): the first escalation opens it (options approve [always] / deny (safe), the
|
|
5
|
+
// agent's recommendation, the tier the hub set on the evaluation, about the asking session); escalating the same
|
|
6
|
+
// request again updates the open Decision's reason instead (no new push). Concurrent escalations race on a
|
|
7
|
+
// version-checked reservation: one opens the Decision, the other is rate_limited (retry).
|
|
8
|
+
// - The recommendation also shows on the request's blocker (registry.recommend: catalog, status, the push's
|
|
9
|
+
// service worker reads it there).
|
|
10
|
+
// - Answering (decisions.ts runs `act` after the answer wins) replies to the permission AS THE USER through the
|
|
11
|
+
// shared executor: no grant, no kill switch (those bind agents, not the user); tenancy of the tree is checked
|
|
12
|
+
// again; the reply is in the agent audit as the user's. The request gone → the Decision closes (gone); its spoke
|
|
13
|
+
// not serving permission → it closes with the unsupported reason (audited unsupported).
|
|
14
|
+
// - The request answered elsewhere, or ended (registry.onPermissionClosed → closed()): every Decision escalating
|
|
15
|
+
// it closes.
|
|
16
|
+
// - Spam limits: ESCALATIONS_PER_HOLDER_HOUR new Decisions per holder an hour, ESCALATIONS_OPEN_MAX open ones per
|
|
17
|
+
// user (both rate_limited: the needs_user still stands, without a Decision), and pushes coalesce: a Decision
|
|
18
|
+
// opened within ESCALATION_PUSH_COALESCE_MS of the user's last escalation push opens quietly (still listed and
|
|
19
|
+
// live in the console).
|
|
20
|
+
import { type Actions, unsupportedMessage } from "../actions"
|
|
21
|
+
import type { Counters, StateStore } from "../backend/types"
|
|
22
|
+
import type { Registry } from "../registry"
|
|
23
|
+
import type { PermissionDecision } from "../../protocol"
|
|
24
|
+
import type { BlockerRecommendation } from "../../status"
|
|
25
|
+
import type { Escalation } from "./acting"
|
|
26
|
+
import type { AgentActor, UserActor } from "./actor"
|
|
27
|
+
import type { AgentAudit } from "./audit"
|
|
28
|
+
import { type ActOutcome, type Decision, type DecisionActor, type Decisions, WHY_MAX } from "./decisions"
|
|
29
|
+
import { type Party, partyFromActor, partyKey, partyLabel } from "./envelope"
|
|
30
|
+
import { AgentError } from "./errors"
|
|
31
|
+
import type { Message } from "./messages"
|
|
32
|
+
|
|
33
|
+
/** New escalation Decisions a holder opens an hour (their asks to @me also count in its message pair limit). */
|
|
34
|
+
export const ESCALATIONS_PER_HOLDER_HOUR = 20
|
|
35
|
+
/** Open escalation Decisions per user. */
|
|
36
|
+
export const ESCALATIONS_OPEN_MAX = 20
|
|
37
|
+
/** Escalations opened this soon after the user's last escalation push do not push again. */
|
|
38
|
+
export const ESCALATION_PUSH_COALESCE_MS = 60_000
|
|
39
|
+
/** A reservation older than this (its opener crashed) no longer blocks a new escalation. */
|
|
40
|
+
const RESERVATION_MS = 60_000
|
|
41
|
+
const HOUR_MS = 3_600_000
|
|
42
|
+
/** Characters of the question (the request's resources are listed up to it). */
|
|
43
|
+
const QUESTION_MAX = 2_000
|
|
44
|
+
const CLOSED_REASON = "The request was answered elsewhere or ended"
|
|
45
|
+
|
|
46
|
+
const COLLECTION = "escalations"
|
|
47
|
+
|
|
48
|
+
/** (holder, request) → its Decision; without decisionID while it is being opened. */
|
|
49
|
+
interface EscalationRecord {
|
|
50
|
+
readonly holder: string
|
|
51
|
+
readonly requestID: string
|
|
52
|
+
readonly at: number
|
|
53
|
+
readonly decisionID?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EscalationsDeps {
|
|
57
|
+
readonly state: StateStore
|
|
58
|
+
readonly counters: Counters
|
|
59
|
+
readonly decisions: Pick<Decisions, "ask" | "get" | "list" | "close" | "revise">
|
|
60
|
+
readonly registry: Pick<Registry, "recommend" | "sessionTenant">
|
|
61
|
+
readonly actions: Pick<Actions, "answerPermission">
|
|
62
|
+
readonly audit?: Pick<AgentAudit, "record">
|
|
63
|
+
/** The holder as envelopes name it (a session by its directory name); default: from the actor alone. */
|
|
64
|
+
readonly describe?: (userID: string, actor: AgentActor) => Party
|
|
65
|
+
/** The Decision's ask was stored (the console's message feed). */
|
|
66
|
+
readonly published?: (userID: string, message: Message) => void
|
|
67
|
+
readonly now?: () => number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rateLimited = (message: string, retryAfter: number) => new AgentError("rate_limited", message, { extra: { retryAfter } })
|
|
71
|
+
|
|
72
|
+
export class Escalations {
|
|
73
|
+
private readonly now: () => number
|
|
74
|
+
|
|
75
|
+
constructor(private readonly deps: EscalationsDeps) {
|
|
76
|
+
this.now = deps.now ?? Date.now
|
|
77
|
+
this.act = this.act.bind(this)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The acting engine's escalate hook: opens or updates the holder's Decision about the request. */
|
|
81
|
+
async escalate(escalation: Escalation): Promise<{ decisionID?: string }> {
|
|
82
|
+
const { userID } = escalation
|
|
83
|
+
const holder = this.describe(userID, escalation.actor)
|
|
84
|
+
const key = `${partyKey(holder)} ${escalation.requestID}`
|
|
85
|
+
const { state } = this.deps
|
|
86
|
+
const existing = await state.get<EscalationRecord>(userID, COLLECTION, key)
|
|
87
|
+
if (existing?.value.decisionID) {
|
|
88
|
+
const decision = await this.deps.decisions.get(userID, existing.value.decisionID)
|
|
89
|
+
if (decision?.state === "open") return this.update(escalation, holder, decision)
|
|
90
|
+
} else if (existing && this.now() - existing.value.at < RESERVATION_MS) {
|
|
91
|
+
throw rateLimited("An escalation of this request is being opened", 1)
|
|
92
|
+
}
|
|
93
|
+
const record: EscalationRecord = { holder: partyKey(holder), requestID: escalation.requestID, at: this.now() }
|
|
94
|
+
const reserved = await state.put(userID, COLLECTION, key, record, { ifVersion: existing?.version ?? 0 })
|
|
95
|
+
if (!reserved.ok) throw rateLimited("An escalation of this request is being opened", 1)
|
|
96
|
+
try {
|
|
97
|
+
const decision = await this.open(escalation, holder)
|
|
98
|
+
await state.put(userID, COLLECTION, key, { ...record, decisionID: decision.id }, { ifVersion: reserved.version })
|
|
99
|
+
return { decisionID: decision.id }
|
|
100
|
+
} catch (cause) {
|
|
101
|
+
await state.delete(userID, COLLECTION, key, { ifVersion: reserved.version })
|
|
102
|
+
throw cause
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Decisions' action runner: replies to the escalated permission as the answering user. */
|
|
107
|
+
async act(userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome> {
|
|
108
|
+
const action = decision.action
|
|
109
|
+
if (!action || action.kind !== "permission") return "gone"
|
|
110
|
+
const reply: PermissionDecision = choice.option === "always" ? "always" : choice.option === "approve" ? "once" : "reject"
|
|
111
|
+
const verb = reply === "reject" ? "deny" : "approve"
|
|
112
|
+
const owned = this.deps.registry.sessionTenant(action.rootID) === by.userID && by.userID === userID
|
|
113
|
+
const answered = owned ? await this.deps.actions.answerPermission({ rootID: action.rootID, requestID: action.requestID, decision: reply }) : { outcome: "gone" as const }
|
|
114
|
+
await this.deps.audit?.record(userID, {
|
|
115
|
+
actor: { kind: "user" },
|
|
116
|
+
verb,
|
|
117
|
+
target: { sessionID: action.sessionID, requestID: action.requestID },
|
|
118
|
+
decision: "allow",
|
|
119
|
+
reason: `Decision ${decision.id}`,
|
|
120
|
+
outcome: answered.outcome === "done" ? "ok" : answered.outcome,
|
|
121
|
+
})
|
|
122
|
+
await this.forget(userID, action.requestID)
|
|
123
|
+
// The spoke holding the request cannot answer permissions: the Decision closes saying so (not "answered elsewhere").
|
|
124
|
+
if (answered.outcome === "unsupported") return { outcome: "gone", reason: unsupportedMessage(answered) }
|
|
125
|
+
return answered.outcome === "done" ? "done" : "gone"
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The request went away (answered elsewhere, ended): closes every Decision escalating it. */
|
|
129
|
+
async closed(userID: string | undefined, requestID: string): Promise<void> {
|
|
130
|
+
if (userID === undefined) return
|
|
131
|
+
for (const { key, value } of await this.deps.state.list<EscalationRecord>(userID, COLLECTION)) {
|
|
132
|
+
if (value.requestID !== requestID) continue
|
|
133
|
+
if (value.decisionID) await this.deps.decisions.close(userID, value.decisionID, CLOSED_REASON)
|
|
134
|
+
await this.deps.state.delete(userID, COLLECTION, key)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- internals ----------------------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
private describe(userID: string, actor: AgentActor): Party {
|
|
141
|
+
return this.deps.describe?.(userID, actor) ?? partyFromActor(actor)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A new Decision, within the spam limits. */
|
|
145
|
+
private async open(escalation: Escalation, holder: Party): Promise<Decision> {
|
|
146
|
+
const { userID } = escalation
|
|
147
|
+
const { decisions, counters } = this.deps
|
|
148
|
+
const open = (await decisions.list(userID, { state: "open" })).filter((d) => d.action?.kind === "permission").length
|
|
149
|
+
if (open >= ESCALATIONS_OPEN_MAX) throw rateLimited(`At most ${ESCALATIONS_OPEN_MAX} escalations wait for the user at once`, 60)
|
|
150
|
+
const opened = await counters.incr(`escalations:${userID}:${partyKey(holder)}`, HOUR_MS)
|
|
151
|
+
if (opened > ESCALATIONS_PER_HOLDER_HOUR) throw rateLimited(`At most ${ESCALATIONS_PER_HOLDER_HOUR} escalations an hour`, 3600)
|
|
152
|
+
const pushes = await counters.incr(`escalation-push:${userID}`, ESCALATION_PUSH_COALESCE_MS)
|
|
153
|
+
const recommend = escalation.always ? "always" : "approve"
|
|
154
|
+
const why = boundedWhy(escalation.why)
|
|
155
|
+
const { message, decision } = await decisions.ask(
|
|
156
|
+
userID,
|
|
157
|
+
escalation.actor,
|
|
158
|
+
{
|
|
159
|
+
question: question(escalation, partyLabel(holder)),
|
|
160
|
+
options: [
|
|
161
|
+
...(escalation.always ? [{ id: "always", label: "Approve always" }] : []),
|
|
162
|
+
{ id: "approve", label: "Approve once" },
|
|
163
|
+
{ id: "deny", label: "Deny", safe: true },
|
|
164
|
+
],
|
|
165
|
+
recommendation: { option: recommend, ...(why ? { why } : {}) },
|
|
166
|
+
urgency: "high",
|
|
167
|
+
tier: escalation.evaluation.tier,
|
|
168
|
+
about: [escalation.sessionID],
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
action: { kind: "permission", requestID: escalation.requestID, rootID: escalation.rootID, sessionID: escalation.sessionID, always: escalation.always },
|
|
172
|
+
quiet: pushes > 1,
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
this.deps.published?.(userID, message)
|
|
176
|
+
this.showOnBlocker(escalation.requestID, holder, decision)
|
|
177
|
+
return decision
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The same holder escalated the request again: a new reason replaces the recommendation's. */
|
|
181
|
+
private async update(escalation: Escalation, holder: Party, decision: Decision): Promise<{ decisionID: string }> {
|
|
182
|
+
const why = boundedWhy(escalation.why)
|
|
183
|
+
let current = decision
|
|
184
|
+
if (why && why !== decision.recommendation?.why) {
|
|
185
|
+
const option = decision.recommendation?.option ?? (escalation.always ? "always" : "approve")
|
|
186
|
+
current = (await this.deps.decisions.revise(escalation.userID, decision.id, { recommendation: { option, why } })) ?? decision
|
|
187
|
+
}
|
|
188
|
+
this.showOnBlocker(escalation.requestID, holder, current)
|
|
189
|
+
return { decisionID: decision.id }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private showOnBlocker(requestID: string, holder: Party, decision: Decision): void {
|
|
193
|
+
const why = decision.recommendation?.why
|
|
194
|
+
const recommendation: BlockerRecommendation = { by: partyLabel(holder), recommends: "approve", ...(why ? { why } : {}), decisionID: decision.id }
|
|
195
|
+
this.deps.registry.recommend(requestID, recommendation)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Drops the (holder, request) records of an answered request. */
|
|
199
|
+
private async forget(userID: string, requestID: string): Promise<void> {
|
|
200
|
+
for (const { key, value } of await this.deps.state.list<EscalationRecord>(userID, COLLECTION)) {
|
|
201
|
+
if (value.requestID === requestID) await this.deps.state.delete(userID, COLLECTION, key)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The agent's reason, cut to what a Decision keeps. */
|
|
207
|
+
function boundedWhy(why: string | undefined): string | undefined {
|
|
208
|
+
const trimmed = why?.trim()
|
|
209
|
+
return trimmed ? Array.from(trimmed).slice(0, WHY_MAX).join("") : undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** "@orchestrator asks to approve shell: bun test, bun run lint. Outside its grant: …" (bounded). */
|
|
213
|
+
function question(escalation: Escalation, by: string): string {
|
|
214
|
+
const { request, evaluation } = escalation
|
|
215
|
+
const what = request ? `${request.action}${request.resources.length ? `: ${request.resources.join(", ")}` : ""}` : "a permission request (details unknown)"
|
|
216
|
+
const always = escalation.always && request?.save?.length ? ` always (saves ${request.save.join(", ")})` : escalation.always ? " always" : ""
|
|
217
|
+
const text = `${by} asks to approve${always} ${what}. Outside its grant: ${evaluation.reason}`
|
|
218
|
+
return text.length > QUESTION_MAX ? `${text.slice(0, QUESTION_MAX - 1)}…` : text
|
|
219
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Cursors of GET /api/agent/events (lifecycle design, frozen "Events route and tool"): opaque, versioned,
|
|
2
|
+
// authenticated, tenant-bound strings over a journal position (`<epoch>.<n>`).
|
|
3
|
+
//
|
|
4
|
+
// hev1_<base64url(kid "\n" position)>.<base64url(mac)>
|
|
5
|
+
//
|
|
6
|
+
// mac is HMAC-SHA256 under the token signing key `kid` (token-keys.ts) of a domain-separated string that also binds
|
|
7
|
+
// the user, truncated to 16 bytes; another user's cursor, a changed byte, an unknown key or another version all fail
|
|
8
|
+
// to decode (400 invalid_cursor), never reset. The user never appears in the cursor itself.
|
|
9
|
+
import { createHmac, timingSafeEqual } from "node:crypto"
|
|
10
|
+
|
|
11
|
+
export const CURSOR_PREFIX = "hev1_"
|
|
12
|
+
const DOMAIN = "hub-agent-events-cursor/v1"
|
|
13
|
+
const MAC_BYTES = 16
|
|
14
|
+
const POSITION = /^([a-z0-9]{1,40})\.(\d{1,15})$/
|
|
15
|
+
const KID = /^[A-Za-z0-9_-]{1,64}$/
|
|
16
|
+
const B64URL = /^[A-Za-z0-9_-]+$/
|
|
17
|
+
|
|
18
|
+
export interface CursorKeys {
|
|
19
|
+
active(): { kid: string; secret: Buffer }
|
|
20
|
+
key(kid: string): Buffer | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A journal position: its epoch and n. */
|
|
24
|
+
export function parsePosition(position: string): { epoch: string; n: number } | undefined {
|
|
25
|
+
const match = POSITION.exec(position)
|
|
26
|
+
return match ? { epoch: match[1]!, n: Number(match[2]) } : undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const mac = (secret: Buffer, userID: string, kid: string, position: string) => createHmac("sha256", secret).update(`${DOMAIN}\n${userID}\n${kid}\n${position}`).digest().subarray(0, MAC_BYTES)
|
|
30
|
+
|
|
31
|
+
export function encodeCursor(keys: CursorKeys, userID: string, position: string): string {
|
|
32
|
+
const { kid, secret } = keys.active()
|
|
33
|
+
const body = Buffer.from(`${kid}\n${position}`).toString("base64url")
|
|
34
|
+
return `${CURSOR_PREFIX}${body}.${mac(secret, userID, kid, position).toString("base64url")}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The position of a cursor this hub gave this user; undefined for anything else. */
|
|
38
|
+
export function decodeCursor(keys: CursorKeys, userID: string, cursor: string): string | undefined {
|
|
39
|
+
if (cursor.length > 300 || !cursor.startsWith(CURSOR_PREFIX)) return undefined
|
|
40
|
+
const [body, tag, ...rest] = cursor.slice(CURSOR_PREFIX.length).split(".")
|
|
41
|
+
if (rest.length || !body || !tag || !B64URL.test(body) || !B64URL.test(tag)) return undefined
|
|
42
|
+
const [kid, position, ...extra] = Buffer.from(body, "base64url").toString("utf8").split("\n")
|
|
43
|
+
if (extra.length || !kid || !position || !KID.test(kid) || !parsePosition(position)) return undefined
|
|
44
|
+
const secret = keys.key(kid)
|
|
45
|
+
if (!secret) return undefined
|
|
46
|
+
const given = Buffer.from(tag, "base64url")
|
|
47
|
+
const expected = mac(secret, userID, kid, position)
|
|
48
|
+
return given.length === expected.length && timingSafeEqual(given, expected) ? position : undefined
|
|
49
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Writers of the canonical agent event journal (events.ts; lifecycle design, Event rollout stage 1). Each change
|
|
2
|
+
// is appended exactly once across a cluster, by the one place that sees it first:
|
|
3
|
+
//
|
|
4
|
+
// - state and blockers: the pod holding the spoke (the registry's status messages are local to it), diffed per tree;
|
|
5
|
+
// - session and machine presence: the "agent-events" leader, diffing the cluster-wide catalog per user against a
|
|
6
|
+
// snapshot it keeps in the Kv, so a new leader continues where the last one stopped (none: everything present
|
|
7
|
+
// is new);
|
|
8
|
+
// - messages and launches: the pod that published them (Feed origin listeners);
|
|
9
|
+
// - archive changes: the route that made them (directory-routes).
|
|
10
|
+
//
|
|
11
|
+
// Appends of one writer are serialized, so a user's events keep their order. Failures are reported, never thrown.
|
|
12
|
+
import type { Kv } from "../backend/types"
|
|
13
|
+
import type { CatalogMachine, RegistryMessage } from "../registry"
|
|
14
|
+
import { AGENT_EVENTS_AGE_MS, type AgentEvents, type EventInput, launchEvent, messageEvent, presenceEvents, type PresenceView, StatusDiff } from "./events"
|
|
15
|
+
import type { Launch } from "./launch-store"
|
|
16
|
+
import type { Message } from "./messages"
|
|
17
|
+
|
|
18
|
+
interface Subscribable<T> {
|
|
19
|
+
subscribe(listener: (userID: string, item: T) => void, options?: { readonly origin?: boolean }): () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface EventWriterRegistry {
|
|
23
|
+
subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
|
|
24
|
+
catalog(): CatalogMachine[]
|
|
25
|
+
sessionTenant(sessionID: string): string | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PresenceLeader {
|
|
29
|
+
isLeader(): boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EventWritersOptions {
|
|
33
|
+
readonly events: AgentEvents
|
|
34
|
+
readonly kv: Kv
|
|
35
|
+
readonly registry: EventWriterRegistry
|
|
36
|
+
/** The owner of a stored machine (undefined: not stored, e.g. the local machine). */
|
|
37
|
+
readonly machineOwner: (machineID: string) => string | undefined
|
|
38
|
+
/** Every stored user (a new leader loads their presence snapshots). */
|
|
39
|
+
readonly users: () => Promise<string[]>
|
|
40
|
+
readonly messages?: Subscribable<Message>
|
|
41
|
+
readonly launches?: Subscribable<Launch>
|
|
42
|
+
readonly report?: (cause: unknown) => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const snapshotKey = (userID: string) => `aev:presence:${userID}`
|
|
46
|
+
|
|
47
|
+
const EMPTY: PresenceView = { sessions: new Set(), machines: new Map() }
|
|
48
|
+
|
|
49
|
+
/** Presence of every user in a catalog. */
|
|
50
|
+
export function presenceOf(machines: readonly CatalogMachine[], tenant: (sessionID: string) => string | undefined, owner: (machineID: string) => string | undefined): Map<string, PresenceView> {
|
|
51
|
+
const out = new Map<string, { sessions: Set<string>; machines: Map<string, string> }>()
|
|
52
|
+
const view = (userID: string) => {
|
|
53
|
+
let v = out.get(userID)
|
|
54
|
+
if (!v) out.set(userID, (v = { sessions: new Set(), machines: new Map() }))
|
|
55
|
+
return v
|
|
56
|
+
}
|
|
57
|
+
for (const machine of machines) {
|
|
58
|
+
const users = new Set<string>()
|
|
59
|
+
const stored = owner(machine.id)
|
|
60
|
+
if (stored !== undefined) users.add(stored)
|
|
61
|
+
for (const process of machine.processes) {
|
|
62
|
+
for (const s of process.sessions) {
|
|
63
|
+
const userID = tenant(s.id)
|
|
64
|
+
if (userID === undefined) continue
|
|
65
|
+
view(userID).sessions.add(s.id)
|
|
66
|
+
if (stored === undefined) users.add(userID)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (machine.processes.length > 0) for (const userID of users) view(userID).machines.set(machine.id, machine.name)
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class EventWriters {
|
|
75
|
+
private readonly options: EventWritersOptions
|
|
76
|
+
private readonly diff = new StatusDiff()
|
|
77
|
+
private readonly report: (cause: unknown) => void
|
|
78
|
+
private queue: Promise<void> = Promise.resolve()
|
|
79
|
+
/** Presence as last journaled, per user (leader only; undefined when not leading). */
|
|
80
|
+
private presence: Map<string, PresenceView> | undefined
|
|
81
|
+
private readonly offs: (() => void)[] = []
|
|
82
|
+
|
|
83
|
+
constructor(options: EventWritersOptions) {
|
|
84
|
+
this.options = options
|
|
85
|
+
this.report = options.report ?? ((cause) => console.error(`[hub] agent event write failed: ${cause instanceof Error ? cause.message : String(cause)}`))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
start(): void {
|
|
89
|
+
const { registry, messages, launches } = this.options
|
|
90
|
+
this.offs.push(
|
|
91
|
+
registry.subscribe((message, tenant) => {
|
|
92
|
+
if (message.type === "status" && tenant !== undefined) this.write(tenant, this.diff.events(message.sessionID, message.sessions))
|
|
93
|
+
else if (message.type === "catalog") {
|
|
94
|
+
this.forgetGone()
|
|
95
|
+
if (this.presence) this.enqueue(() => this.presenceChanged())
|
|
96
|
+
}
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
if (messages) this.offs.push(messages.subscribe((userID, message) => this.write(userID, [messageEvent(message)]), { origin: true }))
|
|
100
|
+
if (launches) this.offs.push(launches.subscribe((userID, launch) => this.write(userID, [launchEvent(launch)]), { origin: true }))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
stop(): void {
|
|
104
|
+
for (const off of this.offs.splice(0)) off()
|
|
105
|
+
this.presence = undefined
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Became the presence leader: load the last leader's snapshots, then journal what changed since. */
|
|
109
|
+
elected(): void {
|
|
110
|
+
this.enqueue(async () => {
|
|
111
|
+
const presence = new Map<string, PresenceView>()
|
|
112
|
+
for (const userID of await this.options.users()) {
|
|
113
|
+
const raw = await this.options.kv.get(snapshotKey(userID))
|
|
114
|
+
if (raw) presence.set(userID, parseSnapshot(raw))
|
|
115
|
+
}
|
|
116
|
+
this.presence = presence
|
|
117
|
+
await this.presenceChanged()
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
deposed(): void {
|
|
122
|
+
this.enqueue(async () => {
|
|
123
|
+
this.presence = undefined
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolves once every queued append is done (tests, drain). */
|
|
128
|
+
settled(): Promise<void> {
|
|
129
|
+
return this.queue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private write(userID: string, events: readonly EventInput[]): void {
|
|
133
|
+
if (events.length === 0) return
|
|
134
|
+
this.enqueue(async () => {
|
|
135
|
+
for (const event of events) await this.options.events.append(userID, event)
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private enqueue(job: () => Promise<void>): void {
|
|
140
|
+
this.queue = this.queue.then(job).catch(this.report)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async presenceChanged(): Promise<void> {
|
|
144
|
+
const before = this.presence
|
|
145
|
+
if (!before) return
|
|
146
|
+
const { registry, machineOwner, events, kv } = this.options
|
|
147
|
+
const after = presenceOf(registry.catalog(), (id) => registry.sessionTenant(id), machineOwner)
|
|
148
|
+
for (const userID of new Set([...before.keys(), ...after.keys()])) {
|
|
149
|
+
const prev = before.get(userID) ?? EMPTY
|
|
150
|
+
const next = after.get(userID) ?? EMPTY
|
|
151
|
+
const changes = presenceEvents(prev, next)
|
|
152
|
+
if (changes.length === 0) continue
|
|
153
|
+
for (const event of changes) await events.append(userID, event)
|
|
154
|
+
if (next.sessions.size === 0 && next.machines.size === 0) {
|
|
155
|
+
before.delete(userID)
|
|
156
|
+
await kv.del(snapshotKey(userID))
|
|
157
|
+
} else {
|
|
158
|
+
before.set(userID, next)
|
|
159
|
+
await kv.set(snapshotKey(userID), JSON.stringify({ sessions: [...next.sessions], machines: [...next.machines] }), { px: AGENT_EVENTS_AGE_MS })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Drops the status diffs of trees no longer in the catalog. */
|
|
165
|
+
private forgetGone(): void {
|
|
166
|
+
const present = new Set<string>()
|
|
167
|
+
for (const machine of this.options.registry.catalog()) for (const process of machine.processes) for (const s of process.sessions) present.add(s.id)
|
|
168
|
+
for (const rootID of this.diff.roots()) if (!present.has(rootID)) this.diff.forget(rootID)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseSnapshot(raw: string): PresenceView {
|
|
173
|
+
try {
|
|
174
|
+
const value = JSON.parse(raw) as { sessions?: unknown; machines?: unknown }
|
|
175
|
+
const sessions = Array.isArray(value.sessions) ? value.sessions.filter((id): id is string => typeof id === "string") : []
|
|
176
|
+
const machines = Array.isArray(value.machines) ? value.machines.filter((m): m is [string, string] => Array.isArray(m) && typeof m[0] === "string" && typeof m[1] === "string") : []
|
|
177
|
+
return { sessions: new Set(sessions), machines: new Map(machines) }
|
|
178
|
+
} catch {
|
|
179
|
+
return EMPTY
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// GET /api/agent/events?cursor=&wait=&limit=&type=&session= (lifecycle design, frozen "Events route and tool"):
|
|
2
|
+
// the canonical journal (events.ts) read with an opaque cursor (event-cursor.ts).
|
|
3
|
+
//
|
|
4
|
+
// - No cursor: no events and a cursor at the latest record (a long poll then waits for what comes next).
|
|
5
|
+
// - wait: seconds 0–60 to hold the request while nothing matches (local appends wake it at once; appends on other
|
|
6
|
+
// pods are rechecked every RECHECK_MS). limit: 1–200 (default 50). type and session may repeat (≤ 20 each);
|
|
7
|
+
// a type ending in `.*` matches a family of types.
|
|
8
|
+
// - At most INSPECT_MAX records are inspected per request. Filters advance the cursor over every inspected record;
|
|
9
|
+
// `more` means records remain unread, matching or not.
|
|
10
|
+
// - A cursor the journal no longer covers (expired, trimmed, another epoch after a restart) answers a typed reset
|
|
11
|
+
// with a fresh cursor and the reads to refresh; a malformed, forged, future, unsupported-version or another
|
|
12
|
+
// user's cursor answers 400 invalid_cursor.
|
|
13
|
+
//
|
|
14
|
+
// Who sees what: the user everything. App tokens need events:subscribe for the route and, per family, sessions:read
|
|
15
|
+
// (session), sessions:manage (manage), messages:read and being a party (message), sessions:launch and having started
|
|
16
|
+
// it (launch). Session tokens see session and manage events, messages they are a party to and launches they started.
|
|
17
|
+
// Reader-only fields (family, audience, position) never leave the hub.
|
|
18
|
+
import type { AgentActor } from "./actor"
|
|
19
|
+
import type { Directory } from "./directory"
|
|
20
|
+
import { decodeCursor, encodeCursor, parsePosition, type CursorKeys } from "./event-cursor"
|
|
21
|
+
import type { AgentEvent, AgentEvents, EventRecord } from "./events"
|
|
22
|
+
import { partyFromActor, partyKey } from "./envelope"
|
|
23
|
+
import { AgentError } from "./errors"
|
|
24
|
+
import { launchedBy } from "./launch-routes"
|
|
25
|
+
import { AGENT_PREFIX, type AgentRoute } from "./routes"
|
|
26
|
+
|
|
27
|
+
export const EVENTS_WAIT_MAX_S = 60
|
|
28
|
+
export const EVENTS_LIMIT_MAX = 200
|
|
29
|
+
export const EVENTS_LIMIT_DEFAULT = 50
|
|
30
|
+
export const INSPECT_MAX = 1000
|
|
31
|
+
export const RECHECK_MS = 1000
|
|
32
|
+
export const RESET_REFRESH = ["status", "sessions", "inbox", "decisions", "launches"] as const
|
|
33
|
+
const FILTER_MAX = 20
|
|
34
|
+
const TYPE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*(\.\*)?$/
|
|
35
|
+
|
|
36
|
+
const NO_STORE = { "cache-control": "no-store" }
|
|
37
|
+
|
|
38
|
+
export interface EventsRouteDeps {
|
|
39
|
+
readonly events: Pick<AgentEvents, "latest" | "since" | "appended">
|
|
40
|
+
readonly keys: CursorKeys
|
|
41
|
+
readonly directory: Pick<Directory, "resolve">
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface EventsPage {
|
|
45
|
+
readonly events: AgentEvent[]
|
|
46
|
+
readonly cursor: string
|
|
47
|
+
readonly more: boolean
|
|
48
|
+
readonly reset: boolean
|
|
49
|
+
readonly refresh?: readonly string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const invalid = (message: string) => new AgentError("invalid", message)
|
|
53
|
+
const badCursor = () => new AgentError("invalid_cursor", "This cursor is not valid here: start again without one", { hint: `GET ${AGENT_PREFIX}/events without cursor` })
|
|
54
|
+
|
|
55
|
+
function intParam(url: URL, key: string, min: number, max: number, fallback: number): number {
|
|
56
|
+
const raw = url.searchParams.get(key)
|
|
57
|
+
if (raw === null || raw === "") return fallback
|
|
58
|
+
if (!/^\d+$/.test(raw) || Number(raw) < min || Number(raw) > max) throw invalid(`${key} must be a whole number from ${min} to ${max}`)
|
|
59
|
+
return Number(raw)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether an actor may read a record. */
|
|
63
|
+
export function visible(record: EventRecord, actor: AgentActor): boolean {
|
|
64
|
+
if (actor.kind === "user") return true
|
|
65
|
+
const party = partyKey(partyFromActor(actor))
|
|
66
|
+
const has = (scope: string) => actor.kind === "session" || actor.scopes.includes(scope)
|
|
67
|
+
switch (record.family) {
|
|
68
|
+
case "session":
|
|
69
|
+
return has("sessions:read")
|
|
70
|
+
case "manage":
|
|
71
|
+
return has("sessions:manage")
|
|
72
|
+
case "message":
|
|
73
|
+
return has("messages:read") && (record.audience?.parties ?? []).includes(party)
|
|
74
|
+
case "launch":
|
|
75
|
+
return has("sessions:launch") && record.audience?.by === launchedBy(actor)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function eventsRoutes(deps: EventsRouteDeps): AgentRoute[] {
|
|
80
|
+
return [
|
|
81
|
+
{
|
|
82
|
+
method: "GET",
|
|
83
|
+
path: `${AGENT_PREFIX}/events`,
|
|
84
|
+
allow: { session: true, scope: "events:subscribe" },
|
|
85
|
+
handle: async ({ user, url, actor, request }) => {
|
|
86
|
+
const wait = intParam(url, "wait", 0, EVENTS_WAIT_MAX_S, 0)
|
|
87
|
+
const limit = intParam(url, "limit", 1, EVENTS_LIMIT_MAX, EVENTS_LIMIT_DEFAULT)
|
|
88
|
+
const types = url.searchParams.getAll("type").filter(Boolean)
|
|
89
|
+
const addrs = url.searchParams.getAll("session").filter(Boolean)
|
|
90
|
+
if (types.length > FILTER_MAX || addrs.length > FILTER_MAX) throw invalid(`At most ${FILTER_MAX} type and ${FILTER_MAX} session filters`)
|
|
91
|
+
if (types.some((t) => t.length > 100 || !TYPE.test(t))) throw invalid("type must be an event type such as session.changed or launch.*")
|
|
92
|
+
const sessions = addrs.length ? new Set(addrs.flatMap((addr) => deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID))) : undefined
|
|
93
|
+
const page = await read(deps, user.id, actor, { cursor: url.searchParams.get("cursor") ?? undefined, wait, limit, types, sessions }, request.signal)
|
|
94
|
+
return Response.json(page, { headers: NO_STORE })
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface ReadOptions {
|
|
101
|
+
readonly cursor?: string
|
|
102
|
+
readonly wait: number
|
|
103
|
+
readonly limit: number
|
|
104
|
+
readonly types: readonly string[]
|
|
105
|
+
readonly sessions?: ReadonlySet<string>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const typeMatches = (types: readonly string[], type: string) => types.length === 0 || types.some((t) => (t.endsWith(".*") ? type.startsWith(t.slice(0, -1)) : t === type))
|
|
109
|
+
|
|
110
|
+
const sessionMatches = (sessions: ReadonlySet<string> | undefined, event: AgentEvent) =>
|
|
111
|
+
!sessions || [event.subject.sessionID, event.subject.rootID, event.subject.senderID].some((id) => id !== undefined && sessions.has(id))
|
|
112
|
+
|
|
113
|
+
export async function read(deps: EventsRouteDeps, userID: string, actor: AgentActor, options: ReadOptions, signal?: AbortSignal): Promise<EventsPage> {
|
|
114
|
+
const { events, keys } = deps
|
|
115
|
+
const page = (position: string, found: AgentEvent[], more: boolean): EventsPage => ({ events: found, cursor: encodeCursor(keys, userID, position), more, reset: false })
|
|
116
|
+
let position: string
|
|
117
|
+
if (options.cursor === undefined || options.cursor === "") {
|
|
118
|
+
position = await events.latest(userID)
|
|
119
|
+
if (options.wait === 0) return page(position, [], false)
|
|
120
|
+
} else {
|
|
121
|
+
const decoded = decodeCursor(keys, userID, options.cursor)
|
|
122
|
+
const held = decoded ? parsePosition(decoded) : undefined
|
|
123
|
+
if (!decoded || !held) throw badCursor()
|
|
124
|
+
const latest = parsePosition(await events.latest(userID))
|
|
125
|
+
if (latest && latest.epoch === held.epoch && held.n > latest.n) throw badCursor()
|
|
126
|
+
position = decoded
|
|
127
|
+
}
|
|
128
|
+
const deadline = performance.now() + options.wait * 1000
|
|
129
|
+
for (;;) {
|
|
130
|
+
const result = await events.since(userID, position)
|
|
131
|
+
if ("reset" in result) return { events: [], cursor: encodeCursor(keys, userID, await events.latest(userID)), more: false, reset: true, refresh: RESET_REFRESH }
|
|
132
|
+
const found: AgentEvent[] = []
|
|
133
|
+
let more = result.records.length > INSPECT_MAX
|
|
134
|
+
for (const record of result.records.slice(0, INSPECT_MAX)) {
|
|
135
|
+
if (visible(record, actor) && typeMatches(options.types, record.event.type) && sessionMatches(options.sessions, record.event)) {
|
|
136
|
+
if (found.length === options.limit) {
|
|
137
|
+
more = true
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
found.push(record.event)
|
|
141
|
+
}
|
|
142
|
+
position = record.position
|
|
143
|
+
}
|
|
144
|
+
const left = deadline - performance.now()
|
|
145
|
+
if (found.length > 0 || more || left <= 0 || signal?.aborted) return page(position, found, more)
|
|
146
|
+
await events.appended(userID, Math.min(RECHECK_MS, left), signal)
|
|
147
|
+
}
|
|
148
|
+
}
|