@huynle/hub 0.7.3 → 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.
- package/CHANGELOG.md +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- 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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- 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
|
+
}
|