@huynle/hub 0.7.4 → 0.8.0

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.
Files changed (221) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +632 -49
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,865 @@
1
+ // The interaction graph projection behind the Watchtower (HWA-31; contract packages/protocol/src/graph.ts,
2
+ // decision 5e4l4u8v). A bounded, authorized, deterministic snapshot of who talks to whom through the hub:
3
+ //
4
+ // - Nodes: the hub, the user (@me), the user's machines (online or quiet), every session of the directory that is
5
+ // not archived or deleted (quiet and offline ones too), apps that appear in traffic, teams. Sessions carry the
6
+ // registry's machine, harness and state, the native parentID (subsession clusters), and — as separate overlays —
7
+ // workspace supervision (supervisorID) and team seats (teamIDs; `seats` adds the role, HWA-38). Teams carry
8
+ // their role names and entry role. Labels are only @names, session IDs, machine names and team titles: never
9
+ // titles, prompts or message text.
10
+ // - Edges: messages aggregated per (kind, from, to) inside the window, with counts, first/last time, ask states,
11
+ // delivery modes and role-addressed counts; asks to @me carrying a Decision are `decision`; replies and declines
12
+ // are `reply` (declines counted). `launch` edges go from the launcher to the session. `policy` edges are the
13
+ // permitted paths of restricted teams between seated sessions (traffic along them counted) plus `denied` counts
14
+ // from the team.denied audit (denial-only edges have `permitted: false`).
15
+ // - Visibility: the user sees everything. Sessions and apps (sessions:read + messages:read) see every session
16
+ // node, as GET /sessions does, but only traffic they sent or received (as GET /messages/:id); apps see teams
17
+ // with teams:read and supervision with workspaces:read; denials are the user's audit only. Launch edges,
18
+ // `launchID` and free-form launch `labels` follow the launch read rule (launch-routes.ts): the user all, apps
19
+ // with sessions:launch and sessions through their act token only launches they started, others none. Archived and deleted
20
+ // sessions are hidden with their traffic (`hidden.messages`). Another tenant's IDs simply do not exist here.
21
+ // - Limits: GRAPH_LIMITS. Over `limit` nodes or GRAPH_LIMITS.edges edges the busiest are kept (`capped`).
22
+ // - Retention: the message store's caps evict silently; `retention.truncated` says traffic inside the window may
23
+ // be missing (Messages.retention().evictedAt), `oldestAt` is the oldest retained interaction the viewer sees.
24
+ // - Detail: edgeDetail/nodeDetail page message summaries (IDs, kinds, states, times; never text), newest first,
25
+ // with an opaque cursor. Text is read only through GET /api/agent/messages/:id. Unknown, foreign, hidden and
26
+ // other-party edges/nodes all answer the same not_found.
27
+ // - Live updates: no new writer. Canonical journal events (message.changed, session.*, role.*, machine.changed,
28
+ // launch.changed, team.*, workspace.*) are the invalidation hints; graphInvalidation maps one to a compact
29
+ // `graph.invalidated` hint (IDs only). A journal `reset` means: take a new snapshot.
30
+ import { GRAPH_LIMITS, type GraphEdge, type GraphNode, type GraphNodeKind, type GraphSnapshot, type TeamEdge } from "../../protocol"
31
+ import type { AgentSession } from "../agent-data"
32
+ import { type AgentActor, canActAsSession } from "./actor"
33
+ import type { Decisions } from "./decisions"
34
+ import type { Directory } from "./directory"
35
+ import type { AgentEvent } from "./events"
36
+ import { type Party, partyKey, sanitizeLabel } from "./envelope"
37
+ import { AgentError } from "./errors"
38
+ import type { Delivery, Message, Messages } from "./messages"
39
+ import type { ReadRegistry } from "./read-routes"
40
+
41
+ export type GraphEdgeKind = GraphEdge["kind"]
42
+ export type GraphStatus = NonNullable<GraphNode["status"]>
43
+
44
+ export const GRAPH_EDGE_KINDS: readonly GraphEdgeKind[] = ["msg", "ask", "reply", "decision", "launch", "policy"]
45
+ export const GRAPH_STATUSES: readonly GraphStatus[] = ["idle", "working", "blocked", "error", "offline", "ended"]
46
+ /** Message summaries per detail page. */
47
+ export const GRAPH_DETAIL_PAGE = { default: 50, max: 200 } as const
48
+ /** The fixed party HWA-11's supervisor digests come from (coordinatorActor). */
49
+ export const COORDINATOR_PARTY = "app:workspace-coordinator"
50
+ /** The fixed party of the Concierge service (CONCIERGE_PARTY, CON-04). */
51
+ const CONCIERGE_APP_PARTY = "app:concierge"
52
+ /** Characters of a filter value. */
53
+ const FILTER_MAX = 200
54
+ /** Native ancestry deeper than this is not followed (cycles in bad data). */
55
+ const DEPTH_MAX = 32
56
+
57
+ // ---- Sources ------------------------------------------------------------------------------------------------
58
+
59
+ export interface GraphLaunch {
60
+ readonly id: string
61
+ /** `app:<token id>`, `ses_…` or `user` (Launch.by). */
62
+ readonly by?: string
63
+ readonly sessionID?: string
64
+ readonly state: string
65
+ readonly created: number
66
+ readonly spec: { readonly labels?: Readonly<Record<string, string>>; readonly harness?: string }
67
+ }
68
+
69
+ export interface GraphTeam {
70
+ readonly id: string
71
+ readonly name: string
72
+ readonly title: string
73
+ readonly state: string
74
+ readonly mode: "open" | "restricted"
75
+ readonly roles: readonly { readonly name: string }[]
76
+ /** The intake role (HWA-38 highlights it in team clusters). */
77
+ readonly entryRole?: string
78
+ readonly edges: readonly TeamEdge[]
79
+ readonly tags?: readonly string[]
80
+ readonly projects?: readonly string[]
81
+ readonly metadata?: Readonly<Record<string, string>>
82
+ readonly paused?: boolean
83
+ }
84
+
85
+ export interface GraphSeat {
86
+ readonly teamID: string
87
+ readonly role: string
88
+ readonly sessionID: string
89
+ }
90
+
91
+ /** A current workspace membership, with the member's supervisor's session (supervision overlay). */
92
+ export interface GraphMembership {
93
+ readonly workspaceID: string
94
+ readonly sessionID: string
95
+ readonly supervisorSessionID?: string
96
+ }
97
+
98
+ /** A team policy refusal (the user's audit, `team.denied`): IDs only. */
99
+ export interface GraphDenial {
100
+ readonly at: number
101
+ readonly senderID: string
102
+ readonly recipientID?: string
103
+ }
104
+
105
+ /** Everything the projection reads; each optional source adds its overlay. */
106
+ export interface GraphSources {
107
+ readonly now: () => number
108
+ /** The user's machines (not revoked). */
109
+ machines(userID: string): readonly { readonly id: string; readonly name: string }[]
110
+ readonly registry: Pick<ReadRegistry, "catalog" | "sessionTenant">
111
+ /** The directory's session records (archived and deleted included: they are hidden here). */
112
+ sessions(userID: string): readonly AgentSession[]
113
+ readonly directory: Pick<Directory, "who">
114
+ readonly messages: Pick<Messages, "all" | "retention">
115
+ readonly decisions?: Pick<Decisions, "list">
116
+ launches?(userID: string): Promise<readonly GraphLaunch[]>
117
+ teams?(userID: string): Promise<{ readonly teams: readonly GraphTeam[]; readonly seats: readonly GraphSeat[] }>
118
+ supervision?(userID: string): Promise<readonly GraphMembership[]>
119
+ denials?(userID: string): Promise<readonly GraphDenial[]>
120
+ }
121
+
122
+ // ---- Views (additive to the frozen GraphNode/GraphEdge) -----------------------------------------------------
123
+
124
+ export interface GraphNodeView extends GraphNode {
125
+ /** Apps (kind user) and the workspace coordinator (kind hub) are app parties. */
126
+ readonly party?: "app"
127
+ readonly online?: boolean
128
+ /** Machines: harness kinds of their connected processes. */
129
+ readonly harnesses?: readonly string[]
130
+ /** Sessions: role labels (`role` or `role.project`). */
131
+ readonly roles?: readonly string[]
132
+ readonly lastSeen?: number
133
+ /** Sessions: native depth (0 for a root) and root. */
134
+ readonly depth?: number
135
+ readonly rootID?: string
136
+ readonly workspaceIDs?: readonly string[]
137
+ /** Sessions a launch started: the latest launch and its labels (sanitized). */
138
+ readonly launchID?: string
139
+ readonly labels?: Readonly<Record<string, string>>
140
+ /** Teams. */
141
+ readonly name?: string
142
+ readonly projects?: readonly string[]
143
+ readonly mode?: "open" | "restricted"
144
+ readonly paused?: boolean
145
+ readonly metadata?: Readonly<Record<string, string>>
146
+ /** Teams (HWA-38): the entry role; `roles` carries a team's role names in definition order. */
147
+ readonly entryRole?: string
148
+ /** Sessions (HWA-38): the (team, role) seats they hold, sorted; only with team visibility, like `teamIDs`. */
149
+ readonly seats?: readonly { readonly teamID: string; readonly role: string }[]
150
+ }
151
+
152
+ export type AskState = "open" | "answered" | "declined" | "expired"
153
+
154
+ export interface GraphEdgeView extends GraphEdge {
155
+ readonly firstAt?: number
156
+ /** Asks and Decisions: how many are in each state now. */
157
+ readonly states?: Partial<Record<AskState, number>>
158
+ /** How messages reached the recipient (absent: not recorded, e.g. stored only). */
159
+ readonly delivery?: Partial<Record<Delivery, number>>
160
+ /** Reply edges: how many were declines. */
161
+ readonly declined?: number
162
+ /** Messages sent to a role address (`@@role…`). */
163
+ readonly roleAddressed?: number
164
+ readonly maxHop?: number
165
+ /** Policy edges: a permitted team path (true) or only refused attempts (false). */
166
+ readonly permitted?: boolean
167
+ readonly kinds?: readonly string[]
168
+ }
169
+
170
+ export interface GraphSnapshotView extends GraphSnapshot {
171
+ readonly nodes: readonly GraphNodeView[]
172
+ readonly edges: readonly GraphEdgeView[]
173
+ /** Traffic left out because an endpoint is archived or deleted. */
174
+ readonly hidden: { readonly messages: number }
175
+ }
176
+
177
+ export interface MessageSummary {
178
+ readonly id: string
179
+ readonly kind: Message["kind"]
180
+ readonly state: Message["state"]
181
+ readonly from: string
182
+ readonly to: string
183
+ /** The address as the sender wrote it (sanitized): `@name`, `@@role`, `ses_…`, `@me`. */
184
+ readonly address: string
185
+ readonly at: number
186
+ readonly hop: number
187
+ readonly generation?: number
188
+ readonly replyTo?: string
189
+ readonly answer?: { readonly id: string; readonly at: number }
190
+ readonly expiresAt?: number
191
+ readonly delivery?: Delivery
192
+ readonly deliveredAt?: number
193
+ readonly decisionID?: string
194
+ }
195
+
196
+ // ---- Viewer and query -----------------------------------------------------------------------------------------
197
+
198
+ export interface GraphViewer {
199
+ readonly kind: AgentActor["kind"]
200
+ /** Non-user viewers: their party key; they see only traffic they are a party of. */
201
+ readonly party?: string
202
+ /**
203
+ * Launch reads mirror launch-routes.ts (LAUNCH allow + launchedBy): users read every launch; apps with
204
+ * sessions:launch and sessions through their act token read only launches they started (`launcher`); others
205
+ * read none. Launch edges, `launchID` and the free-form `labels` follow it.
206
+ */
207
+ readonly launches: boolean
208
+ /** Non-user viewers: launches they started (Launch.by). */
209
+ readonly launcher?: string
210
+ readonly teams: boolean
211
+ readonly supervision: boolean
212
+ readonly denials: boolean
213
+ }
214
+
215
+ /** What the actor may see; an app also needs messages:read (the route checks sessions:read). */
216
+ export function viewerOf(actor: AgentActor): GraphViewer {
217
+ switch (actor.kind) {
218
+ case "user":
219
+ return { kind: "user", launches: true, teams: true, supervision: true, denials: true }
220
+ case "session":
221
+ return { kind: "session", party: partyKey({ kind: "session", sessionID: actor.sessionID }), launches: canActAsSession(actor), launcher: actor.sessionID, teams: false, supervision: false, denials: false }
222
+ case "app": {
223
+ if (!actor.scopes.includes("messages:read")) throw new AgentError("forbidden", "Reading the interaction graph needs sessions:read and messages:read", { hint: "Create an app token with both scopes" })
224
+ const party = partyKey({ kind: "app", tokenID: actor.tokenID })
225
+ return { kind: "app", party, launches: actor.scopes.includes("sessions:launch"), launcher: party, teams: actor.scopes.includes("teams:read"), supervision: actor.scopes.includes("workspaces:read"), denials: false }
226
+ }
227
+ }
228
+ }
229
+
230
+ export interface GraphQuery {
231
+ readonly since: number
232
+ readonly until: number
233
+ readonly limit: number
234
+ readonly machine?: string
235
+ readonly harness?: string
236
+ readonly team?: string
237
+ readonly tag?: string
238
+ readonly project?: string
239
+ /** A session ID: it and its native descendants. */
240
+ readonly session?: string
241
+ readonly workspace?: string
242
+ readonly kinds?: ReadonlySet<GraphEdgeKind>
243
+ readonly states?: ReadonlySet<GraphStatus>
244
+ }
245
+
246
+ const invalid = (message: string) => new AgentError("invalid", message)
247
+
248
+ function intParam(params: URLSearchParams, name: string): number | undefined {
249
+ const raw = params.get(name)
250
+ if (raw === null || raw === "") return undefined
251
+ if (!/^\d{1,15}$/.test(raw)) throw invalid(`${name} must be a whole number${name === "limit" ? "" : " of milliseconds since the epoch"}`)
252
+ return Number(raw)
253
+ }
254
+
255
+ function textParam(params: URLSearchParams, name: string): string | undefined {
256
+ const raw = params.get(name)?.trim()
257
+ if (!raw) return undefined
258
+ if (raw.length > FILTER_MAX) throw invalid(`${name} is longer than ${FILTER_MAX} characters`)
259
+ return raw
260
+ }
261
+
262
+ function listParam<T extends string>(params: URLSearchParams, name: string, allowed: readonly T[]): ReadonlySet<T> | undefined {
263
+ const raw = params.get(name)
264
+ if (raw === null || raw === "") return undefined
265
+ const values = raw.split(",").filter(Boolean)
266
+ if (values.some((v) => !allowed.includes(v as T))) throw invalid(`${name} is a comma list of ${allowed.join(", ")}`)
267
+ return new Set(values as T[])
268
+ }
269
+
270
+ /** since/until (ms; default the last 24 h; at most 7 days). */
271
+ function windowOf(params: URLSearchParams, now: number): { since: number; until: number } {
272
+ const until = intParam(params, "until") ?? now
273
+ const since = intParam(params, "since") ?? until - GRAPH_LIMITS.defaultWindowMs
274
+ if (since > until) throw invalid("since must not be after until")
275
+ if (until - since > GRAPH_LIMITS.windowMs) throw invalid(`The window is at most ${GRAPH_LIMITS.windowMs / 86_400_000} days`)
276
+ return { since, until }
277
+ }
278
+
279
+ /** GET /api/agent/graph's query: window, limit and filters (invalid on anything malformed). */
280
+ export function parseGraphQuery(params: URLSearchParams, now: number): GraphQuery {
281
+ const limit = intParam(params, "limit") ?? GRAPH_LIMITS.nodes
282
+ if (limit < 1 || limit > GRAPH_LIMITS.nodes) throw invalid(`limit must be 1–${GRAPH_LIMITS.nodes}`)
283
+ const out: Record<string, unknown> = { ...windowOf(params, now), limit }
284
+ for (const name of ["machine", "harness", "team", "tag", "project", "session", "workspace"] as const) {
285
+ const value = textParam(params, name)
286
+ if (value !== undefined) out[name] = value
287
+ }
288
+ const kinds = listParam(params, "kind", GRAPH_EDGE_KINDS)
289
+ const states = listParam(params, "state", GRAPH_STATUSES)
290
+ if (kinds) out.kinds = kinds
291
+ if (states) out.states = states
292
+ return out as unknown as GraphQuery
293
+ }
294
+
295
+ // ---- Projection -------------------------------------------------------------------------------------------------
296
+
297
+ interface EdgeAcc {
298
+ readonly id: string
299
+ readonly kind: GraphEdgeKind
300
+ readonly from: string
301
+ readonly to: string
302
+ readonly teamID?: string
303
+ count: number
304
+ lastAt: number
305
+ firstAt?: number
306
+ denied: number
307
+ declined: number
308
+ roleAddressed: number
309
+ maxHop: number
310
+ permitted?: boolean
311
+ kinds?: Set<string>
312
+ readonly states: Partial<Record<AskState, number>>
313
+ readonly delivery: Partial<Record<Delivery, number>>
314
+ readonly messages: Message[]
315
+ }
316
+
317
+ interface Projection {
318
+ readonly nodes: Map<string, GraphNodeView>
319
+ readonly edges: Map<string, EdgeAcc>
320
+ /** Visible traffic in the window, oldest first. */
321
+ readonly messages: Message[]
322
+ readonly decisionOf: ReadonlyMap<string, string>
323
+ readonly hidden: number
324
+ readonly retention: GraphSnapshot["retention"]
325
+ }
326
+
327
+ const RANK: Record<GraphNodeKind, number> = { hub: 0, user: 1, machine: 2, team: 3, session: 5 }
328
+ const rankOf = (node: GraphNodeView) => (node.id === "hub" ? 0 : node.id === "@me" ? 1 : node.party === "app" ? 4 : RANK[node.kind])
329
+ const byID = (a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
330
+ const machineNode = (id: string) => `machine:${id}`
331
+
332
+ /** A party's node ID: the session ID, `@me`, or `app:<token id>`. */
333
+ export function partyNode(party: Party): string {
334
+ return party.kind === "session" ? party.sessionID : party.kind === "user" ? "@me" : partyKey(party)
335
+ }
336
+
337
+ function edgeKindOf(message: Message, decisionOf: ReadonlyMap<string, string>): GraphEdgeKind {
338
+ if (message.kind === "ask") return decisionOf.has(message.id) ? "decision" : "ask"
339
+ return message.kind === "msg" ? "msg" : "reply"
340
+ }
341
+
342
+ const edgeID = (kind: GraphEdgeKind, from: string, to: string, teamID?: string) => (kind === "policy" ? `policy|${from}|${to}|${teamID ?? ""}` : `${kind}|${from}|${to}`)
343
+
344
+ function newEdge(kind: GraphEdgeKind, from: string, to: string, teamID?: string): EdgeAcc {
345
+ return { id: edgeID(kind, from, to, teamID), kind, from, to, ...(teamID !== undefined ? { teamID } : {}), count: 0, lastAt: 0, denied: 0, declined: 0, roleAddressed: 0, maxHop: 0, states: {}, delivery: {}, messages: [] }
346
+ }
347
+
348
+ function bump<K extends string>(record: Partial<Record<K, number>>, key: K): void {
349
+ record[key] = (record[key] ?? 0) + 1
350
+ }
351
+
352
+ function cleanLabels(labels: Readonly<Record<string, string>> | undefined): Record<string, string> | undefined {
353
+ if (!labels) return undefined
354
+ const out: Record<string, string> = {}
355
+ for (const [key, value] of Object.entries(labels)) {
356
+ const k = sanitizeLabel(String(key))
357
+ const v = sanitizeLabel(String(value))
358
+ if (k) out[k] = v
359
+ }
360
+ return Object.keys(out).length ? out : undefined
361
+ }
362
+
363
+ interface Live {
364
+ readonly harness: string
365
+ readonly machineID: string
366
+ readonly state: string
367
+ readonly parentID?: string
368
+ }
369
+
370
+ /** The user's live sessions (roots and subsessions) from the registry, tenant-checked: harness, machine, state. */
371
+ function liveSessions(sources: GraphSources, userID: string): { sessions: Map<string, Live>; machines: Map<string, Set<string>> } {
372
+ const sessions = new Map<string, Live>()
373
+ const machines = new Map<string, Set<string>>()
374
+ for (const machine of sources.registry.catalog()) {
375
+ const harnesses = machines.get(machine.id) ?? new Set<string>()
376
+ machines.set(machine.id, harnesses)
377
+ for (const process of machine.processes) {
378
+ // A process reporting only another tenant's sessions says nothing about this user's machine.
379
+ if (!process.sessions.length || process.sessions.some((s) => sources.registry.sessionTenant(s.id) === userID)) harnesses.add(process.harness)
380
+ for (const session of process.sessions) {
381
+ if (sources.registry.sessionTenant(session.id) !== userID) continue
382
+ const tree = session.status?.length ? session.status : [{ id: session.id, state: session.state, ...(session.parentID ? { parentID: session.parentID } : {}) }]
383
+ for (const entry of tree) {
384
+ if (sessions.has(entry.id)) continue
385
+ sessions.set(entry.id, { harness: process.harness, machineID: machine.id, state: entry.state, ...(entry.parentID ? { parentID: entry.parentID } : {}) })
386
+ }
387
+ }
388
+ }
389
+ }
390
+ return { sessions, machines }
391
+ }
392
+
393
+ const STATUS: ReadonlySet<string> = new Set(GRAPH_STATUSES)
394
+
395
+ /** A recorded harness kind, if it is one (records are free-form JSON). */
396
+ const knownHarness = (value: unknown): string | undefined => (typeof value === "string" && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(value) ? value : undefined)
397
+
398
+ async function project(sources: GraphSources, userID: string, viewer: GraphViewer, since: number, until: number): Promise<Projection> {
399
+ const nodes = new Map<string, GraphNodeView>()
400
+ const put = (node: GraphNodeView) => nodes.set(node.id, node)
401
+ put({ id: "hub", kind: "hub", label: "Hub" })
402
+ put({ id: "@me", kind: "user", label: "@me" })
403
+
404
+ // Machines and sessions.
405
+ const live = liveSessions(sources, userID)
406
+ for (const m of sources.machines(userID)) {
407
+ const harnesses = live.machines.get(m.id)
408
+ put({ id: machineNode(m.id), kind: "machine", label: sanitizeLabel(m.name) || m.id, machineID: m.id, online: harnesses !== undefined, ...(harnesses?.size ? { harnesses: [...harnesses].sort() } : {}) })
409
+ }
410
+ const records = new Map<string, AgentSession>()
411
+ const hiddenSessions = new Set<string>()
412
+ for (const record of sources.sessions(userID) ?? []) {
413
+ if (!record?.sessionID) continue
414
+ if (record.archivedAt !== undefined || record.deletedAt !== undefined) hiddenSessions.add(record.sessionID)
415
+ else records.set(record.sessionID, record)
416
+ }
417
+ for (const [id, l] of live.sessions) if (!records.has(id) && !hiddenSessions.has(id)) records.set(id, { sessionID: id, machineID: l.machineID, ...(l.parentID ? { parentID: l.parentID } : {}) })
418
+ const entries = new Map(sources.directory.who(userID).map((e) => [e.sessionID, e]))
419
+ const parentOf = (id: string) => records.get(id)?.parentID ?? live.sessions.get(id)?.parentID
420
+ for (const [id, record] of records) {
421
+ const entry = entries.get(id)
422
+ const l = live.sessions.get(id)
423
+ let depth = 0
424
+ let root = id
425
+ for (let up = parentOf(id); up !== undefined && depth < DEPTH_MAX && records.has(up); up = parentOf(up)) {
426
+ depth++
427
+ root = up
428
+ }
429
+ const parentID = parentOf(id)
430
+ const machineID = l?.machineID ?? record.machineID
431
+ const state = l ? (STATUS.has(l.state) ? (l.state as GraphStatus) : "idle") : "offline"
432
+ const roles = (entry?.roles ?? []).map((r) => (r.project !== undefined ? `${r.role}.${r.project}` : r.role))
433
+ const harness = l?.harness ?? knownHarness(record.harness)
434
+ put({
435
+ id,
436
+ kind: "session",
437
+ label: entry?.name ? `@${entry.name}` : id,
438
+ ...(machineID ? { machineID } : {}),
439
+ ...(harness ? { harness } : {}),
440
+ ...(parentID ? { parentID } : {}),
441
+ status: state,
442
+ ...(record.project ? { project: record.project } : {}),
443
+ online: l !== undefined || entry?.online === true,
444
+ ...(record.lastSeen !== undefined ? { lastSeen: record.lastSeen } : {}),
445
+ depth,
446
+ ...(depth > 0 ? { rootID: root } : {}),
447
+ ...(roles.length ? { roles } : {}),
448
+ })
449
+ }
450
+ const isSession = (id: string) => nodes.get(id)?.kind === "session"
451
+ const update = (id: string, patch: Partial<GraphNodeView>) => {
452
+ const node = nodes.get(id)
453
+ if (node) nodes.set(id, { ...node, ...patch })
454
+ }
455
+
456
+ // Overlays: supervision, teams, launches.
457
+ const edges = new Map<string, EdgeAcc>()
458
+ const edgeFor = (kind: GraphEdgeKind, from: string, to: string, teamID?: string) => {
459
+ const id = edgeID(kind, from, to, teamID)
460
+ let acc = edges.get(id)
461
+ if (!acc) edges.set(id, (acc = newEdge(kind, from, to, teamID)))
462
+ return acc
463
+ }
464
+ if (viewer.supervision && sources.supervision) {
465
+ const workspaces = new Map<string, Set<string>>()
466
+ const supervisors = new Map<string, string>()
467
+ for (const m of await sources.supervision(userID)) {
468
+ if (!isSession(m.sessionID)) continue
469
+ ;(workspaces.get(m.sessionID) ?? workspaces.set(m.sessionID, new Set()).get(m.sessionID)!).add(m.workspaceID)
470
+ if (m.supervisorSessionID !== undefined && isSession(m.supervisorSessionID) && !supervisors.has(m.sessionID)) supervisors.set(m.sessionID, m.supervisorSessionID)
471
+ }
472
+ for (const [id, set] of workspaces) update(id, { workspaceIDs: [...set].sort(), ...(supervisors.has(id) ? { supervisorID: supervisors.get(id)! } : {}) })
473
+ }
474
+ let teamData: Awaited<ReturnType<NonNullable<GraphSources["teams"]>>> | undefined
475
+ if (viewer.teams && sources.teams) {
476
+ teamData = await sources.teams(userID)
477
+ const seatsOf = new Map<string, Set<string>>()
478
+ for (const team of teamData.teams) {
479
+ if (team.state !== "active") continue
480
+ put({
481
+ id: team.id,
482
+ kind: "team",
483
+ label: sanitizeLabel(team.title) || team.name,
484
+ name: team.name,
485
+ mode: team.mode,
486
+ ...(team.roles.length ? { roles: team.roles.map((r) => r.name) } : {}),
487
+ ...(team.entryRole ? { entryRole: team.entryRole } : {}),
488
+ ...(team.tags?.length ? { tags: [...team.tags] } : {}),
489
+ ...(team.projects?.length ? { project: team.projects[0]!, projects: [...team.projects] } : {}),
490
+ ...(team.paused ? { paused: true } : {}),
491
+ ...(team.metadata && Object.keys(team.metadata).length ? { metadata: cleanLabels(team.metadata)! } : {}),
492
+ })
493
+ }
494
+ const rolesOf = new Map<string, Map<string, { teamID: string; role: string }>>()
495
+ for (const seat of teamData.seats) {
496
+ if (!isSession(seat.sessionID) || nodes.get(seat.teamID)?.kind !== "team") continue
497
+ ;(seatsOf.get(seat.sessionID) ?? seatsOf.set(seat.sessionID, new Set()).get(seat.sessionID)!).add(seat.teamID)
498
+ ;(rolesOf.get(seat.sessionID) ?? rolesOf.set(seat.sessionID, new Map()).get(seat.sessionID)!).set(`${seat.teamID}\u0000${seat.role}`, { teamID: seat.teamID, role: seat.role })
499
+ }
500
+ const bySeat = (a: { teamID: string; role: string }, b: { teamID: string; role: string }) => (a.teamID < b.teamID ? -1 : a.teamID > b.teamID ? 1 : a.role < b.role ? -1 : a.role > b.role ? 1 : 0)
501
+ for (const [id, set] of seatsOf) update(id, { teamIDs: [...set].sort(), seats: [...rolesOf.get(id)!.values()].sort(bySeat) })
502
+ }
503
+ if (viewer.launches && sources.launches) {
504
+ // Only launches the viewer may read (all for the user, its own otherwise): the latest of them labels a session.
505
+ const launches = [...(await sources.launches(userID))]
506
+ .filter((launch) => viewer.launcher === undefined || launch.by === viewer.launcher)
507
+ .sort((a, b) => a.created - b.created || (a.id < b.id ? -1 : 1))
508
+ for (const launch of launches) {
509
+ if (launch.sessionID === undefined || !isSession(launch.sessionID)) continue
510
+ const labels = cleanLabels(launch.spec.labels)
511
+ const { labels: _previous, ...node } = nodes.get(launch.sessionID)!
512
+ const harness = node.harness ?? knownHarness(launch.spec.harness)
513
+ put({ ...node, launchID: launch.id, ...(labels ? { labels } : {}), ...(harness ? { harness } : {}) })
514
+ if (launch.by === undefined || launch.created < since || launch.created > until) continue
515
+ const from = launch.by === "user" ? "@me" : launch.by.startsWith("app:") ? launch.by : isSession(launch.by) ? launch.by : undefined
516
+ if (from === undefined) continue
517
+ if (from.startsWith("app:") && !nodes.has(from)) put(appNode(from))
518
+ const acc = edgeFor("launch", from, launch.sessionID)
519
+ acc.count++
520
+ acc.firstAt = Math.min(acc.firstAt ?? launch.created, launch.created)
521
+ acc.lastAt = Math.max(acc.lastAt, launch.created)
522
+ }
523
+ }
524
+
525
+ // A legacy offline session (recorded before the directory kept harnesses) on a machine whose processes for this
526
+ // user all run one harness: that harness. Never across tenants (liveSessions only counts this user's processes).
527
+ for (const node of nodes.values()) {
528
+ if (node.kind !== "session" || node.harness !== undefined || node.machineID === undefined) continue
529
+ const only = live.machines.get(node.machineID)
530
+ if (only?.size === 1) update(node.id, { harness: [...only][0]! })
531
+ }
532
+
533
+ // Messages.
534
+ const [all, retention, decisions] = await Promise.all([
535
+ sources.messages.all(userID),
536
+ sources.messages.retention(userID),
537
+ sources.decisions ? sources.decisions.list(userID) : Promise.resolve([]),
538
+ ])
539
+ const decisionOf = new Map(decisions.map((d) => [d.messageID, d.id]))
540
+ const visible = all.filter((m) => viewer.party === undefined || partyKey(m.from) === viewer.party || partyKey(m.recipient) === viewer.party)
541
+ const known = (party: Party) => party.kind !== "session" || isSession(party.sessionID)
542
+ const messages: Message[] = []
543
+ let hidden = 0
544
+ for (const m of visible) {
545
+ if (m.at < since || m.at > until) continue
546
+ if (!known(m.from) || !known(m.recipient)) {
547
+ hidden++
548
+ continue
549
+ }
550
+ messages.push(m)
551
+ for (const party of [m.from, m.recipient]) {
552
+ const id = partyNode(party)
553
+ if (party.kind === "app" && !nodes.has(id)) put(appNode(id, party.label))
554
+ }
555
+ const acc = edgeFor(edgeKindOf(m, decisionOf), partyNode(m.from), partyNode(m.recipient))
556
+ acc.count++
557
+ acc.firstAt = Math.min(acc.firstAt ?? m.at, m.at)
558
+ acc.lastAt = Math.max(acc.lastAt, m.at)
559
+ acc.maxHop = Math.max(acc.maxHop, m.hop)
560
+ if (m.to.startsWith("@@")) acc.roleAddressed++
561
+ if (m.kind === "ask") bump(acc.states, m.state as AskState)
562
+ if (m.kind === "decline") acc.declined++
563
+ if (m.delivery) bump(acc.delivery, m.delivery)
564
+ acc.messages.push(m)
565
+ }
566
+
567
+ // Team policy overlay: permitted paths of restricted teams between seated sessions, and refusals.
568
+ if (teamData) {
569
+ const seated = new Map<string, Map<string, string[]>>() // teamID → role → sessions
570
+ for (const seat of teamData.seats) {
571
+ if (!isSession(seat.sessionID)) continue
572
+ const roles = seated.get(seat.teamID) ?? seated.set(seat.teamID, new Map()).get(seat.teamID)!
573
+ ;(roles.get(seat.role) ?? roles.set(seat.role, []).get(seat.role)!).push(seat.sessionID)
574
+ }
575
+ for (const team of teamData.teams) {
576
+ if (team.state !== "active" || team.mode !== "restricted") continue
577
+ const roles = seated.get(team.id)
578
+ if (!roles) continue
579
+ for (const path of team.edges) {
580
+ const pairs: [string, string][] = []
581
+ for (const a of roles.get(path.from) ?? []) for (const b of roles.get(path.to) ?? []) if (a !== b) pairs.push([a, b], ...(path.direction === "two-way" ? [[b, a] as [string, string]] : []))
582
+ for (const [a, b] of pairs) {
583
+ if (edges.size >= GRAPH_LIMITS.edges * 2) break
584
+ const acc = edgeFor("policy", a, b, team.id)
585
+ acc.permitted = true
586
+ acc.kinds ??= new Set()
587
+ for (const kind of path.kinds) acc.kinds.add(kind)
588
+ }
589
+ }
590
+ }
591
+ for (const acc of edges.values()) {
592
+ if (acc.kind !== "policy") continue
593
+ for (const m of messages) {
594
+ if (m.from.kind !== "session" || m.recipient.kind !== "session" || m.from.sessionID !== acc.from || m.recipient.sessionID !== acc.to || !acc.kinds?.has(m.kind)) continue
595
+ acc.count++
596
+ acc.firstAt = Math.min(acc.firstAt ?? m.at, m.at)
597
+ acc.lastAt = Math.max(acc.lastAt, m.at)
598
+ acc.messages.push(m)
599
+ }
600
+ }
601
+ }
602
+ if (viewer.denials && sources.denials) {
603
+ // A refusal on a pair some team permits (another kind) counts on that path; others get a denial-only edge.
604
+ const permittedPair = new Map<string, EdgeAcc>()
605
+ for (const e of [...edges.values()].sort(byID)) if (e.kind === "policy" && e.permitted && !permittedPair.has(`${e.from}|${e.to}`)) permittedPair.set(`${e.from}|${e.to}`, e)
606
+ for (const d of await sources.denials(userID)) {
607
+ if (d.at < since || d.at > until || !isSession(d.senderID)) continue
608
+ const to = d.recipientID !== undefined && isSession(d.recipientID) ? d.recipientID : "hub"
609
+ const permitted = permittedPair.get(`${d.senderID}|${to}`)
610
+ const acc = permitted ?? edgeFor("policy", d.senderID, to)
611
+ if (!permitted) acc.permitted = false
612
+ acc.denied++
613
+ acc.lastAt = Math.max(acc.lastAt, d.at)
614
+ acc.firstAt = Math.min(acc.firstAt ?? d.at, d.at)
615
+ }
616
+ }
617
+
618
+ const evicted = retention.evictedAt !== undefined && retention.evictedAt >= since
619
+ const oldest = visible[0]?.at
620
+ return {
621
+ nodes,
622
+ edges,
623
+ messages,
624
+ decisionOf,
625
+ hidden,
626
+ retention: { truncated: evicted, ...(oldest !== undefined ? { oldestAt: oldest } : {}) },
627
+ }
628
+ }
629
+
630
+ function appNode(id: string, label?: string): GraphNodeView {
631
+ if (id === COORDINATOR_PARTY) return { id, kind: "hub", label: "workspace coordinator", party: "app" }
632
+ // (CON-09a) The Concierge service's party (its updates, digests and launches): the hub's, named for the Concierge.
633
+ if (id === CONCIERGE_APP_PARTY) return { id, kind: "hub", label: "@@concierge (hub)", party: "app" }
634
+ return { id, kind: "user", label: sanitizeLabel(label ?? "") || id, party: "app" }
635
+ }
636
+
637
+ function edgeView(acc: EdgeAcc): GraphEdgeView {
638
+ const nonEmpty = (record: Record<string, number | undefined>) => Object.keys(record).length > 0
639
+ return {
640
+ id: acc.id,
641
+ from: acc.from,
642
+ to: acc.to,
643
+ kind: acc.kind,
644
+ count: acc.count,
645
+ lastAt: acc.lastAt,
646
+ ...(acc.teamID ? { teamID: acc.teamID } : {}),
647
+ ...(acc.denied ? { denied: acc.denied } : {}),
648
+ ...(acc.firstAt !== undefined ? { firstAt: acc.firstAt } : {}),
649
+ ...(nonEmpty(acc.states) ? { states: { ...acc.states } } : {}),
650
+ ...(nonEmpty(acc.delivery) ? { delivery: { ...acc.delivery } } : {}),
651
+ ...(acc.declined ? { declined: acc.declined } : {}),
652
+ ...(acc.roleAddressed ? { roleAddressed: acc.roleAddressed } : {}),
653
+ ...(acc.maxHop ? { maxHop: acc.maxHop } : {}),
654
+ ...(acc.permitted !== undefined ? { permitted: acc.permitted } : {}),
655
+ ...(acc.kinds?.size ? { kinds: [...acc.kinds].sort() } : {}),
656
+ }
657
+ }
658
+
659
+ // ---- Filters and limits ---------------------------------------------------------------------------------------
660
+
661
+ function sessionFilter(projection: Projection, sources: GraphSources, userID: string, q: GraphQuery): ((node: GraphNodeView) => boolean) | undefined {
662
+ const narrowing = q.machine ?? q.harness ?? q.team ?? q.tag ?? q.project ?? q.session ?? q.workspace ?? q.states
663
+ if (narrowing === undefined) return undefined
664
+ const machineIDs = q.machine !== undefined ? new Set(sources.machines(userID).filter((m) => m.id === q.machine || m.name === q.machine).map((m) => m.id)) : undefined
665
+ const teams = teamsMatching(projection, q)
666
+ const project = q.project?.toLowerCase()
667
+ const inTree = (node: GraphNodeView) => {
668
+ for (let id: string | undefined = node.id, depth = 0; id !== undefined && depth <= DEPTH_MAX; id = projection.nodes.get(id)?.parentID, depth++) if (id === q.session) return true
669
+ return false
670
+ }
671
+ return (node) =>
672
+ (!machineIDs || (node.machineID !== undefined && machineIDs.has(node.machineID))) &&
673
+ (q.harness === undefined || node.harness === q.harness) &&
674
+ (project === undefined || node.project?.toLowerCase() === project) &&
675
+ (q.session === undefined || inTree(node)) &&
676
+ (q.workspace === undefined || (node.workspaceIDs ?? []).includes(q.workspace)) &&
677
+ (!teams || (node.teamIDs ?? []).some((id) => teams.has(id))) &&
678
+ (!q.states || (node.status !== undefined && q.states.has(node.status)))
679
+ }
680
+
681
+ /** Team nodes the team/tag filters select (undefined: no team filter). */
682
+ function teamsMatching(projection: Projection, q: GraphQuery): Set<string> | undefined {
683
+ if (q.team === undefined && q.tag === undefined) return undefined
684
+ const out = new Set<string>()
685
+ for (const node of projection.nodes.values()) {
686
+ if (node.kind !== "team") continue
687
+ if (q.team !== undefined && node.id !== q.team && node.name !== q.team) continue
688
+ if (q.tag !== undefined && !(node.tags ?? []).includes(q.tag)) continue
689
+ out.add(node.id)
690
+ }
691
+ return out
692
+ }
693
+
694
+ function snapshot(projection: Projection, sources: GraphSources, userID: string, q: GraphQuery): GraphSnapshotView {
695
+ const keepSession = sessionFilter(projection, sources, userID, q)
696
+ const teams = teamsMatching(projection, q)
697
+ const machineIDs = q.machine !== undefined ? new Set(sources.machines(userID).filter((m) => m.id === q.machine || m.name === q.machine).map((m) => m.id)) : undefined
698
+ const sessions = new Set<string>()
699
+ for (const node of projection.nodes.values()) if (node.kind === "session" && (!keepSession || keepSession(node))) sessions.add(node.id)
700
+ const hostMachines = new Set([...sessions].map((id) => projection.nodes.get(id)!.machineID))
701
+ const seatedTeams = new Set([...sessions].flatMap((id) => projection.nodes.get(id)!.teamIDs ?? []))
702
+ const keep = (node: GraphNodeView): boolean => {
703
+ if (node.party === "app" || node.id === "hub" || node.id === "@me") return true
704
+ switch (node.kind) {
705
+ case "session":
706
+ return sessions.has(node.id)
707
+ case "machine":
708
+ if (machineIDs) return machineIDs.has(node.machineID!)
709
+ return !keepSession || hostMachines.has(node.machineID)
710
+ case "team":
711
+ if (teams) return teams.has(node.id)
712
+ return !keepSession || seatedTeams.has(node.id)
713
+ default:
714
+ return true
715
+ }
716
+ }
717
+ const kept = new Map([...projection.nodes].filter(([, node]) => keep(node)))
718
+ let edges = [...projection.edges.values()].filter((e) => kept.has(e.from) && kept.has(e.to) && (!q.kinds || q.kinds.has(e.kind)))
719
+ // App parties appear only with traffic.
720
+ const touched = new Set(edges.flatMap((e) => [e.from, e.to]))
721
+ for (const [id, node] of kept) if (node.party === "app" && !touched.has(id)) kept.delete(id)
722
+
723
+ // Limits: the hub and @me first, then the busiest nodes.
724
+ const activity = new Map<string, number>()
725
+ for (const e of edges) for (const id of [e.from, e.to]) activity.set(id, (activity.get(id) ?? 0) + e.count + e.denied)
726
+ let capped = false
727
+ let nodes = [...kept.values()]
728
+ if (nodes.length > q.limit) {
729
+ capped = true
730
+ nodes = nodes
731
+ .sort((a, b) => Math.min(rankOf(a), 2) - Math.min(rankOf(b), 2) || (activity.get(b.id) ?? 0) - (activity.get(a.id) ?? 0) || rankOf(a) - rankOf(b) || byID(a, b))
732
+ .slice(0, q.limit)
733
+ const ids = new Set(nodes.map((node) => node.id))
734
+ edges = edges.filter((e) => ids.has(e.from) && ids.has(e.to))
735
+ }
736
+ if (edges.length > GRAPH_LIMITS.edges) {
737
+ capped = true
738
+ edges = edges.sort((a, b) => b.lastAt - a.lastAt || byID(a, b)).slice(0, GRAPH_LIMITS.edges)
739
+ }
740
+ return {
741
+ v: 1,
742
+ at: sources.now(),
743
+ window: { since: q.since, until: q.until },
744
+ nodes: nodes.sort((a, b) => rankOf(a) - rankOf(b) || byID(a, b)),
745
+ edges: edges.sort(byID).map(edgeView),
746
+ retention: projection.retention,
747
+ capped,
748
+ hidden: { messages: projection.hidden },
749
+ }
750
+ }
751
+
752
+ /** The graph snapshot for `viewer` (see the file comment). */
753
+ export async function buildGraph(sources: GraphSources, userID: string, viewer: GraphViewer, q: GraphQuery): Promise<GraphSnapshotView> {
754
+ return snapshot(await project(sources, userID, viewer, q.since, q.until), sources, userID, q)
755
+ }
756
+
757
+ // ---- Detail -------------------------------------------------------------------------------------------------------
758
+
759
+ export interface DetailPage {
760
+ readonly messages: MessageSummary[]
761
+ /** For the next (older) page; absent on the last one. */
762
+ readonly cursor?: string
763
+ }
764
+
765
+ function summary(m: Message, decisionOf: ReadonlyMap<string, string>): MessageSummary {
766
+ const decisionID = decisionOf.get(m.id)
767
+ return {
768
+ id: m.id,
769
+ kind: m.kind,
770
+ state: m.state,
771
+ from: partyNode(m.from),
772
+ to: partyNode(m.recipient),
773
+ address: sanitizeLabel(m.to),
774
+ at: m.at,
775
+ hop: m.hop,
776
+ ...(m.generation !== undefined ? { generation: m.generation } : {}),
777
+ ...(m.replyTo !== undefined ? { replyTo: m.replyTo } : {}),
778
+ ...(m.answer ? { answer: { id: m.answer.id, at: m.answer.at } } : {}),
779
+ ...(m.expiresAt !== undefined ? { expiresAt: m.expiresAt } : {}),
780
+ ...(m.delivery ? { delivery: m.delivery } : {}),
781
+ ...(m.deliveredAt !== undefined ? { deliveredAt: m.deliveredAt } : {}),
782
+ ...(decisionID ? { decisionID } : {}),
783
+ }
784
+ }
785
+
786
+ const encodeCursor = (m: Message) => Buffer.from(JSON.stringify([m.at, m.id])).toString("base64url")
787
+
788
+ function decodeCursor(raw: string): [number, string] {
789
+ try {
790
+ const value = JSON.parse(Buffer.from(raw, "base64url").toString("utf8")) as unknown
791
+ if (Array.isArray(value) && value.length === 2 && Number.isSafeInteger(value[0]) && typeof value[1] === "string") return [value[0], value[1]]
792
+ } catch {
793
+ // falls through
794
+ }
795
+ throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
796
+ }
797
+
798
+ /** Newest first, a page at a time. */
799
+ function page(list: readonly Message[], params: URLSearchParams, decisionOf: ReadonlyMap<string, string>): DetailPage {
800
+ const limit = intParam(params, "limit") ?? GRAPH_DETAIL_PAGE.default
801
+ if (limit < 1 || limit > GRAPH_DETAIL_PAGE.max) throw invalid(`limit must be 1–${GRAPH_DETAIL_PAGE.max}`)
802
+ const raw = params.get("cursor")
803
+ const after = raw ? decodeCursor(raw) : undefined
804
+ const sorted = [...new Map(list.map((m) => [m.id, m])).values()]
805
+ .sort((a, b) => b.at - a.at || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
806
+ .filter((m) => !after || m.at < after[0] || (m.at === after[0] && m.id < after[1]))
807
+ const items = sorted.slice(0, limit)
808
+ return { messages: items.map((m) => summary(m, decisionOf)), ...(sorted.length > limit ? { cursor: encodeCursor(items.at(-1)!) } : {}) }
809
+ }
810
+
811
+ /** GET /graph/edges/:id: the edge and its messages (window from since/until). */
812
+ export async function edgeDetail(sources: GraphSources, userID: string, viewer: GraphViewer, id: string, params: URLSearchParams, now: number): Promise<DetailPage & { edge: GraphEdgeView }> {
813
+ const { since, until } = windowOf(params, now)
814
+ const projection = await project(sources, userID, viewer, since, until)
815
+ const acc = projection.edges.get(id)
816
+ if (!acc) throw new AgentError("not_found", "No such edge")
817
+ return { edge: edgeView(acc), ...page(acc.messages, params, projection.decisionOf) }
818
+ }
819
+
820
+ /** GET /graph/nodes/:id: the node and the messages it sent or received (window from since/until). */
821
+ export async function nodeDetail(sources: GraphSources, userID: string, viewer: GraphViewer, id: string, params: URLSearchParams, now: number): Promise<DetailPage & { node: GraphNodeView }> {
822
+ const { since, until } = windowOf(params, now)
823
+ const projection = await project(sources, userID, viewer, since, until)
824
+ const node = projection.nodes.get(id)
825
+ if (!node) throw new AgentError("not_found", "No such node")
826
+ const mine = projection.messages.filter((m) => partyNode(m.from) === id || partyNode(m.recipient) === id)
827
+ return { node, ...page(mine, params, projection.decisionOf) }
828
+ }
829
+
830
+ // ---- Live updates --------------------------------------------------------------------------------------------------
831
+
832
+ /** Journal event types that change the graph (besides the `team.` and `workspace.` families). */
833
+ export const GRAPH_INVALIDATING_EVENTS: ReadonlySet<string> = new Set([
834
+ "message.changed",
835
+ "session.added",
836
+ "session.removed",
837
+ "session.changed",
838
+ "session.archived",
839
+ "session.named",
840
+ "role.claimed",
841
+ "role.released",
842
+ "machine.changed",
843
+ "launch.changed",
844
+ ])
845
+
846
+ export interface GraphInvalidation {
847
+ readonly type: "graph.invalidated"
848
+ readonly at: number
849
+ /** The journal event type that caused it (`reset`: the reader lost its place). */
850
+ readonly cause: string
851
+ /** Node IDs it touches (may be empty). */
852
+ readonly nodes: readonly string[]
853
+ readonly messageID?: string
854
+ /** Take a new snapshot instead of patching. */
855
+ readonly resnapshot?: true
856
+ }
857
+
858
+ /** The compact `graph.invalidated` hint of a journal event (IDs only), or undefined when it does not change the graph. */
859
+ export function graphInvalidation(event: Pick<AgentEvent, "at" | "type" | "subject"> & Partial<Pick<AgentEvent, "id" | "data">>): GraphInvalidation | undefined {
860
+ if (event.type === "reset") return { type: "graph.invalidated", at: event.at, cause: "reset", nodes: [], resnapshot: true }
861
+ if (!GRAPH_INVALIDATING_EVENTS.has(event.type) && !event.type.startsWith("team.") && !event.type.startsWith("workspace.")) return undefined
862
+ const { senderID, sessionID, teamID, machineID, messageID } = event.subject
863
+ const nodes = [senderID, sessionID, teamID, machineID !== undefined ? machineNode(machineID) : undefined].filter((id): id is string => typeof id === "string" && id.length > 0)
864
+ return { type: "graph.invalidated", at: event.at, cause: event.type, nodes: [...new Set(nodes)].sort(), ...(messageID ? { messageID } : {}) }
865
+ }