@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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// The canonical agent event journal (lifecycle design: Events; decision xwzersmm). One bounded journal per user of
|
|
2
|
+
// compact events that GET /api/agent/events, MCP `events` and /api/agent/watch read:
|
|
3
|
+
//
|
|
4
|
+
// {id: "evt_…", at, type, subject: {sessionID?, rootID?, messageID?, senderID?, launchID?, machineID?}, data}
|
|
5
|
+
//
|
|
6
|
+
// Payloads are built from allowlists here and nowhere else: never prompts, message bodies, titles, blocker
|
|
7
|
+
// summaries, launch reasons or labels, credentials, or connection/pod/spoke IDs. Each record also keeps, for the
|
|
8
|
+
// reader only, its scope family (session, manage, message, launch) and its audience (the parties of a message, who
|
|
9
|
+
// started a launch), so an app or session token sees only what its scopes and identity allow.
|
|
10
|
+
//
|
|
11
|
+
// Storage is an EventLog (a Valkey stream per user in a cluster, process memory otherwise) bounded to
|
|
12
|
+
// AGENT_EVENTS_MAX events and AGENT_EVENTS_AGE_MS. An event id is assigned once, before any cross-pod fan-out;
|
|
13
|
+
// append claims it in the Kv first (SET NX), so appending the same id twice stores it once.
|
|
14
|
+
import { randomBytes } from "node:crypto"
|
|
15
|
+
import type { Clock, EventLog, Kv } from "../backend/types"
|
|
16
|
+
import type { LiveStatus } from "../registry"
|
|
17
|
+
import { partyKey } from "./envelope"
|
|
18
|
+
import type { Launch } from "./launch-store"
|
|
19
|
+
import { addressOf, type Message } from "./messages"
|
|
20
|
+
|
|
21
|
+
export const AGENT_EVENTS_MAX = 10_000
|
|
22
|
+
export const AGENT_EVENTS_AGE_MS = 86_400_000
|
|
23
|
+
/** Record format version (readers reject others rather than skip them). */
|
|
24
|
+
export const AGENT_EVENT_VERSION = 1
|
|
25
|
+
|
|
26
|
+
/** The scope family a reader needs: sessions:read, sessions:manage, messages:read, sessions:launch. */
|
|
27
|
+
export type EventFamily = "session" | "manage" | "message" | "launch"
|
|
28
|
+
|
|
29
|
+
export type EventSubject = Readonly<Record<string, string>>
|
|
30
|
+
export type EventData = Readonly<Record<string, string | number | boolean>>
|
|
31
|
+
|
|
32
|
+
/** An event as readers receive it. */
|
|
33
|
+
export interface AgentEvent {
|
|
34
|
+
readonly id: string
|
|
35
|
+
readonly at: number
|
|
36
|
+
readonly type: string
|
|
37
|
+
readonly subject: EventSubject
|
|
38
|
+
readonly data: EventData
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Who else may read an event beyond the user: the party keys of a message, who started a launch. */
|
|
42
|
+
export interface EventAudience {
|
|
43
|
+
readonly parties?: readonly string[]
|
|
44
|
+
readonly by?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What a writer appends. */
|
|
48
|
+
export interface EventInput {
|
|
49
|
+
/** A stable id (assigned before fan-out); a new one when absent. */
|
|
50
|
+
readonly id?: string
|
|
51
|
+
readonly type: string
|
|
52
|
+
readonly family: EventFamily
|
|
53
|
+
readonly subject: EventSubject
|
|
54
|
+
readonly data: EventData
|
|
55
|
+
readonly audience?: EventAudience
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A journal record: the event, its reader-only scope fields, and its position in the user's log. */
|
|
59
|
+
export interface EventRecord {
|
|
60
|
+
readonly position: string
|
|
61
|
+
readonly event: AgentEvent
|
|
62
|
+
readonly family: EventFamily
|
|
63
|
+
readonly audience?: EventAudience
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Records after a position, or `reset` when the journal no longer covers it (expired, trimmed, restarted). */
|
|
67
|
+
export type EventsSince = { readonly records: EventRecord[] } | { readonly reset: true }
|
|
68
|
+
|
|
69
|
+
const FAMILIES: ReadonlySet<string> = new Set<EventFamily>(["session", "manage", "message", "launch"])
|
|
70
|
+
const EVENT_ID = /^evt_[0-9a-f]{20}$/
|
|
71
|
+
|
|
72
|
+
export const newEventID = (): string => `evt_${randomBytes(10).toString("hex")}`
|
|
73
|
+
|
|
74
|
+
export interface AgentEventsOptions {
|
|
75
|
+
readonly log: EventLog
|
|
76
|
+
readonly kv: Kv
|
|
77
|
+
readonly now?: Clock
|
|
78
|
+
readonly onAppend?: (userID: string, record: Pick<EventRecord, "event" | "family" | "audience">) => void | Promise<void>
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class AgentEvents {
|
|
82
|
+
private readonly log: EventLog
|
|
83
|
+
private readonly kv: Kv
|
|
84
|
+
private readonly now: Clock
|
|
85
|
+
private readonly onAppend?: AgentEventsOptions["onAppend"]
|
|
86
|
+
/** Readers waiting for this process's next append, per user (long polling; other pods' appends are rechecked). */
|
|
87
|
+
private readonly waiters = new Map<string, Set<() => void>>()
|
|
88
|
+
|
|
89
|
+
constructor(options: AgentEventsOptions) {
|
|
90
|
+
this.log = options.log
|
|
91
|
+
this.kv = options.kv
|
|
92
|
+
this.now = options.now ?? Date.now
|
|
93
|
+
this.onAppend = options.onAppend
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Stores an event once per id; returns its id. */
|
|
97
|
+
async append(userID: string, input: EventInput): Promise<string> {
|
|
98
|
+
const id = input.id ?? newEventID()
|
|
99
|
+
if (!EVENT_ID.test(id)) throw new Error("Invalid event id")
|
|
100
|
+
const claim = `aev:${userID}:${id}`
|
|
101
|
+
if (!(await this.kv.set(claim, "1", { nx: true, px: AGENT_EVENTS_AGE_MS }))) return id
|
|
102
|
+
const record = {
|
|
103
|
+
type: input.type,
|
|
104
|
+
v: AGENT_EVENT_VERSION,
|
|
105
|
+
id,
|
|
106
|
+
at: this.now(),
|
|
107
|
+
family: input.family,
|
|
108
|
+
subject: input.subject,
|
|
109
|
+
data: input.data,
|
|
110
|
+
...(input.audience ? { audience: input.audience } : {}),
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
await this.log.append(userID, record)
|
|
114
|
+
} catch (cause) {
|
|
115
|
+
// Not stored: a retry with the same id must be able to store it.
|
|
116
|
+
await this.kv.del(claim).catch(() => {})
|
|
117
|
+
throw cause
|
|
118
|
+
}
|
|
119
|
+
await this.onAppend?.(userID, {
|
|
120
|
+
event: { id, at: record.at, type: input.type, subject: input.subject, data: input.data },
|
|
121
|
+
family: input.family,
|
|
122
|
+
...(input.audience ? { audience: input.audience } : {}),
|
|
123
|
+
})
|
|
124
|
+
const waiting = this.waiters.get(userID)
|
|
125
|
+
if (waiting) for (const wake of [...waiting]) wake()
|
|
126
|
+
return id
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Resolves on this process's next append for the user, after `ms`, or when `signal` aborts. */
|
|
130
|
+
appended(userID: string, ms: number, signal?: AbortSignal): Promise<void> {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
let set = this.waiters.get(userID)
|
|
133
|
+
if (!set) this.waiters.set(userID, (set = new Set()))
|
|
134
|
+
const done = () => {
|
|
135
|
+
clearTimeout(timer)
|
|
136
|
+
signal?.removeEventListener("abort", done)
|
|
137
|
+
set!.delete(done)
|
|
138
|
+
if (set!.size === 0 && this.waiters.get(userID) === set) this.waiters.delete(userID)
|
|
139
|
+
resolve()
|
|
140
|
+
}
|
|
141
|
+
const timer = setTimeout(done, ms)
|
|
142
|
+
set.add(done)
|
|
143
|
+
if (signal?.aborted) done()
|
|
144
|
+
else signal?.addEventListener("abort", done, { once: true })
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The position of the user's latest record; a reader holding it gets every later one. */
|
|
149
|
+
latest(userID: string): Promise<string> {
|
|
150
|
+
return this.log.latest(userID)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async since(userID: string, position: string): Promise<EventsSince> {
|
|
154
|
+
const result = await this.log.since(userID, position)
|
|
155
|
+
if ("resync" in result) return { reset: true }
|
|
156
|
+
const records: EventRecord[] = []
|
|
157
|
+
for (const logged of result.events) {
|
|
158
|
+
const m = logged.message as Record<string, unknown>
|
|
159
|
+
// A record this version cannot read is a reset, never a silent skip.
|
|
160
|
+
if (m.v !== AGENT_EVENT_VERSION || typeof m.id !== "string" || typeof m.at !== "number" || !FAMILIES.has(m.family as string)) return { reset: true }
|
|
161
|
+
records.push({
|
|
162
|
+
position: logged.id,
|
|
163
|
+
event: { id: m.id, at: m.at, type: String(m.type), subject: (m.subject ?? {}) as EventSubject, data: (m.data ?? {}) as EventData },
|
|
164
|
+
family: m.family as EventFamily,
|
|
165
|
+
...(m.audience ? { audience: m.audience as EventAudience } : {}),
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
return { records }
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---- Compact builders (allowlists) ------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/** A stored agent message changed (sent, delivered, answered…): never its text. */
|
|
175
|
+
export function messageEvent(message: Message): EventInput {
|
|
176
|
+
const { id, kind, state, from, recipient, replyTo, delivery } = message
|
|
177
|
+
return {
|
|
178
|
+
type: "message.changed",
|
|
179
|
+
family: "message",
|
|
180
|
+
subject: {
|
|
181
|
+
messageID: id,
|
|
182
|
+
...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
|
|
183
|
+
...(from.kind === "session" ? { senderID: from.sessionID } : {}),
|
|
184
|
+
},
|
|
185
|
+
data: { kind, state, from: addressOf(from), to: message.to, ...(replyTo ? { replyTo } : {}), ...(delivery ? { delivery } : {}) },
|
|
186
|
+
audience: { parties: [partyKey(from), partyKey(recipient)] },
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A launch changed: never its prompt, reason or labels. */
|
|
191
|
+
export function launchEvent(launch: Launch): EventInput {
|
|
192
|
+
return {
|
|
193
|
+
type: "launch.changed",
|
|
194
|
+
family: "launch",
|
|
195
|
+
subject: { launchID: launch.id, ...(launch.sessionID !== undefined ? { sessionID: launch.sessionID } : {}) },
|
|
196
|
+
data: { state: launch.state, ...(launch.outcome !== undefined ? { outcome: launch.outcome } : {}), ...(launch.name !== undefined ? { name: launch.name } : {}) },
|
|
197
|
+
...(launch.by !== undefined ? { audience: { by: launch.by } } : {}),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A session's archive state changed. */
|
|
202
|
+
export const archiveEvent = (sessionID: string, archived: boolean): EventInput => ({ type: "session.archived", family: "manage", subject: { sessionID }, data: { archived } })
|
|
203
|
+
|
|
204
|
+
export const pinnedEvent = (sessionID: string, pinned: boolean): EventInput => ({ type: "session.pinned", family: "manage", subject: { sessionID }, data: { pinned } })
|
|
205
|
+
|
|
206
|
+
/** A session was explicitly named (names are validated slugs: a-z, 0-9 and -). */
|
|
207
|
+
export const namedEvent = (sessionID: string, name: string): EventInput => ({ type: "session.named", family: "manage", subject: { sessionID }, data: { name } })
|
|
208
|
+
|
|
209
|
+
/** A session claimed (held) or released a role; role and project are validated slugs. */
|
|
210
|
+
export function roleEvent(sessionID: string, held: boolean, role: { role: string; project?: string; generation?: number; exclusive?: boolean }): EventInput {
|
|
211
|
+
return {
|
|
212
|
+
type: held ? "role.claimed" : "role.released",
|
|
213
|
+
family: "manage",
|
|
214
|
+
subject: { sessionID },
|
|
215
|
+
data: {
|
|
216
|
+
role: role.role,
|
|
217
|
+
...(role.project !== undefined ? { project: role.project } : {}),
|
|
218
|
+
...(held && role.generation !== undefined ? { generation: role.generation } : {}),
|
|
219
|
+
...(held && role.exclusive ? { exclusive: true } : {}),
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
interface Known {
|
|
225
|
+
state: string
|
|
226
|
+
blockers: Set<string>
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* State and blocker changes of status trees, against what was seen before (one per writer). Bounded by the live
|
|
231
|
+
* trees: a tree's entry is replaced by each status of it and dropped by forget().
|
|
232
|
+
*/
|
|
233
|
+
export class StatusDiff {
|
|
234
|
+
private readonly trees = new Map<string, Map<string, Known>>()
|
|
235
|
+
|
|
236
|
+
events(rootID: string, sessions: readonly LiveStatus[]): EventInput[] {
|
|
237
|
+
const before = this.trees.get(rootID) ?? new Map<string, Known>()
|
|
238
|
+
const after = new Map<string, Known>()
|
|
239
|
+
const out: EventInput[] = []
|
|
240
|
+
for (const entry of sessions) {
|
|
241
|
+
const known = before.get(entry.id)
|
|
242
|
+
const subject = { sessionID: entry.id, rootID }
|
|
243
|
+
const open = new Set(entry.blockers.map((b) => b.id))
|
|
244
|
+
for (const b of entry.blockers) if (!known?.blockers.has(b.id)) out.push({ type: "blocker.opened", family: "session", subject, data: { blockerID: b.id, kind: b.kind } })
|
|
245
|
+
for (const id of known?.blockers ?? []) if (!open.has(id)) out.push({ type: "blocker.closed", family: "session", subject, data: { blockerID: id } })
|
|
246
|
+
if (known?.state !== entry.state) out.push({ type: "session.changed", family: "session", subject, data: { state: entry.state, ...(known ? { prev: known.state } : {}) } })
|
|
247
|
+
after.set(entry.id, { state: entry.state, blockers: open })
|
|
248
|
+
}
|
|
249
|
+
this.trees.set(rootID, after)
|
|
250
|
+
return out
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Drops a tree that is gone. */
|
|
254
|
+
forget(rootID: string): void {
|
|
255
|
+
this.trees.delete(rootID)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
roots(): string[] {
|
|
259
|
+
return [...this.trees.keys()]
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Presence per user (sessions and machines) diffed against the previous view (the catalog writer's). */
|
|
264
|
+
export interface PresenceView {
|
|
265
|
+
readonly sessions: ReadonlySet<string>
|
|
266
|
+
/** Machine id → name, for the machines with a connected process. */
|
|
267
|
+
readonly machines: ReadonlyMap<string, string>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function presenceEvents(before: PresenceView, after: PresenceView): EventInput[] {
|
|
271
|
+
const out: EventInput[] = []
|
|
272
|
+
for (const id of after.sessions) if (!before.sessions.has(id)) out.push({ type: "session.added", family: "session", subject: { sessionID: id }, data: {} })
|
|
273
|
+
for (const id of before.sessions) if (!after.sessions.has(id)) out.push({ type: "session.removed", family: "session", subject: { sessionID: id }, data: {} })
|
|
274
|
+
for (const [id, name] of after.machines) if (!before.machines.has(id)) out.push({ type: "machine.changed", family: "session", subject: { machineID: id }, data: { name, online: true } })
|
|
275
|
+
for (const [id, name] of before.machines) if (!after.machines.has(id)) out.push({ type: "machine.changed", family: "session", subject: { machineID: id }, data: { name, online: false } })
|
|
276
|
+
return out
|
|
277
|
+
}
|
|
@@ -43,7 +43,7 @@ export function withSuffix(base: string, taken: (name: string) => boolean): stri
|
|
|
43
43
|
export const isReservedName = (name: string): boolean => name === "me"
|
|
44
44
|
|
|
45
45
|
/** Roles only the user may claim in phase 1 (grants including `roles` come in phase 3). */
|
|
46
|
-
const RESERVED_ROLES = ["orchestrator"
|
|
46
|
+
const RESERVED_ROLES = ["orchestrator"]
|
|
47
47
|
|
|
48
48
|
export const isReservedRole = (role: string): boolean => RESERVED_ROLES.some((prefix) => role.startsWith(prefix))
|
|
49
49
|
|
|
@@ -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
|
|