@huynle/hub 0.7.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 +70 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- 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 +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -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/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +115 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/store.ts +53 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -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 +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -19,9 +19,8 @@ import type { Directory, DirectoryEntry, RoleView } from "./directory"
|
|
|
19
19
|
import type { AgentActor } from "./actor"
|
|
20
20
|
import { AgentError } from "./errors"
|
|
21
21
|
import { AGENT_PREFIX, type AgentRoute } from "./routes"
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
import { type LaunchSource, watchStream, type MessageSource, type WatchRegistry } from "./watch"
|
|
22
|
+
import { watchStream } from "./watch"
|
|
23
|
+
import type { EventsRouteDeps } from "./events-routes"
|
|
25
24
|
|
|
26
25
|
/** Labels per session ID. */
|
|
27
26
|
export type SessionLabels = ReadonlyMap<string, Readonly<Record<string, string>>>
|
|
@@ -46,14 +45,10 @@ export interface ReadDeps {
|
|
|
46
45
|
readonly directory: Directory
|
|
47
46
|
readonly registry: ReadRegistry
|
|
48
47
|
readonly spokes?: SpokeRequester
|
|
49
|
-
/** Agent messages for the watch stream's message events. */
|
|
50
|
-
readonly feed?: MessageSource
|
|
51
48
|
/** The labels of the user's launched sessions (none without it). */
|
|
52
49
|
readonly labels?: (userID: string) => Promise<SessionLabels>
|
|
53
|
-
/**
|
|
54
|
-
readonly
|
|
55
|
-
/** What the watch stream of a user reads (cluster mode: status from every pod); the registry when absent. */
|
|
56
|
-
readonly watchRegistry?: (userID: string) => WatchRegistry
|
|
50
|
+
/** The journal GET /api/agent/watch streams (no watch route without it). */
|
|
51
|
+
readonly events?: EventsRouteDeps
|
|
57
52
|
/** Watch stream heartbeat (default WATCH_HEARTBEAT_MS). */
|
|
58
53
|
readonly heartbeatMs?: number
|
|
59
54
|
/** Aborted when the hub drains: watch streams end with a `retry:` hint. */
|
|
@@ -358,32 +353,33 @@ export function readRoutes(deps: ReadDeps): AgentRoute[] {
|
|
|
358
353
|
allow: READ,
|
|
359
354
|
handle: async ({ user, params, url }) => ok(await log(deps, user.id, params.addr!, url.searchParams.get("n"))),
|
|
360
355
|
},
|
|
361
|
-
|
|
362
|
-
method: "GET",
|
|
363
|
-
path: `${AGENT_PREFIX}/watch`,
|
|
364
|
-
allow: READ,
|
|
365
|
-
handle: ({ user, url, request, actor }) => {
|
|
366
|
-
const addr = url.searchParams.get("session") || undefined
|
|
367
|
-
// Message events: the user sees all; a session, or an app with messages:read, those it is a party to.
|
|
368
|
-
const messageReader = actor.kind === "user" || actor.kind === "session" || actor.scopes.includes("messages:read")
|
|
369
|
-
const messages = deps.feed && messageReader ? { feed: deps.feed, ...(actor.kind === "user" ? {} : { party: partyKey(partyFromActor(actor)) }) } : undefined
|
|
370
|
-
const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
|
|
371
|
-
const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
|
|
372
|
-
// Launch events: the user sees all; an app or session those it started.
|
|
373
|
-
const launches = deps.launches ? { feed: deps.launches, ...(actor.kind === "user" ? {} : { by: launchedBy(actor) }) } : undefined
|
|
374
|
-
return watchStream({
|
|
375
|
-
registry: deps.watchRegistry?.(user.id) ?? deps.registry,
|
|
376
|
-
userID: user.id,
|
|
377
|
-
signal: request.signal,
|
|
378
|
-
blocked,
|
|
379
|
-
...(sessions ? { sessions } : {}),
|
|
380
|
-
...(messages ? { messages } : {}),
|
|
381
|
-
...(launches ? { launches } : {}),
|
|
382
|
-
...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
|
|
383
|
-
...(deps.now ? { now: deps.now } : {}),
|
|
384
|
-
...(deps.draining ? { draining: deps.draining } : {}),
|
|
385
|
-
})
|
|
386
|
-
},
|
|
387
|
-
},
|
|
356
|
+
...(deps.events ? [watchRoute(deps, deps.events)] : []),
|
|
388
357
|
]
|
|
389
358
|
}
|
|
359
|
+
|
|
360
|
+
/** GET /api/agent/watch: the journal's events live (watch.ts), with the events route's visibility and filters. */
|
|
361
|
+
function watchRoute(deps: ReadDeps, events: EventsRouteDeps): AgentRoute {
|
|
362
|
+
return {
|
|
363
|
+
method: "GET",
|
|
364
|
+
path: `${AGENT_PREFIX}/watch`,
|
|
365
|
+
allow: READ,
|
|
366
|
+
handle: ({ user, url, request, actor }) => {
|
|
367
|
+
const addr = url.searchParams.get("session") || undefined
|
|
368
|
+
const sessions = addr ? new Set(deps.directory.resolve(user.id, addr).sessions.map((s) => s.sessionID)) : undefined
|
|
369
|
+
const blocked = ["1", "true"].includes(url.searchParams.get("blocked") ?? "")
|
|
370
|
+
const cursor = url.searchParams.get("cursor") || request.headers.get("last-event-id") || undefined
|
|
371
|
+
return watchStream({
|
|
372
|
+
events,
|
|
373
|
+
userID: user.id,
|
|
374
|
+
actor,
|
|
375
|
+
signal: request.signal,
|
|
376
|
+
...(blocked ? { types: ["blocker.*"] } : {}),
|
|
377
|
+
...(sessions ? { sessions } : {}),
|
|
378
|
+
...(cursor ? { cursor } : {}),
|
|
379
|
+
...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}),
|
|
380
|
+
...(deps.now ? { now: deps.now } : {}),
|
|
381
|
+
...(deps.draining ? { draining: deps.draining } : {}),
|
|
382
|
+
})
|
|
383
|
+
},
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
// requests skip it) → 403 forbidden; credential → 401 unauthorized / token_expired; route → 404 not_found;
|
|
7
7
|
// route class (agentAllows) → 403 forbidden; then the handler, whose throws become the envelope (agentFailure).
|
|
8
8
|
// Path parameters address sessions as `:addr` (resolved per caller), never `:session`.
|
|
9
|
+
import type { AgentEvents } from "./events"
|
|
10
|
+
import type { CursorKeys } from "./event-cursor"
|
|
11
|
+
import { eventsRoutes } from "./events-routes"
|
|
9
12
|
import type { DeviceRecord, Store, UserRecord } from "../store"
|
|
10
13
|
import type { Directory } from "./directory"
|
|
11
14
|
import { directoryRoutes } from "./directory-routes"
|
|
@@ -25,7 +28,7 @@ import { type ActingSwitch, actingSwitchRoutes } from "./acting-switch"
|
|
|
25
28
|
import { Acting, type ActingRegistry, type Escalate } from "./acting"
|
|
26
29
|
import { actingRoutes } from "./acting-routes"
|
|
27
30
|
import type { Actions } from "../actions"
|
|
28
|
-
import {
|
|
31
|
+
import { launchRoutes, type RouteLaunches } from "./launch-routes"
|
|
29
32
|
import { type RouteWebhooks, webhookRoutes } from "./webhook-routes"
|
|
30
33
|
|
|
31
34
|
export const AGENT_PREFIX = "/api/agent"
|
|
@@ -87,8 +90,10 @@ export interface AgentDeps {
|
|
|
87
90
|
readonly launches?: RouteLaunches
|
|
88
91
|
/** Webhooks (P4-T10): the /api/agent/webhooks routes when given. */
|
|
89
92
|
readonly webhooks?: RouteWebhooks
|
|
90
|
-
/**
|
|
91
|
-
readonly
|
|
93
|
+
/** The canonical agent event journal: archive changes are appended to it. */
|
|
94
|
+
readonly agentEvents?: Pick<AgentEvents, "append" | "latest" | "since" | "appended">
|
|
95
|
+
/** Signing keys of GET /api/agent/events cursors; the events route is served when given with agentEvents. */
|
|
96
|
+
readonly eventCursorKeys?: CursorKeys
|
|
92
97
|
/** needs_user escalations of the act verbs (P3-T8 opens a Decision); without it needs_user is only answered. */
|
|
93
98
|
readonly escalate?: Escalate
|
|
94
99
|
/** The console's prompt limit, for the user's prompts on the act route. */
|
|
@@ -97,8 +102,6 @@ export interface AgentDeps {
|
|
|
97
102
|
readonly feed?: MessageFeed
|
|
98
103
|
/** Heartbeat of GET /api/agent/watch. */
|
|
99
104
|
readonly heartbeatMs?: number
|
|
100
|
-
/** What GET /api/agent/watch reads per user (cluster mode); the registry when absent. */
|
|
101
|
-
readonly watchRegistry?: ReadDeps["watchRegistry"]
|
|
102
105
|
/** Aborted when the hub drains: watch streams end with a `retry:` hint. */
|
|
103
106
|
readonly draining?: AbortSignal
|
|
104
107
|
readonly now?: () => number
|
|
@@ -123,9 +126,10 @@ const userView = (user: UserRecord) => ({
|
|
|
123
126
|
export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
124
127
|
const feed = deps.feed ?? new MessageFeed()
|
|
125
128
|
return [
|
|
126
|
-
...(deps.directory ? directoryRoutes(deps.directory) : []),
|
|
129
|
+
...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}) }) : []),
|
|
130
|
+
...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory }) : []),
|
|
127
131
|
...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
|
|
128
|
-
...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.
|
|
132
|
+
...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}) }) : []),
|
|
129
133
|
{
|
|
130
134
|
method: "GET",
|
|
131
135
|
path: `${AGENT_PREFIX}/whoami`,
|
|
@@ -144,9 +148,9 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
|
|
|
144
148
|
]
|
|
145
149
|
}
|
|
146
150
|
|
|
147
|
-
/** What the read routes show of launches: launched sessions' labels (the latest launch wins)
|
|
148
|
-
function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"
|
|
149
|
-
const { launches
|
|
151
|
+
/** What the read routes show of launches: launched sessions' labels (the latest launch wins). */
|
|
152
|
+
function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels"> {
|
|
153
|
+
const { launches } = deps
|
|
150
154
|
return {
|
|
151
155
|
...(launches
|
|
152
156
|
? {
|
|
@@ -158,7 +162,6 @@ function launchReads(deps: AgentDeps): Pick<ReadDeps, "labels" | "launches"> {
|
|
|
158
162
|
},
|
|
159
163
|
}
|
|
160
164
|
: {}),
|
|
161
|
-
...(launchFeed ? { launches: launchFeed } : {}),
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
167
|
|
|
@@ -1,220 +1,111 @@
|
|
|
1
|
-
// GET /api/agent/watch (design §4): a
|
|
2
|
-
// orchestrator loops.
|
|
3
|
-
//
|
|
1
|
+
// GET /api/agent/watch (design §4; lifecycle design "Events"): a live SSE view of the canonical agent event journal
|
|
2
|
+
// (events.ts), for orchestrator loops. The same events, visibility and filters as GET /api/agent/events, read with
|
|
3
|
+
// the same read() (events-routes.ts), so the two never define events differently. Not the console's /api/events.
|
|
4
4
|
//
|
|
5
|
-
// ?blocked=1 blocker events only
|
|
5
|
+
// ?blocked=1 blocker events only (blocker.*)
|
|
6
6
|
// ?session=<addr> only that session's tree (an address resolved for the caller; several for a @@role)
|
|
7
|
+
// ?cursor=… or Last-Event-ID: resume after a cursor this stream (or GET /api/agent/events) gave; a bad one is
|
|
8
|
+
// 400 invalid_cursor before the stream starts
|
|
7
9
|
//
|
|
8
|
-
//
|
|
9
|
-
// {type: "ready"}
|
|
10
|
-
// {
|
|
11
|
-
// {type: "
|
|
12
|
-
//
|
|
13
|
-
// {type: "session.added", sessionID, title} / {type: "session.removed", sessionID} catalog changes
|
|
14
|
-
// {type: "message", id, kind, state, from, to, sessionID?, senderID?, replyTo?, delivery?} an agent message was
|
|
15
|
-
// stored (no text): sessionID is the recipient session, senderID the sending one. The user sees all of them,
|
|
16
|
-
// a session or app (messages:read) only those it sent or received; ?session= matches either session.
|
|
17
|
-
// {type: "launch", id, state, reason, outcome?, sessionID?, name?, labels} a launch changed (the user sees all of
|
|
18
|
-
// theirs, an app or session those it started); ?session= matches its session.
|
|
10
|
+
// Frames (each `data:` line one JSON object; `id:` lines carry the resumable cursor):
|
|
11
|
+
// {type: "ready", at} first, once subscribed
|
|
12
|
+
// {id: "evt_…", at, type, subject, data} a journal event (no free text)
|
|
13
|
+
// {type: "reset", at, refresh: [...]} the cursor was no longer covered: re-read those
|
|
14
|
+
// event: ping every heartbeat while idle (default 20 s), with the cursor
|
|
19
15
|
// When the hub drains (shutting down), the stream ends with `retry: 2000` (reconnect, to another pod, after it).
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
import
|
|
23
|
-
import { partyKey } from "./envelope"
|
|
24
|
-
import { addressOf, type Message } from "./messages"
|
|
25
|
-
import type { Launch } from "./launch-store"
|
|
26
|
-
import type { ReadRegistry } from "./read-routes"
|
|
16
|
+
import type { AgentActor } from "./actor"
|
|
17
|
+
import type { AgentEvent } from "./events"
|
|
18
|
+
import { read, RESET_REFRESH, type EventsPage, type EventsRouteDeps } from "./events-routes"
|
|
27
19
|
|
|
28
20
|
export const WATCH_HEARTBEAT_MS = 20_000
|
|
29
21
|
/** Draining: the `retry:` (ms) an ending watch stream sends. */
|
|
30
22
|
export const WATCH_RETRY_MS = 2_000
|
|
31
23
|
const RETRY = `retry: ${WATCH_RETRY_MS}\n\n`
|
|
32
24
|
const SSE_HEADERS = { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" }
|
|
33
|
-
|
|
34
|
-
export interface WatchRegistry extends Pick<ReadRegistry, "catalog" | "sessionTenant"> {
|
|
35
|
-
subscribe(listener: (message: RegistryMessage, tenant?: string) => void): () => void
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Stored agent messages as they are sent (MessageFeed). */
|
|
39
|
-
export interface MessageSource {
|
|
40
|
-
subscribe(listener: (userID: string, message: Message) => void): () => void
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Launch changes as the engine makes them (LaunchFeed). */
|
|
44
|
-
export interface LaunchSource {
|
|
45
|
-
subscribe(listener: (userID: string, launch: Launch) => void): () => void
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The compact watch event of a launch change (never its prompt). */
|
|
49
|
-
function launchEvent(launch: Launch): WatchEvent {
|
|
50
|
-
return {
|
|
51
|
-
type: "launch",
|
|
52
|
-
id: launch.id,
|
|
53
|
-
state: launch.state,
|
|
54
|
-
reason: launch.reason,
|
|
55
|
-
...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}),
|
|
56
|
-
...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}),
|
|
57
|
-
...(launch.name !== undefined ? { name: launch.name } : {}),
|
|
58
|
-
labels: launch.spec.labels,
|
|
59
|
-
}
|
|
60
|
-
}
|
|
25
|
+
const PAGE = 200
|
|
61
26
|
|
|
62
27
|
export interface WatchOptions {
|
|
63
|
-
readonly
|
|
28
|
+
readonly events: EventsRouteDeps
|
|
64
29
|
readonly userID: string
|
|
30
|
+
readonly actor: AgentActor
|
|
65
31
|
readonly signal: AbortSignal
|
|
66
|
-
/**
|
|
67
|
-
readonly
|
|
32
|
+
/** Resume after this cursor (none: from now). */
|
|
33
|
+
readonly cursor?: string
|
|
34
|
+
/** Only these event types (exact, or `blocker.*`). */
|
|
35
|
+
readonly types?: readonly string[]
|
|
68
36
|
/** Only events of these sessions or of trees rooted at them. */
|
|
69
37
|
readonly sessions?: ReadonlySet<string>
|
|
70
|
-
/** Message events: from this feed, only those of the party key (undefined: all of the user's). */
|
|
71
|
-
readonly messages?: { readonly feed: MessageSource; readonly party?: string }
|
|
72
|
-
/** Launch events: from this feed, only those launched by `by` (undefined: all of the user's). */
|
|
73
|
-
readonly launches?: { readonly feed: LaunchSource; readonly by?: string }
|
|
74
38
|
readonly heartbeatMs?: number
|
|
75
39
|
readonly now?: () => number
|
|
76
40
|
/** Aborted when the hub drains: the stream ends with a `retry:` hint. */
|
|
77
41
|
readonly draining?: AbortSignal
|
|
78
42
|
}
|
|
79
43
|
|
|
80
|
-
/** The
|
|
81
|
-
function
|
|
82
|
-
const { id, kind, state, from, recipient, replyTo, delivery } = message
|
|
83
|
-
return {
|
|
84
|
-
type: "message",
|
|
85
|
-
id,
|
|
86
|
-
kind,
|
|
87
|
-
state,
|
|
88
|
-
from: addressOf(from),
|
|
89
|
-
to: message.to,
|
|
90
|
-
...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
|
|
91
|
-
...(from.kind === "session" ? { senderID: from.sessionID } : {}),
|
|
92
|
-
...(replyTo ? { replyTo } : {}),
|
|
93
|
-
...(delivery ? { delivery } : {}),
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
interface Known {
|
|
98
|
-
state: string
|
|
99
|
-
blockers: Set<string>
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
type WatchEvent = { type: string; sessionID?: string; rootID?: string; senderID?: string } & Record<string, unknown>
|
|
103
|
-
|
|
104
|
-
export function watchStream(options: WatchOptions): Response {
|
|
44
|
+
/** The watch stream; throws invalid_cursor (before streaming) for a cursor this hub did not give this user. */
|
|
45
|
+
export async function watchStream(options: WatchOptions): Promise<Response> {
|
|
105
46
|
if (options.draining?.aborted) return new Response(RETRY, { headers: SSE_HEADERS })
|
|
106
|
-
const {
|
|
47
|
+
const { userID, actor } = options
|
|
107
48
|
const now = options.now ?? Date.now
|
|
49
|
+
const heartbeatMs = options.heartbeatMs ?? WATCH_HEARTBEAT_MS
|
|
50
|
+
const filters = { limit: PAGE, types: options.types ?? [], ...(options.sessions ? { sessions: options.sessions } : {}) }
|
|
51
|
+
const first = await read(options.events, userID, actor, { ...filters, ...(options.cursor ? { cursor: options.cursor } : {}), wait: 0 })
|
|
108
52
|
const encoder = new TextEncoder()
|
|
109
|
-
|
|
110
|
-
const known = new Map<string, Known>()
|
|
111
|
-
/** Catalog sessions of the user, ID → title. */
|
|
112
|
-
let catalog = new Map<string, string>()
|
|
53
|
+
const stop = new AbortController()
|
|
113
54
|
|
|
114
|
-
const own = () => {
|
|
115
|
-
const out = new Map<string, { title: string; status: LiveStatus[] }>()
|
|
116
|
-
for (const machine of registry.catalog()) {
|
|
117
|
-
for (const process of machine.processes) {
|
|
118
|
-
for (const s of process.sessions) {
|
|
119
|
-
if (!out.has(s.id) && registry.sessionTenant(s.id) === userID) out.set(s.id, { title: s.title, status: s.status })
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return out
|
|
124
|
-
}
|
|
125
|
-
const remember = (entry: LiveStatus) => known.set(entry.id, { state: entry.state, blockers: new Set(entry.blockers.map((b) => b.id)) })
|
|
126
|
-
const seed = () => {
|
|
127
|
-
const sessions = own()
|
|
128
|
-
for (const { status } of sessions.values()) for (const entry of status) if (!known.has(entry.id)) remember(entry)
|
|
129
|
-
return sessions
|
|
130
|
-
}
|
|
131
|
-
catalog = new Map([...seed()].map(([id, s]) => [id, s.title]))
|
|
132
|
-
|
|
133
|
-
const wanted = (event: WatchEvent) => {
|
|
134
|
-
if (options.blocked && !event.type.startsWith("blocker.")) return false
|
|
135
|
-
if (!options.sessions) return true
|
|
136
|
-
return [event.sessionID, event.rootID, event.senderID].some((id) => id !== undefined && options.sessions!.has(id))
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Events for a status tree (root first), against what was known. */
|
|
140
|
-
const statusEvents = (rootID: string, sessions: readonly LiveStatus[]): WatchEvent[] => {
|
|
141
|
-
const out: WatchEvent[] = []
|
|
142
|
-
for (const entry of sessions) {
|
|
143
|
-
const before = known.get(entry.id)
|
|
144
|
-
const open = new Set(entry.blockers.map((b) => b.id))
|
|
145
|
-
for (const b of entry.blockers) {
|
|
146
|
-
if (!before?.blockers.has(b.id)) out.push({ type: "blocker.opened", sessionID: entry.id, rootID, blocker: { kind: b.kind, id: b.id, summary: b.summary } })
|
|
147
|
-
}
|
|
148
|
-
for (const id of before?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", sessionID: entry.id, rootID, id })
|
|
149
|
-
if (before?.state !== entry.state) out.push({ type: "state", sessionID: entry.id, rootID, state: entry.state, ...(before ? { prev: before.state } : {}) })
|
|
150
|
-
remember(entry)
|
|
151
|
-
}
|
|
152
|
-
return out
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const catalogEvents = (): WatchEvent[] => {
|
|
156
|
-
const sessions = seed()
|
|
157
|
-
const out: WatchEvent[] = []
|
|
158
|
-
for (const [id, s] of sessions) if (!catalog.has(id)) out.push({ type: "session.added", sessionID: id, title: s.title })
|
|
159
|
-
for (const id of catalog.keys()) if (!sessions.has(id)) out.push({ type: "session.removed", sessionID: id })
|
|
160
|
-
catalog = new Map([...sessions].map(([id, s]) => [id, s.title]))
|
|
161
|
-
return out
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
let cleanup = () => {}
|
|
165
55
|
const stream = new ReadableStream<Uint8Array>({
|
|
166
|
-
start(controller) {
|
|
56
|
+
async start(controller) {
|
|
57
|
+
let open = true
|
|
58
|
+
const close = () => {
|
|
59
|
+
if (!open) return
|
|
60
|
+
open = false
|
|
61
|
+
stop.abort()
|
|
62
|
+
try {
|
|
63
|
+
controller.close()
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
167
66
|
const write = (chunk: string) => {
|
|
67
|
+
if (!open) return
|
|
168
68
|
try {
|
|
169
69
|
controller.enqueue(encoder.encode(chunk))
|
|
170
70
|
} catch {
|
|
171
|
-
|
|
71
|
+
close()
|
|
172
72
|
}
|
|
173
73
|
}
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
for (const event of events) if (wanted(event)) send(event)
|
|
180
|
-
})
|
|
181
|
-
const messages = options.messages
|
|
182
|
-
const unsubscribeMessages = messages?.feed.subscribe((user, message) => {
|
|
183
|
-
if (user !== userID) return
|
|
184
|
-
if (messages.party !== undefined && partyKey(message.from) !== messages.party && partyKey(message.recipient) !== messages.party) return
|
|
185
|
-
const event = messageEvent(message)
|
|
186
|
-
if (wanted(event)) send(event)
|
|
187
|
-
})
|
|
188
|
-
const launches = options.launches
|
|
189
|
-
const unsubscribeLaunches = launches?.feed.subscribe((user, launch) => {
|
|
190
|
-
if (user !== userID || (launches.by !== undefined && launch.by !== launches.by)) return
|
|
191
|
-
const event = launchEvent(launch)
|
|
192
|
-
if (wanted(event)) send(event)
|
|
193
|
-
})
|
|
194
|
-
// A named event (not a comment) so clients can tell a live stream from a dead one.
|
|
195
|
-
const heartbeat = setInterval(() => write("event: ping\ndata: 0\n\n"), options.heartbeatMs ?? WATCH_HEARTBEAT_MS)
|
|
196
|
-
cleanup = () => {
|
|
197
|
-
clearInterval(heartbeat)
|
|
198
|
-
unsubscribe()
|
|
199
|
-
unsubscribeMessages?.()
|
|
200
|
-
unsubscribeLaunches?.()
|
|
74
|
+
const frame = (data: object, id?: string) => write(`${id ? `id: ${id}\n` : ""}data: ${JSON.stringify(data)}\n\n`)
|
|
75
|
+
const emit = (page: EventsPage) => {
|
|
76
|
+
if (page.reset) return frame({ type: "reset", at: now(), refresh: page.refresh ?? RESET_REFRESH }, page.cursor)
|
|
77
|
+
if (page.events.length === 0) return write(`event: ping\nid: ${page.cursor}\ndata: 0\n\n`)
|
|
78
|
+
page.events.forEach((event: AgentEvent, i) => frame(event, i === page.events.length - 1 ? page.cursor : undefined))
|
|
201
79
|
}
|
|
202
|
-
options.signal.addEventListener("abort", () =>
|
|
203
|
-
send({ type: "ready" })
|
|
80
|
+
options.signal.addEventListener("abort", () => close(), { once: true })
|
|
204
81
|
options.draining?.addEventListener(
|
|
205
82
|
"abort",
|
|
206
83
|
() => {
|
|
207
84
|
write(RETRY)
|
|
208
|
-
|
|
209
|
-
try {
|
|
210
|
-
controller.close()
|
|
211
|
-
} catch {}
|
|
85
|
+
close()
|
|
212
86
|
},
|
|
213
87
|
{ once: true },
|
|
214
88
|
)
|
|
89
|
+
frame({ type: "ready", at: now() }, options.cursor && !first.reset ? options.cursor : first.cursor)
|
|
90
|
+
if (first.reset || first.events.length) emit(first)
|
|
91
|
+
let cursor = first.cursor
|
|
92
|
+
while (open && !stop.signal.aborted) {
|
|
93
|
+
let page: EventsPage
|
|
94
|
+
try {
|
|
95
|
+
page = await read(options.events, userID, actor, { ...filters, cursor, wait: heartbeatMs / 1000 }, stop.signal)
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
console.error(`[hub] agent watch read failed: ${(cause as Error).message}`)
|
|
98
|
+
write(RETRY)
|
|
99
|
+
return close()
|
|
100
|
+
}
|
|
101
|
+
if (stop.signal.aborted) break
|
|
102
|
+
emit(page)
|
|
103
|
+
cursor = page.cursor
|
|
104
|
+
}
|
|
105
|
+
close()
|
|
215
106
|
},
|
|
216
107
|
cancel() {
|
|
217
|
-
|
|
108
|
+
stop.abort()
|
|
218
109
|
},
|
|
219
110
|
})
|
|
220
111
|
return new Response(stream, { headers: SSE_HEADERS })
|
|
@@ -13,13 +13,13 @@ import type { Store } from "../store"
|
|
|
13
13
|
import type { AgentActor } from "./actor"
|
|
14
14
|
import { AgentError } from "./errors"
|
|
15
15
|
import type { AgentRoute } from "./routes"
|
|
16
|
-
import type
|
|
16
|
+
import { webhookEventScopes, type WebhookOwner, type Webhooks } from "./webhooks"
|
|
17
17
|
|
|
18
18
|
// A literal: routes.ts imports this module.
|
|
19
19
|
const PATH = "/api/agent/webhooks"
|
|
20
20
|
const NO_STORE = { "cache-control": "no-store" }
|
|
21
21
|
/** Users; apps with sessions:launch. */
|
|
22
|
-
export const WEBHOOK_SCOPE = "
|
|
22
|
+
export const WEBHOOK_SCOPE = "events:subscribe"
|
|
23
23
|
const ALLOW = { scope: WEBHOOK_SCOPE } as const
|
|
24
24
|
|
|
25
25
|
export type RouteWebhooks = Pick<Webhooks, "register" | "list" | "get" | "remove" | "deliveries">
|
|
@@ -29,6 +29,13 @@ const ownerOf = (actor: AgentActor): WebhookOwner | undefined => (actor.kind ===
|
|
|
29
29
|
|
|
30
30
|
const notFound = (id: string) => new AgentError("not_found", `No webhook ${id}`)
|
|
31
31
|
|
|
32
|
+
const requireEventScopes = (actor: AgentActor, events: unknown) => {
|
|
33
|
+
if (actor.kind !== "app") return
|
|
34
|
+
const patterns = events === undefined ? ["launch.*"] : Array.isArray(events) && events.every((e) => typeof e === "string") ? events : []
|
|
35
|
+
const missing = webhookEventScopes(patterns).filter((scope) => !actor.scopes.includes(scope))
|
|
36
|
+
if (missing.length) throw new AgentError("forbidden", `Webhook events need scope ${missing.join(", ")}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
32
39
|
async function bodyOf(request: Request): Promise<Record<string, unknown>> {
|
|
33
40
|
const raw = JSON.parse(await request.text()) as unknown
|
|
34
41
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new AgentError("invalid", "Body must be a JSON object")
|
|
@@ -43,6 +50,7 @@ export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audi
|
|
|
43
50
|
allow: ALLOW,
|
|
44
51
|
async handle({ request, actor, user }) {
|
|
45
52
|
const input = await bodyOf(request)
|
|
53
|
+
requireEventScopes(actor, input.events)
|
|
46
54
|
const result = await webhooks.register(user.id, ownerOf(actor) ?? "user", { url: input.url, events: input.events, label: input.label })
|
|
47
55
|
await store?.audit({ actor: user.id, action: "webhook.create", target: result.webhook.id, detail: new URL(result.webhook.url).host })
|
|
48
56
|
return Response.json(result, { status: 201, headers: NO_STORE })
|
|
@@ -27,12 +27,20 @@ import {
|
|
|
27
27
|
import type { Clock, Locks, StateStore, Timers } from "../backend/types"
|
|
28
28
|
import { publicFetch, PublicURLError, publicURLProblem, type ResolveHost } from "../push-target"
|
|
29
29
|
import { AgentError } from "./errors"
|
|
30
|
+
import type { AgentEvent, EventAudience, EventFamily } from "./events"
|
|
30
31
|
import { LAUNCH_STATES, type Launch } from "./launch-store"
|
|
31
32
|
|
|
32
33
|
export const WEBHOOKS = "webhooks"
|
|
33
34
|
export const WEBHOOK_OUTBOX = "webhook-outbox"
|
|
34
35
|
/** Every event type a webhook can receive (phase 4: launch changes). */
|
|
35
|
-
export const
|
|
36
|
+
export const CANONICAL_WEBHOOK_EVENT_TYPES: readonly string[] = [
|
|
37
|
+
"session.added", "session.removed", "session.changed", "session.archived", "session.named",
|
|
38
|
+
"session.pinned",
|
|
39
|
+
"blocker.opened", "blocker.closed", "machine.changed", "role.claimed", "role.released",
|
|
40
|
+
"message.changed", "launch.changed",
|
|
41
|
+
]
|
|
42
|
+
/** Includes persisted pre-canonical launch registrations for rolling rollback compatibility. */
|
|
43
|
+
export const WEBHOOK_EVENT_TYPES: readonly string[] = [...CANONICAL_WEBHOOK_EVENT_TYPES, ...LAUNCH_STATES.map((state) => `launch.${state}`)]
|
|
36
44
|
export const DEFAULT_WEBHOOK_EVENTS: readonly string[] = ["launch.*"]
|
|
37
45
|
export const MAX_WEBHOOKS = 20
|
|
38
46
|
export const MAX_WEBHOOK_URL = 2048
|
|
@@ -133,6 +141,8 @@ export interface WebhooksDeps {
|
|
|
133
141
|
readonly fetch?: typeof globalThis.fetch
|
|
134
142
|
/** Whether an app token (by its id) may still have webhooks; its registrations are dropped when not. */
|
|
135
143
|
readonly appAlive?: (userID: string, tokenID: string) => boolean
|
|
144
|
+
/** Current scopes of an app token; absent preserves the legacy liveness-only check. */
|
|
145
|
+
readonly appScopes?: (userID: string, tokenID: string) => readonly string[] | undefined
|
|
136
146
|
readonly backoffMs?: readonly number[]
|
|
137
147
|
readonly timeoutMs?: number
|
|
138
148
|
readonly maxResponseBytes?: number
|
|
@@ -165,14 +175,30 @@ function matches(pattern: string, type: string): boolean {
|
|
|
165
175
|
function eventsOf(raw: unknown): string[] {
|
|
166
176
|
if (raw === undefined) return [...DEFAULT_WEBHOOK_EVENTS]
|
|
167
177
|
if (!Array.isArray(raw) || raw.length === 0 || raw.length > MAX_WEBHOOK_EVENTS || !raw.every((e): e is string => typeof e === "string")) {
|
|
168
|
-
throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*"]`)
|
|
178
|
+
throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*", "message.changed"]`)
|
|
169
179
|
}
|
|
170
180
|
for (const pattern of raw) {
|
|
171
|
-
if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`,
|
|
181
|
+
if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`, "Use a canonical event type or family wildcard")
|
|
172
182
|
}
|
|
173
183
|
return [...new Set(raw)]
|
|
174
184
|
}
|
|
175
185
|
|
|
186
|
+
export function webhookEventScopes(patterns: readonly string[]): string[] {
|
|
187
|
+
const scopes = new Set<string>()
|
|
188
|
+
if (patterns.some((pattern) => LAUNCH_STATES.some((state) => matches(pattern, `launch.${state}`)))) scopes.add("sessions:launch")
|
|
189
|
+
for (const pattern of patterns) for (const type of CANONICAL_WEBHOOK_EVENT_TYPES) if (matches(pattern, type)) {
|
|
190
|
+
if (type === "message.changed") scopes.add("messages:read")
|
|
191
|
+
else if (type === "launch.changed") scopes.add("sessions:launch")
|
|
192
|
+
else if (["session.archived", "session.named", "session.pinned", "role.claimed", "role.released"].includes(type)) scopes.add("sessions:manage")
|
|
193
|
+
else scopes.add("sessions:read")
|
|
194
|
+
}
|
|
195
|
+
return [...scopes]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function canonicalWebhookEvent(source: string, userID: string, event: AgentEvent): WebhookEvent {
|
|
199
|
+
return { id: event.id, type: event.type, source, subject: event.subject.launchID ?? event.subject.messageID ?? event.subject.sessionID ?? event.subject.machineID ?? event.id, time: new Date(event.at).toISOString(), user: userID, schemaVersion: 1, data: { subject: event.subject, data: event.data } }
|
|
200
|
+
}
|
|
201
|
+
|
|
176
202
|
const view = ({ secret: _secret, ...rest }: Registration): WebhookView => rest
|
|
177
203
|
|
|
178
204
|
const deliveryView = (entry: OutboxEntry): DeliveryView => ({
|
|
@@ -286,8 +312,9 @@ export class Webhooks {
|
|
|
286
312
|
* only when `by` (who caused it) is that app. Resolves with how many deliveries were newly queued (an event
|
|
287
313
|
* already queued for a registration is not queued again).
|
|
288
314
|
*/
|
|
289
|
-
async publish(userID: string, event: WebhookEvent, options: { readonly by?: string } = {}): Promise<number> {
|
|
290
|
-
const
|
|
315
|
+
async publish(userID: string, event: WebhookEvent, options: { readonly by?: string; readonly owners?: readonly string[]; readonly matchTypes?: readonly string[] } = {}): Promise<number> {
|
|
316
|
+
const matchTypes = options.matchTypes ?? [event.type]
|
|
317
|
+
const targets = (await this.registrations(userID)).filter((r) => (r.by === "user" || r.by === options.by || options.owners?.includes(r.by)) && r.events.some((p) => matchTypes.some((type) => matches(p, type))))
|
|
291
318
|
if (!targets.length) return 0
|
|
292
319
|
const max = this.deps.maxOutbox ?? WEBHOOK_OUTBOX_MAX
|
|
293
320
|
let size = (await this.deps.state.list(userID, WEBHOOK_OUTBOX)).length
|
|
@@ -308,6 +335,15 @@ export class Webhooks {
|
|
|
308
335
|
return queued
|
|
309
336
|
}
|
|
310
337
|
|
|
338
|
+
publishCanonical(userID: string, event: AgentEvent, _family: EventFamily, audience?: EventAudience): Promise<number> {
|
|
339
|
+
const legacyLaunchType = event.type === "launch.changed" && typeof event.data.state === "string" ? `launch.${event.data.state}` : undefined
|
|
340
|
+
return this.publish(userID, canonicalWebhookEvent(this.deps.source, userID, event), {
|
|
341
|
+
...(audience?.by ? { by: audience.by } : {}),
|
|
342
|
+
...(audience?.parties ? { owners: audience.parties.filter((party) => party.startsWith("app:")) } : {}),
|
|
343
|
+
...(legacyLaunchType ? { matchTypes: [event.type, legacyLaunchType] } : {}),
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
311
347
|
/** A launch change (the LaunchFeed): `data` is the launch as the API shows it. */
|
|
312
348
|
async onLaunch(userID: string, launch: Launch, data: unknown): Promise<number> {
|
|
313
349
|
return this.publish(userID, launchEvent(this.deps.source, launch, data), launch.by !== undefined ? { by: launch.by } : {})
|
|
@@ -401,6 +437,14 @@ export class Webhooks {
|
|
|
401
437
|
await this.remove(userID, registration.id)
|
|
402
438
|
return
|
|
403
439
|
}
|
|
440
|
+
if (registration.by !== "user" && this.deps.appScopes) {
|
|
441
|
+
const scopes = this.deps.appScopes(userID, registration.by.slice("app:".length))
|
|
442
|
+
const required = ["events:subscribe", ...webhookEventScopes(registration.events)]
|
|
443
|
+
if (!scopes || required.some((scope) => !scopes.includes(scope))) {
|
|
444
|
+
await this.remove(userID, registration.id)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
}
|
|
404
448
|
const { last, final } = await this.send(registration, entry, now, timeoutMs)
|
|
405
449
|
const attempts = entry.attempts + 1
|
|
406
450
|
const delivered = last.status !== undefined && last.status >= 200 && last.status < 300
|