@huynle/hub 0.7.1 → 0.7.3
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 +87 -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/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- 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 +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -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/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -7,15 +7,30 @@
|
|
|
7
7
|
// POST /api/agent/name {name, session?}
|
|
8
8
|
// POST /api/agent/roles/claim {role, exclusive?, project?, session?}
|
|
9
9
|
// POST /api/agent/roles/release {role, project?, session?}
|
|
10
|
+
// PUT /api/agent/sessions/:addr/archive {archived}
|
|
10
11
|
// `session` is an address (ses_…, ID prefix, @name, …) resolved for the caller; a session token defaults to
|
|
11
|
-
// its own session, a user must name one.
|
|
12
|
+
// its own session, a user or app must name one. Foreign and unknown sessions are both not_found.
|
|
13
|
+
//
|
|
14
|
+
// App tokens (lifecycle design, decision xwzersmm): naming and archiving need sessions:manage; a role claim or
|
|
15
|
+
// release also needs a grant allowing `roles` whose projects cover the (live) session's directory, in the acting
|
|
16
|
+
// order (kill switch → grant → hourly cap → kill switch), with every outcome in the agent audit (verb roles or
|
|
17
|
+
// roles.release). Reserved roles stay the user's to assign. Each change is in the admin audit and the event
|
|
18
|
+
// journal once (session.named, role.claimed, role.released, session.archived); a no-op is neither.
|
|
19
|
+
import type { ActingSwitch } from "./acting-switch"
|
|
20
|
+
import { auditOf } from "./acting"
|
|
21
|
+
import type { AgentAudit, AuditDecision, AuditOutcome } from "./audit"
|
|
22
|
+
import { partyFromActor } from "./envelope"
|
|
23
|
+
import { type AgentEvents, archiveEvent, type EventInput, namedEvent, pinnedEvent, roleEvent } from "./events"
|
|
24
|
+
import type { Grant, Grants } from "./grants"
|
|
12
25
|
import path from "node:path"
|
|
13
|
-
import type { CatalogMachine, RegistryMessage } from "../registry"
|
|
26
|
+
import type { CatalogMachine, Registry, RegistryMessage } from "../registry"
|
|
14
27
|
import type { Store } from "../store"
|
|
15
28
|
import { Directory, type ReportedSession, type SessionSource } from "./directory"
|
|
16
29
|
import { AgentError } from "./errors"
|
|
30
|
+
import { ArchiveInactive, parseInactiveFor } from "./archive-inactive"
|
|
17
31
|
import { slugify } from "./names"
|
|
18
32
|
import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
|
|
33
|
+
import type { CursorKeys } from "./event-cursor"
|
|
19
34
|
|
|
20
35
|
/** What the directory reads from the Registry. */
|
|
21
36
|
export interface DirectoryRegistry {
|
|
@@ -127,8 +142,120 @@ function target(directory: Directory, { actor, user }: AgentContext, addr: strin
|
|
|
127
142
|
const NO_STORE = { "cache-control": "no-store" }
|
|
128
143
|
const ok = (body: unknown) => Response.json(body, { headers: NO_STORE })
|
|
129
144
|
|
|
130
|
-
export
|
|
145
|
+
export interface DirectoryRoutesDeps {
|
|
146
|
+
readonly directory: Directory
|
|
147
|
+
readonly store: Pick<Store, "setSessionArchived" | "setSessionPinned" | "archiveSessionIfUnpinned" | "viewPrefs" | "agentData" | "audit">
|
|
148
|
+
readonly events?: Pick<AgentEvents, "append">
|
|
149
|
+
/** App role writes: the live session (its directory, for the grant's projects). */
|
|
150
|
+
readonly registry?: Pick<Registry, "sessionTenant" | "session">
|
|
151
|
+
readonly grants?: Pick<Grants, "usable" | "consume">
|
|
152
|
+
readonly acting?: Pick<ActingSwitch, "ensureActing">
|
|
153
|
+
readonly audit?: Pick<AgentAudit, "record">
|
|
154
|
+
readonly previewKeys?: CursorKeys
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const MANAGE = { session: true, scope: "sessions:manage" } as const
|
|
158
|
+
|
|
159
|
+
export function directoryRoutes(deps: DirectoryRoutesDeps): AgentRoute[] {
|
|
160
|
+
const { directory, store, events } = deps
|
|
161
|
+
|
|
162
|
+
/** The admin audit and the journal, once per change. */
|
|
163
|
+
async function changed(context: AgentContext, action: string, sessionID: string, event: EventInput): Promise<void> {
|
|
164
|
+
await store.audit({ actor: context.user.id, action, target: sessionID, detail: context.actor.kind })
|
|
165
|
+
await events?.append(context.user.id, event).catch((cause) => console.error(`[hub] ${event.type} event failed: ${(cause as Error).message}`))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A role write: users and sessions as before; an app under a `roles` grant on the session's project, audited. */
|
|
169
|
+
async function roleWrite<T>(context: AgentContext, verb: "roles" | "roles.release", sessionID: string, run: () => Promise<T>): Promise<T> {
|
|
170
|
+
const { actor, user } = context
|
|
171
|
+
if (actor.kind !== "app") return run()
|
|
172
|
+
const { registry, grants, acting, audit } = deps
|
|
173
|
+
if (!registry || !grants || !acting || !audit) throw new AgentError("forbidden", "This hub does not let app tokens change roles")
|
|
174
|
+
let grant: Grant | undefined
|
|
175
|
+
const record = (decision: AuditDecision, outcome: AuditOutcome, reason?: string) =>
|
|
176
|
+
audit.record(user.id, { actor: partyFromActor(actor), verb, target: { sessionID }, ...(grant ? { grant: grant.id } : {}), decision, ...(reason ? { reason } : {}), outcome })
|
|
177
|
+
try {
|
|
178
|
+
acting.ensureActing(user.id)
|
|
179
|
+
const live = registry.sessionTenant(sessionID) === user.id ? registry.session(sessionID) : undefined
|
|
180
|
+
if (!live) throw new AgentError("offline", "No spoke reports this session now, so its project cannot be checked against a grant")
|
|
181
|
+
const usable = await grants.usable(actor, "roles", { directory: live.directory })
|
|
182
|
+
if (!usable.length) throw new AgentError("forbidden", "No grant lets this app change roles in this session's project")
|
|
183
|
+
grant = usable[0]!
|
|
184
|
+
await grants.consume(grant)
|
|
185
|
+
acting.ensureActing(user.id)
|
|
186
|
+
const result = await run()
|
|
187
|
+
await record("allow", "ok")
|
|
188
|
+
return result
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
const [decision, outcome] = auditOf(cause)
|
|
191
|
+
await record(decision, outcome, cause instanceof Error ? cause.message : undefined)
|
|
192
|
+
throw cause
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
131
196
|
return [
|
|
197
|
+
...(deps.previewKeys ? [{
|
|
198
|
+
method: "POST" as const,
|
|
199
|
+
path: `${AGENT_PREFIX}/sessions/archive-inactive`,
|
|
200
|
+
allow: { scope: "sessions:manage" },
|
|
201
|
+
async handle(context: AgentContext) {
|
|
202
|
+
const inactive = new ArchiveInactive({ secret: deps.previewKeys!.active().secret })
|
|
203
|
+
const body = await readBody(context.request)
|
|
204
|
+
const prefs = store.viewPrefs(context.user.id)
|
|
205
|
+
const entries = (store.agentData(context.user.id).sessions ?? []).map((s) => ({ sessionID: s.sessionID, online: !!deps.registry?.session(s.sessionID), ...(s.lastSeen !== undefined ? { lastSeen: s.lastSeen } : {}), roles: [] }))
|
|
206
|
+
if (body.preview === true) {
|
|
207
|
+
const limit = body.limit
|
|
208
|
+
if (!Number.isInteger(limit) || Number(limit) < 1 || Number(limit) > 200) throw new AgentError("invalid", "limit must be a whole number from 1 to 200")
|
|
209
|
+
return ok(inactive.preview(context.user.id, entries, { pinned: new Set(prefs.pinnedSessions), archived: new Set(prefs.hiddenSessions), inactiveForMs: parseInactiveFor(body.inactiveFor), limit: Number(limit) }))
|
|
210
|
+
}
|
|
211
|
+
if (body.confirm !== true || typeof body.previewToken !== "string") throw new AgentError("invalid", "Use preview:true first, then confirm:true with its previewToken")
|
|
212
|
+
const open = new Set(entries.filter((s) => deps.registry?.session(s.sessionID)?.open).map((s) => s.sessionID))
|
|
213
|
+
const active = new Set(entries.filter((s) => { const v = deps.registry?.session(s.sessionID); return !!v && (v.busy || v.status.some((x) => x.state === "working" || x.state === "blocked")) }).map((s) => s.sessionID))
|
|
214
|
+
const checked = inactive.revalidate(context.user.id, body.previewToken, entries, { pinned: new Set(prefs.pinnedSessions), archived: new Set(prefs.hiddenSessions), open, active })
|
|
215
|
+
const changedIDs: string[] = []
|
|
216
|
+
const skipped = [...checked.skipped]
|
|
217
|
+
for (const sessionID of checked.candidates) {
|
|
218
|
+
const result = await store.archiveSessionIfUnpinned(context.user.id, sessionID)
|
|
219
|
+
if (result === "changed") { changedIDs.push(sessionID); await changed(context, "session.archive", sessionID, archiveEvent(sessionID, true)) }
|
|
220
|
+
else skipped.push({ sessionID, reason: result === "pinned" ? "pinned" : result === "not_found" ? "not_found" : "archived" })
|
|
221
|
+
}
|
|
222
|
+
if (changedIDs.length) await directory.sync(context.user.id)
|
|
223
|
+
return ok({ changed: changedIDs, skipped })
|
|
224
|
+
},
|
|
225
|
+
}] : []),
|
|
226
|
+
{
|
|
227
|
+
method: "PUT",
|
|
228
|
+
path: `${AGENT_PREFIX}/sessions/:addr/pin`,
|
|
229
|
+
allow: { scope: "sessions:manage" },
|
|
230
|
+
async handle(context) {
|
|
231
|
+
const body = await readBody(context.request)
|
|
232
|
+
if (flag(body, "pinned") === undefined) throw new AgentError("invalid", "pinned must be true or false")
|
|
233
|
+
const sessionID = target(directory, context, context.params.addr)
|
|
234
|
+
const pinned = body.pinned === true
|
|
235
|
+
const result = await store.setSessionPinned(context.user.id, sessionID, pinned)
|
|
236
|
+
if (!result) throw new AgentError("not_found", `No session ${context.params.addr}`)
|
|
237
|
+
if (result.changed) await changed(context, pinned ? "session.pin" : "session.unpin", sessionID, pinnedEvent(sessionID, pinned))
|
|
238
|
+
return ok({ sessionID, pinned, changed: result.changed })
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
method: "PUT",
|
|
243
|
+
path: `${AGENT_PREFIX}/sessions/:addr/archive`,
|
|
244
|
+
allow: { scope: "sessions:manage" },
|
|
245
|
+
async handle(context) {
|
|
246
|
+
const body = await readBody(context.request)
|
|
247
|
+
if (flag(body, "archived") === undefined) throw new AgentError("invalid", "archived must be true or false")
|
|
248
|
+
const sessionID = target(directory, context, context.params.addr)
|
|
249
|
+
const archived = body.archived === true
|
|
250
|
+
const result = await store.setSessionArchived(context.user.id, sessionID, archived)
|
|
251
|
+
if (!result) throw new AgentError("invalid", "The archive is full; unarchive a session first")
|
|
252
|
+
if (result.changed) {
|
|
253
|
+
await directory.sync(context.user.id)
|
|
254
|
+
await changed(context, archived ? "session.archive" : "session.unarchive", sessionID, archiveEvent(sessionID, archived))
|
|
255
|
+
}
|
|
256
|
+
return ok({ sessionID, archived, changed: result.changed })
|
|
257
|
+
},
|
|
258
|
+
},
|
|
132
259
|
{
|
|
133
260
|
method: "GET",
|
|
134
261
|
path: `${AGENT_PREFIX}/who`,
|
|
@@ -143,28 +270,39 @@ export function directoryRoutes(directory: Directory): AgentRoute[] {
|
|
|
143
270
|
{
|
|
144
271
|
method: "POST",
|
|
145
272
|
path: `${AGENT_PREFIX}/name`,
|
|
146
|
-
allow:
|
|
273
|
+
allow: MANAGE,
|
|
147
274
|
handle: async (context) => {
|
|
148
275
|
const body = await readBody(context.request)
|
|
149
276
|
const name = text(body, "name", true)
|
|
150
277
|
const sessionID = target(directory, context, text(body, "session"))
|
|
151
|
-
|
|
278
|
+
const before = directory.resolveOne(context.user.id, sessionID).name
|
|
279
|
+
const named = await directory.setName(context.user.id, sessionID, name)
|
|
280
|
+
if (named.name !== before) await changed(context, "session.name", sessionID, namedEvent(sessionID, named.name))
|
|
281
|
+
return ok(named)
|
|
152
282
|
},
|
|
153
283
|
},
|
|
154
284
|
{
|
|
155
285
|
method: "POST",
|
|
156
286
|
path: `${AGENT_PREFIX}/roles/claim`,
|
|
157
|
-
allow:
|
|
287
|
+
allow: MANAGE,
|
|
158
288
|
handle: async (context) => {
|
|
159
289
|
const body = await readBody(context.request)
|
|
160
290
|
const role = text(body, "role", true)
|
|
161
291
|
const exclusive = flag(body, "exclusive")
|
|
162
292
|
const project = text(body, "project")
|
|
163
293
|
const sessionID = target(directory, context, text(body, "session"))
|
|
164
|
-
const claim = await
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
294
|
+
const claim = await roleWrite(context, "roles", sessionID, async () => {
|
|
295
|
+
const before = directory.resolveOne(context.user.id, sessionID).roles
|
|
296
|
+
const claim = await directory.claimRole(context.user.id, sessionID, role, {
|
|
297
|
+
...(exclusive !== undefined ? { exclusive } : {}),
|
|
298
|
+
...(project !== undefined ? { project } : {}),
|
|
299
|
+
byUser: context.actor.kind === "user",
|
|
300
|
+
})
|
|
301
|
+
const prev = before.find((r) => r.role === claim.role && r.project === claim.project)
|
|
302
|
+
if (!prev || prev.generation !== claim.generation || !!prev.exclusive !== !!claim.exclusive) {
|
|
303
|
+
await changed(context, "role.claim", sessionID, roleEvent(sessionID, true, claim))
|
|
304
|
+
}
|
|
305
|
+
return claim
|
|
168
306
|
})
|
|
169
307
|
return ok({ sessionID, ...claim })
|
|
170
308
|
},
|
|
@@ -172,13 +310,18 @@ export function directoryRoutes(directory: Directory): AgentRoute[] {
|
|
|
172
310
|
{
|
|
173
311
|
method: "POST",
|
|
174
312
|
path: `${AGENT_PREFIX}/roles/release`,
|
|
175
|
-
allow:
|
|
313
|
+
allow: MANAGE,
|
|
176
314
|
handle: async (context) => {
|
|
177
315
|
const body = await readBody(context.request)
|
|
178
316
|
const role = text(body, "role", true)
|
|
179
317
|
const project = text(body, "project")
|
|
180
318
|
const sessionID = target(directory, context, text(body, "session"))
|
|
181
|
-
|
|
319
|
+
const released = await roleWrite(context, "roles.release", sessionID, async () => {
|
|
320
|
+
const released = await directory.releaseRole(context.user.id, sessionID, role, project)
|
|
321
|
+
await changed(context, "role.release", sessionID, roleEvent(sessionID, false, released))
|
|
322
|
+
return released
|
|
323
|
+
})
|
|
324
|
+
return ok({ sessionID, ...released, released: true })
|
|
182
325
|
},
|
|
183
326
|
},
|
|
184
327
|
]
|
|
@@ -207,7 +207,7 @@ export class Directory {
|
|
|
207
207
|
/**
|
|
208
208
|
* Claims `role` (or `role.project`) for a session. Each new holder gets the next holder generation of
|
|
209
209
|
* (role, project). An exclusive claim fails (name_taken) while another live holder exists, and nobody else
|
|
210
|
-
* can claim while an exclusive holder is live. Reserved roles (orchestrator
|
|
210
|
+
* can claim while an exclusive holder is live. Reserved roles (orchestrator…) need `byUser`
|
|
211
211
|
* (grants with `roles` come in phase 3). Exclusivity and generations are per (role, project scope).
|
|
212
212
|
*/
|
|
213
213
|
async claimRole(userID: string, sessionID: string, requested: string, options: { exclusive?: boolean; project?: string; byUser?: boolean } = {}): Promise<RoleView> {
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
export const AGENT_ERROR_STATUS = {
|
|
6
6
|
/** Malformed request (bad JSON, bad fields, cookie and bearer together). */
|
|
7
7
|
invalid: 400,
|
|
8
|
+
/** An events cursor this hub did not give this user, or cannot read (start again without one). */
|
|
9
|
+
invalid_cursor: 400,
|
|
8
10
|
/** No credential, or an unknown, revoked or disabled one. */
|
|
9
11
|
unauthorized: 401,
|
|
10
12
|
/** A session or app token that expired: get a new one. */
|
|
@@ -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
|
+
}
|