@haywoodmarx/agent-pager 0.1.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/LICENSE +21 -0
- package/README.md +63 -0
- package/dist/src/broker/address.d.ts +49 -0
- package/dist/src/broker/address.js +110 -0
- package/dist/src/broker/broker.d.ts +665 -0
- package/dist/src/broker/broker.js +2270 -0
- package/dist/src/broker/database.d.ts +81 -0
- package/dist/src/broker/database.js +69 -0
- package/dist/src/broker/operator-projection.d.ts +291 -0
- package/dist/src/broker/operator-projection.js +722 -0
- package/dist/src/broker/protocol.d.ts +237 -0
- package/dist/src/broker/protocol.js +311 -0
- package/dist/src/broker/schema.d.ts +94 -0
- package/dist/src/broker/schema.js +454 -0
- package/dist/src/broker/snapshot.d.ts +171 -0
- package/dist/src/broker/snapshot.js +78 -0
- package/dist/src/configuration-defaults.d.ts +64 -0
- package/dist/src/configuration-defaults.js +48 -0
- package/dist/src/configuration.d.ts +76 -0
- package/dist/src/configuration.js +156 -0
- package/dist/src/implementations/claude/environment.d.ts +8 -0
- package/dist/src/implementations/claude/environment.js +9 -0
- package/dist/src/implementations/claude/gateway-cli.d.ts +2 -0
- package/dist/src/implementations/claude/gateway-cli.js +136 -0
- package/dist/src/implementations/claude/gateway-identity.d.ts +134 -0
- package/dist/src/implementations/claude/gateway-identity.js +103 -0
- package/dist/src/implementations/claude/gateway.d.ts +148 -0
- package/dist/src/implementations/claude/gateway.js +236 -0
- package/dist/src/implementations/claude/instructions.d.ts +33 -0
- package/dist/src/implementations/claude/instructions.js +64 -0
- package/dist/src/implementations/claude/manifest.d.ts +33 -0
- package/dist/src/implementations/claude/manifest.js +108 -0
- package/dist/src/implementations/claude/plugin-directory.d.ts +80 -0
- package/dist/src/implementations/claude/plugin-directory.js +137 -0
- package/dist/src/implementations/claude/session-continuation.d.ts +25 -0
- package/dist/src/implementations/claude/session-continuation.js +125 -0
- package/dist/src/implementations/claude/session-identity.d.ts +25 -0
- package/dist/src/implementations/claude/session-identity.js +31 -0
- package/dist/src/implementations/claude/session-start-cli.d.ts +2 -0
- package/dist/src/implementations/claude/session-start-cli.js +16 -0
- package/dist/src/implementations/claude/socket-delivery.d.ts +52 -0
- package/dist/src/implementations/claude/socket-delivery.js +159 -0
- package/dist/src/implementations/model.d.ts +162 -0
- package/dist/src/implementations/model.js +1 -0
- package/dist/src/implementations/opencode/adapter.d.ts +71 -0
- package/dist/src/implementations/opencode/adapter.js +94 -0
- package/dist/src/implementations/opencode/environment.d.ts +8 -0
- package/dist/src/implementations/opencode/environment.js +9 -0
- package/dist/src/implementations/opencode/manifest.d.ts +30 -0
- package/dist/src/implementations/opencode/manifest.js +93 -0
- package/dist/src/implementations/opencode/plugin-api.d.ts +120 -0
- package/dist/src/implementations/opencode/plugin-api.js +1 -0
- package/dist/src/implementations/opencode/plugin.d.ts +38 -0
- package/dist/src/implementations/opencode/plugin.js +303 -0
- package/dist/src/implementations/opencode/self-close-tool.d.ts +9 -0
- package/dist/src/implementations/opencode/self-close-tool.js +31 -0
- package/dist/src/implementations/orca/terminal.d.ts +48 -0
- package/dist/src/implementations/orca/terminal.js +154 -0
- package/dist/src/implementations/pi/broker-client.d.ts +56 -0
- package/dist/src/implementations/pi/broker-client.js +156 -0
- package/dist/src/implementations/pi/environment.d.ts +8 -0
- package/dist/src/implementations/pi/environment.js +9 -0
- package/dist/src/implementations/pi/extension.d.ts +57 -0
- package/dist/src/implementations/pi/extension.js +253 -0
- package/dist/src/implementations/pi/host-slots.d.ts +18 -0
- package/dist/src/implementations/pi/host-slots.js +27 -0
- package/dist/src/implementations/pi/manifest.d.ts +11 -0
- package/dist/src/implementations/pi/manifest.js +61 -0
- package/dist/src/implementations/pi/self-close-tool.d.ts +7 -0
- package/dist/src/implementations/pi/self-close-tool.js +27 -0
- package/dist/src/implementations/registry.d.ts +47 -0
- package/dist/src/implementations/registry.js +157 -0
- package/dist/src/implementations/session-key.d.ts +13 -0
- package/dist/src/implementations/session-key.js +19 -0
- package/dist/src/index.d.ts +27 -0
- package/dist/src/index.js +26 -0
- package/dist/src/launch/broker-lock.d.ts +257 -0
- package/dist/src/launch/broker-lock.js +644 -0
- package/dist/src/launch/cli.d.ts +37 -0
- package/dist/src/launch/cli.js +317 -0
- package/dist/src/launch/environment.d.ts +45 -0
- package/dist/src/launch/environment.js +48 -0
- package/dist/src/launch/launch.d.ts +235 -0
- package/dist/src/launch/launch.js +223 -0
- package/dist/src/launch/on-demand.d.ts +202 -0
- package/dist/src/launch/on-demand.js +435 -0
- package/dist/src/launch/runtime-composition.d.ts +88 -0
- package/dist/src/launch/runtime-composition.js +253 -0
- package/dist/src/launch/runtime.d.ts +79 -0
- package/dist/src/launch/runtime.js +196 -0
- package/dist/src/launch/service-system.d.ts +11 -0
- package/dist/src/launch/service-system.js +39 -0
- package/dist/src/launch/service.d.ts +173 -0
- package/dist/src/launch/service.js +492 -0
- package/dist/src/launch/skill.d.ts +57 -0
- package/dist/src/launch/skill.js +81 -0
- package/dist/src/launch/terminal.d.ts +26 -0
- package/dist/src/launch/terminal.js +43 -0
- package/dist/src/launch/workspace-branch.d.ts +10 -0
- package/dist/src/launch/workspace-branch.js +29 -0
- package/dist/src/messaging/delivery-client.d.ts +144 -0
- package/dist/src/messaging/delivery-client.js +261 -0
- package/dist/src/messaging/delivery.d.ts +75 -0
- package/dist/src/messaging/delivery.js +22 -0
- package/dist/src/messaging/inbox-socket.d.ts +98 -0
- package/dist/src/messaging/inbox-socket.js +278 -0
- package/dist/src/messaging/mcp.d.ts +53 -0
- package/dist/src/messaging/mcp.js +501 -0
- package/dist/src/messaging/notifications.d.ts +231 -0
- package/dist/src/messaging/notifications.js +489 -0
- package/dist/src/messaging/push-delivery.d.ts +77 -0
- package/dist/src/messaging/push-delivery.js +187 -0
- package/dist/src/messaging/tools.d.ts +11 -0
- package/dist/src/messaging/tools.js +102 -0
- package/dist/src/messaging/wake.d.ts +23 -0
- package/dist/src/messaging/wake.js +39 -0
- package/dist/src/operator/agent-projection.d.ts +102 -0
- package/dist/src/operator/agent-projection.js +249 -0
- package/dist/src/operator/commands.d.ts +50 -0
- package/dist/src/operator/commands.js +199 -0
- package/dist/src/operator/diagnostics.d.ts +21 -0
- package/dist/src/operator/diagnostics.js +22 -0
- package/dist/src/operator/protocol.d.ts +511 -0
- package/dist/src/operator/protocol.js +49 -0
- package/dist/src/operator/repository-system.d.ts +14 -0
- package/dist/src/operator/repository-system.js +150 -0
- package/dist/src/operator/repository.d.ts +79 -0
- package/dist/src/operator/repository.js +72 -0
- package/dist/src/operator/traffic-projection.d.ts +19 -0
- package/dist/src/operator/traffic-projection.js +229 -0
- package/dist/src/operator/transport.d.ts +87 -0
- package/dist/src/operator/transport.js +549 -0
- package/dist/src/operator/update-store.d.ts +53 -0
- package/dist/src/operator/update-store.js +162 -0
- package/dist/src/operator/updates.d.ts +94 -0
- package/dist/src/operator/updates.js +280 -0
- package/dist/src/operator/validation.d.ts +114 -0
- package/dist/src/operator/validation.js +916 -0
- package/dist/src/shared/guards.d.ts +59 -0
- package/dist/src/shared/guards.js +119 -0
- package/dist/src/shared/http.d.ts +30 -0
- package/dist/src/shared/http.js +50 -0
- package/dist/src/shared/row.d.ts +2 -0
- package/dist/src/shared/row.js +1 -0
- package/dist/src/shared/shell.d.ts +6 -0
- package/dist/src/shared/shell.js +8 -0
- package/dist/src/tui/actions.d.ts +166 -0
- package/dist/src/tui/actions.js +253 -0
- package/dist/src/tui/agent-tree.d.ts +125 -0
- package/dist/src/tui/agent-tree.js +663 -0
- package/dist/src/tui/app.d.ts +38 -0
- package/dist/src/tui/app.js +378 -0
- package/dist/src/tui/chrome.d.ts +86 -0
- package/dist/src/tui/chrome.js +85 -0
- package/dist/src/tui/client.d.ts +41 -0
- package/dist/src/tui/client.js +279 -0
- package/dist/src/tui/composition.d.ts +41 -0
- package/dist/src/tui/composition.js +146 -0
- package/dist/src/tui/message-views.d.ts +94 -0
- package/dist/src/tui/message-views.js +515 -0
- package/dist/src/tui/model.d.ts +414 -0
- package/dist/src/tui/model.js +758 -0
- package/dist/src/tui/presentation.d.ts +350 -0
- package/dist/src/tui/presentation.js +541 -0
- package/dist/src/tui/production.d.ts +79 -0
- package/dist/src/tui/production.js +253 -0
- package/dist/src/tui/run-terminal.d.ts +32 -0
- package/dist/src/tui/run-terminal.js +54 -0
- package/dist/src/tui/runtime.d.ts +113 -0
- package/dist/src/tui/runtime.js +189 -0
- package/dist/src/tui/theme.d.ts +49 -0
- package/dist/src/tui/theme.js +55 -0
- package/dist/src/tui/transport.d.ts +31 -0
- package/dist/src/tui/transport.js +255 -0
- package/dist/src/version.d.ts +11 -0
- package/dist/src/version.js +52 -0
- package/package.json +142 -0
- package/skills/pager/SKILL.md +112 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
import { BROKER_OPERATOR_TRAFFIC_LIMIT, decodeMessageBody, isAgentId, isOpaqueId, LIFECYCLE_EVENT_STATE, ProtocolError, } from "./protocol.js";
|
|
2
|
+
import { normalizeAlias } from "./address.js";
|
|
3
|
+
import { AGENT_STATES, MESSAGE_EVENT_SOURCES, MESSAGE_EVENT_TONES, MESSAGE_STATES, MESSAGE_UNORDERABLE_POSITION, orderablePosition, PING_UNORDERABLE_POSITION, unorderablePosition, } from "./schema.js";
|
|
4
|
+
/** Event types that observe a delivery state, in the order the vocabulary declares them. */
|
|
5
|
+
const STATE_OBSERVING_EVENT_TYPES = Object.entries(LIFECYCLE_EVENT_STATE)
|
|
6
|
+
.filter(([, state]) => state !== null)
|
|
7
|
+
.map(([type]) => type);
|
|
8
|
+
const RETAINED_AGENT_PREDICATE = `not (
|
|
9
|
+
agents.state = 'reaped'
|
|
10
|
+
and agents.registration_ephemeral = 1
|
|
11
|
+
and not exists (select 1 from messages where sender_id = agents.id or recipient_id = agents.id)
|
|
12
|
+
and not exists (select 1 from operator_ping_audits where target_agent_id = agents.id)
|
|
13
|
+
)`;
|
|
14
|
+
/**
|
|
15
|
+
* Caps how many values one statement binds in an `in (...)` list.
|
|
16
|
+
*
|
|
17
|
+
* bun:sqlite has not been observed to reject an oversize parameter list, so this is not working
|
|
18
|
+
* around a measured failure; it bounds how large one statement's parameter list grows for a
|
|
19
|
+
* projection over an otherwise-unbounded row count, splitting into runs instead.
|
|
20
|
+
*/
|
|
21
|
+
const SQL_PARAMETER_LIMIT = 500;
|
|
22
|
+
/** Bounds how recent a delivery failure must be for the broker to report itself degraded. */
|
|
23
|
+
const RECENT_FAILURE_WINDOW_MS = 30_000;
|
|
24
|
+
/** Caps how many operational events the broker retains in memory, oldest dropped first. */
|
|
25
|
+
const OPERATIONAL_EVENT_LIMIT = 4_096;
|
|
26
|
+
/**
|
|
27
|
+
* Read the stored columns a delivery projection narrows, reporting a row that cannot supply them.
|
|
28
|
+
*
|
|
29
|
+
* Never throws over a row: this read feeds the operator update stream, which a throw leaves
|
|
30
|
+
* permanently unavailable.
|
|
31
|
+
* @param message - Durable `messages` row being projected.
|
|
32
|
+
* @returns - The narrowed columns, or the ones that could not be read.
|
|
33
|
+
*/
|
|
34
|
+
function readableColumns(message) {
|
|
35
|
+
const unreadable = [
|
|
36
|
+
MESSAGE_STATES.some((candidate) => candidate === message.state) ? null : "state",
|
|
37
|
+
isAgentId(message.sender_id) ? null : "sender_id",
|
|
38
|
+
isAgentId(message.recipient_id) ? null : "recipient_id",
|
|
39
|
+
isTrafficPosition(message.sequence) ? null : "sequence",
|
|
40
|
+
typeof message.body === "string" ? null : "body",
|
|
41
|
+
typeof message.body_size === "number" ? null : "body_size",
|
|
42
|
+
typeof message.content_type === "string" ? null : "content_type",
|
|
43
|
+
].filter((column) => column !== null);
|
|
44
|
+
if (unreadable.length > 0)
|
|
45
|
+
return { unreadable };
|
|
46
|
+
return {
|
|
47
|
+
sequence: message.sequence,
|
|
48
|
+
state: readStoredEnum(message.state, MESSAGE_STATES, "messages.state"),
|
|
49
|
+
senderAgentId: readStoredAgentId(message.sender_id, "messages.sender_id"),
|
|
50
|
+
recipientAgentId: readStoredAgentId(message.recipient_id, "messages.recipient_id"),
|
|
51
|
+
encodedBody: message.body,
|
|
52
|
+
bodySizeBytes: message.body_size,
|
|
53
|
+
contentType: message.content_type,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Read the stored columns a ping audit narrows, reporting a row that cannot supply them.
|
|
58
|
+
*
|
|
59
|
+
* Never throws over a row: the ping and message halves share one window on one stream.
|
|
60
|
+
* @param ping - Durable `operator_ping_audits` row being projected.
|
|
61
|
+
* @returns - The narrowed columns, or the ones that could not be read.
|
|
62
|
+
*/
|
|
63
|
+
function readablePingColumns(ping) {
|
|
64
|
+
const unreadable = [
|
|
65
|
+
ping.actor === "operator" ? null : "actor",
|
|
66
|
+
isAgentId(ping.target_agent_id) ? null : "target_agent_id",
|
|
67
|
+
isTrafficPosition(ping.traffic_position) ? null : "traffic_position",
|
|
68
|
+
].filter((column) => column !== null);
|
|
69
|
+
if (unreadable.length > 0)
|
|
70
|
+
return { unreadable };
|
|
71
|
+
return {
|
|
72
|
+
actor: "operator",
|
|
73
|
+
targetAgentId: readStoredAgentId(ping.target_agent_id, "operator_ping_audits.target_agent_id"),
|
|
74
|
+
trafficPosition: ping.traffic_position,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Report whether a stored column can serve as a durable traffic position.
|
|
79
|
+
* @param value - Column value as the driver returned it.
|
|
80
|
+
* @returns - True when the value is a positive safe integer.
|
|
81
|
+
*/
|
|
82
|
+
function isTrafficPosition(value) {
|
|
83
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read the stored columns an agent projection narrows, reporting a row that cannot supply them.
|
|
87
|
+
*
|
|
88
|
+
* Never throws over a row: this read feeds the operator update stream.
|
|
89
|
+
* @param agent - Durable `agents` row being projected.
|
|
90
|
+
* @returns - The narrowed columns, or the ones that could not be read.
|
|
91
|
+
*/
|
|
92
|
+
function readableAgentColumns(agent) {
|
|
93
|
+
const unreadable = [
|
|
94
|
+
isAgentId(agent.id) ? null : "id",
|
|
95
|
+
AGENT_STATES.some((candidate) => candidate === agent.state) ? null : "state",
|
|
96
|
+
].filter((column) => column !== null);
|
|
97
|
+
if (unreadable.length > 0)
|
|
98
|
+
return { unreadable };
|
|
99
|
+
return {
|
|
100
|
+
id: readStoredAgentId(agent.id, "agents.id"),
|
|
101
|
+
state: readStoredEnum(agent.state, AGENT_STATES, "agents.state"),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Identify what one operational event observes, so the same observation is recognised again.
|
|
106
|
+
*
|
|
107
|
+
* Two identical sheds share a key, which is why the ring's index counts rather than flags.
|
|
108
|
+
* @param event - Operational event being recorded.
|
|
109
|
+
* @returns - Key equal for two events observing the same thing.
|
|
110
|
+
*/
|
|
111
|
+
function ringKey(event) {
|
|
112
|
+
if (event.type === "message_shed")
|
|
113
|
+
return JSON.stringify(event);
|
|
114
|
+
if (event.type === "stored_row_skipped")
|
|
115
|
+
return `${event.type}\u0000${event.column}\u0000${event.rowKey}`;
|
|
116
|
+
if (event.type === "skips_truncated")
|
|
117
|
+
return `${event.type}\u0000${event.table}\u0000${String(event.named)}\u0000${String(event.total)}`;
|
|
118
|
+
const rowId = event.type === "agent_row_skipped"
|
|
119
|
+
? event.agentId
|
|
120
|
+
: event.type === "ping_row_skipped"
|
|
121
|
+
? event.auditId
|
|
122
|
+
: event.messageId;
|
|
123
|
+
return `${event.type}\u0000${rowId}\u0000${event.unreadable.join("\u0001")}`;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Name a skipped row by its durable id, or by its rowid when the id is not text.
|
|
127
|
+
* @param id - The row's id column as the driver returned it.
|
|
128
|
+
* @param rowid - The row's rowid.
|
|
129
|
+
* @returns - A name that tells two such rows apart.
|
|
130
|
+
*/
|
|
131
|
+
function skippedRowName(id, rowid) {
|
|
132
|
+
return typeof id === "string" ? id : `rowid ${String(rowid)}`;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Copy one operational event deeply enough that a caller cannot rewrite retained history.
|
|
136
|
+
*
|
|
137
|
+
* A spread alone leaves the event's own array shared with the copy the projection keeps.
|
|
138
|
+
* @param event - Retained event being handed to a caller.
|
|
139
|
+
* @returns - Copy that shares no mutable structure with the retained one.
|
|
140
|
+
*/
|
|
141
|
+
function cloneOperationalEvent(event) {
|
|
142
|
+
return event.type === "message_shed" || event.type === "stored_row_skipped" || event.type === "skips_truncated"
|
|
143
|
+
? { ...event }
|
|
144
|
+
: { ...event, unreadable: [...event.unreadable] };
|
|
145
|
+
}
|
|
146
|
+
function chunked(values, size) {
|
|
147
|
+
const chunks = [];
|
|
148
|
+
for (let index = 0; index < values.length; index += size)
|
|
149
|
+
chunks.push(values.slice(index, index + size));
|
|
150
|
+
return chunks;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Read one stored column against the vocabulary its `check` constraint admits.
|
|
154
|
+
*
|
|
155
|
+
* Throws; a caller that cannot afford a throw must not call it.
|
|
156
|
+
* @param value - Column value as the driver returned it.
|
|
157
|
+
* @param allowed - Vocabulary the column's constraint admits.
|
|
158
|
+
* @param column - Column named in the rejection, qualified by its table.
|
|
159
|
+
* @returns - The value, narrowed to the vocabulary.
|
|
160
|
+
*/
|
|
161
|
+
export function readStoredEnum(value, allowed, column) {
|
|
162
|
+
const known = allowed.find((candidate) => candidate === value);
|
|
163
|
+
if (known === undefined)
|
|
164
|
+
throw new ProtocolError("validation_failed", `Stored ${column} is corrupt`);
|
|
165
|
+
return known;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Read a stored agent identifier, refusing one this build could not have written.
|
|
169
|
+
* @param value - Column value as the driver returned it.
|
|
170
|
+
* @param column - Column named in the rejection.
|
|
171
|
+
* @returns - The value, narrowed to an agent identity.
|
|
172
|
+
*/
|
|
173
|
+
export function readStoredAgentId(value, column) {
|
|
174
|
+
if (!isAgentId(value))
|
|
175
|
+
throw new ProtocolError("validation_failed", `Stored ${column} is corrupt`);
|
|
176
|
+
return value;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Assert an untrusted value has the shape of a durable operator-facing identity.
|
|
180
|
+
* @param value - Untrusted candidate identity.
|
|
181
|
+
* @param label - Names the identity kind for the rejection message.
|
|
182
|
+
* @returns - The value, narrowed to a validated durable identity string.
|
|
183
|
+
*/
|
|
184
|
+
export function assertDurableOperatorId(value, label) {
|
|
185
|
+
if (!isOpaqueId(value))
|
|
186
|
+
throw new ProtocolError("validation_failed", `Invalid operator ${label} identity`);
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
function assertDurableAgentId(value) {
|
|
190
|
+
if (!isAgentId(value))
|
|
191
|
+
throw new ProtocolError("validation_failed", "Operator command target is corrupt");
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Reads durable operator-facing state over a broker connection: traffic records, command
|
|
195
|
+
* idempotency, and snapshot projections. Holds the in-memory operational event log, which only this
|
|
196
|
+
* projection reads.
|
|
197
|
+
*/
|
|
198
|
+
export class BrokerOperatorProjection {
|
|
199
|
+
#db;
|
|
200
|
+
#now;
|
|
201
|
+
#operationalEvents = [];
|
|
202
|
+
/**
|
|
203
|
+
* Counts of what the ring currently holds, by observation.
|
|
204
|
+
*
|
|
205
|
+
* Through `#recordOnce`, an observation is recorded once while the ring holds it, and again once a
|
|
206
|
+
* later event has evicted it.
|
|
207
|
+
*/
|
|
208
|
+
#ringKeys = new Map();
|
|
209
|
+
/**
|
|
210
|
+
* Bind operator projection reads to one connection.
|
|
211
|
+
* @param db - Connection this projection reads.
|
|
212
|
+
* @param now - Clock shared with the rest of the broker.
|
|
213
|
+
*/
|
|
214
|
+
constructor(db, now) {
|
|
215
|
+
this.#db = db;
|
|
216
|
+
this.#now = now;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Record a shed-message operational event for later diagnostic reporting.
|
|
220
|
+
* @param event - Operational event describing the shed policy and its target.
|
|
221
|
+
*/
|
|
222
|
+
recordShed(event) {
|
|
223
|
+
this.#record(event);
|
|
224
|
+
}
|
|
225
|
+
#record(event, key = ringKey(event)) {
|
|
226
|
+
this.#operationalEvents.push({ event, key });
|
|
227
|
+
this.#ringKeys.set(key, (this.#ringKeys.get(key) ?? 0) + 1);
|
|
228
|
+
if (this.#operationalEvents.length > OPERATIONAL_EVENT_LIMIT) {
|
|
229
|
+
const evicted = this.#operationalEvents.shift();
|
|
230
|
+
if (evicted !== undefined) {
|
|
231
|
+
const held = (this.#ringKeys.get(evicted.key) ?? 1) - 1;
|
|
232
|
+
if (held > 0)
|
|
233
|
+
this.#ringKeys.set(evicted.key, held);
|
|
234
|
+
else
|
|
235
|
+
this.#ringKeys.delete(evicted.key);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Record one durable row whose stored column the broker could not read, unless the ring holds it.
|
|
241
|
+
* @param event - Row and column the caller could not read.
|
|
242
|
+
* @returns - True when this call recorded it, so the caller knows whether to also say so.
|
|
243
|
+
*/
|
|
244
|
+
recordStoredRowSkip(event) {
|
|
245
|
+
return this.#recordOnce(event);
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Record an observation the ring is not already holding.
|
|
249
|
+
* @param event - Observation to record if it is not already in the ring.
|
|
250
|
+
* @param key - Ring identity to hold it under, when the event's own fields cannot supply one.
|
|
251
|
+
* @returns - True when this call recorded it.
|
|
252
|
+
*/
|
|
253
|
+
#recordOnce(event, key = ringKey(event)) {
|
|
254
|
+
if (this.#ringKeys.has(key))
|
|
255
|
+
return false;
|
|
256
|
+
this.#record(event, key);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Return operational events recorded since the broker started.
|
|
261
|
+
* @returns - Copy of every retained operational event, oldest first.
|
|
262
|
+
*/
|
|
263
|
+
operationalEvents() {
|
|
264
|
+
return this.#operationalEvents.map((held) => cloneOperationalEvent(held.event));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Read bounded durable traffic without claiming, acknowledging, or changing retained bodies.
|
|
268
|
+
* @returns Traffic source rows in their single broker-owned durable order.
|
|
269
|
+
*/
|
|
270
|
+
operatorTrafficRecords() {
|
|
271
|
+
const messages = this.#db
|
|
272
|
+
.prepare(`select rowid, id, team_id, workspace_id, sender_id, recipient_id, addressed_to, address_outcome, subject, type, body, body_size, content_type, state, created_at, sequence from messages where ${orderablePosition("sequence")} order by sequence desc limit ?`)
|
|
273
|
+
.all(BROKER_OPERATOR_TRAFFIC_LIMIT);
|
|
274
|
+
const pings = this.#db
|
|
275
|
+
.prepare(`select rowid, audit_id, control_id, target_agent_id, actor, accepted_at, traffic_position from operator_ping_audits where ${orderablePosition("traffic_position")} order by traffic_position desc limit ?`)
|
|
276
|
+
.all(BROKER_OPERATOR_TRAFFIC_LIMIT);
|
|
277
|
+
const window = [
|
|
278
|
+
...messages.map((row) => ({ kind: "message", row, position: Number(row.sequence) })),
|
|
279
|
+
...pings.map((row) => ({ kind: "ping", row, position: Number(row.traffic_position) })),
|
|
280
|
+
]
|
|
281
|
+
.sort((left, right) => right.position - left.position)
|
|
282
|
+
.slice(0, BROKER_OPERATOR_TRAFFIC_LIMIT)
|
|
283
|
+
.sort((left, right) => left.position - right.position);
|
|
284
|
+
const lifecycleByMessage = this.#trafficEventsForWindow(window.filter((entry) => entry.kind === "message").map((entry) => String(entry.row.id)));
|
|
285
|
+
const skips = [];
|
|
286
|
+
const records = window.flatMap((entry) => {
|
|
287
|
+
if (entry.kind === "ping")
|
|
288
|
+
return [];
|
|
289
|
+
const message = entry.row;
|
|
290
|
+
const stored = readableColumns(message);
|
|
291
|
+
if ("unreadable" in stored) {
|
|
292
|
+
const skip = {
|
|
293
|
+
type: "traffic_row_skipped",
|
|
294
|
+
messageId: skippedRowName(message.id, message.rowid),
|
|
295
|
+
unreadable: stored.unreadable,
|
|
296
|
+
};
|
|
297
|
+
// Copied on the way out for the same reason operationalEvents() copies: what the ring keeps
|
|
298
|
+
// is the projection's, and this exit hands the caller the same object the ring is given.
|
|
299
|
+
skips.push(cloneOperationalEvent(skip));
|
|
300
|
+
// Held under the rowid: `messages.id` is not among the columns checked here.
|
|
301
|
+
this.#recordOnce(skip, `traffic_row_skipped\u0000${String(message.rowid)}`);
|
|
302
|
+
return [];
|
|
303
|
+
}
|
|
304
|
+
const lifecycle = lifecycleByMessage.get(String(message.id)) ?? [];
|
|
305
|
+
return [
|
|
306
|
+
{
|
|
307
|
+
kind: "accepted_delivery",
|
|
308
|
+
messageId: String(message.id),
|
|
309
|
+
trafficPosition: stored.sequence,
|
|
310
|
+
teamId: String(message.team_id),
|
|
311
|
+
workspaceId: String(message.workspace_id),
|
|
312
|
+
acceptedAt: Number(message.created_at),
|
|
313
|
+
subject: String(message.subject),
|
|
314
|
+
type: String(message.type),
|
|
315
|
+
...stored,
|
|
316
|
+
lifecycle,
|
|
317
|
+
},
|
|
318
|
+
];
|
|
319
|
+
});
|
|
320
|
+
records.push(...window.flatMap((entry) => {
|
|
321
|
+
if (entry.kind === "message")
|
|
322
|
+
return [];
|
|
323
|
+
const ping = entry.row;
|
|
324
|
+
const stored = readablePingColumns(ping);
|
|
325
|
+
if ("unreadable" in stored) {
|
|
326
|
+
const skip = {
|
|
327
|
+
type: "ping_row_skipped",
|
|
328
|
+
auditId: skippedRowName(ping.audit_id, ping.rowid),
|
|
329
|
+
unreadable: stored.unreadable,
|
|
330
|
+
};
|
|
331
|
+
skips.push(cloneOperationalEvent(skip));
|
|
332
|
+
this.#recordOnce(skip, `ping_row_skipped\u0000${String(ping.rowid)}`);
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
return [
|
|
336
|
+
{
|
|
337
|
+
kind: "ping_audit",
|
|
338
|
+
auditId: String(ping.audit_id),
|
|
339
|
+
trafficPosition: stored.trafficPosition,
|
|
340
|
+
controlId: String(ping.control_id),
|
|
341
|
+
targetAgentId: stored.targetAgentId,
|
|
342
|
+
actor: stored.actor,
|
|
343
|
+
acceptedAt: Number(ping.accepted_at),
|
|
344
|
+
},
|
|
345
|
+
];
|
|
346
|
+
}));
|
|
347
|
+
this.#nameUnorderable(MESSAGE_UNORDERABLE_POSITION, (rowKey, id) => {
|
|
348
|
+
const skip = { type: "traffic_row_skipped", messageId: id, unreadable: ["sequence"] };
|
|
349
|
+
skips.push(cloneOperationalEvent(skip));
|
|
350
|
+
this.#recordOnce(skip, `traffic_row_skipped\u0000${rowKey}`);
|
|
351
|
+
}, (truncated) => skips.push(truncated));
|
|
352
|
+
this.#nameUnorderable(PING_UNORDERABLE_POSITION, (rowKey, id) => {
|
|
353
|
+
const skip = {
|
|
354
|
+
type: "ping_row_skipped",
|
|
355
|
+
auditId: id,
|
|
356
|
+
unreadable: ["traffic_position"],
|
|
357
|
+
};
|
|
358
|
+
skips.push(cloneOperationalEvent(skip));
|
|
359
|
+
this.#recordOnce(skip, `ping_row_skipped\u0000${rowKey}`);
|
|
360
|
+
}, (truncated) => skips.push(truncated));
|
|
361
|
+
records.sort((left, right) => left.trafficPosition - right.trafficPosition);
|
|
362
|
+
return { records, skips };
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Return a prior operator command acceptance for idempotent request handling.
|
|
366
|
+
* @param commandId - Opaque command correlation supplied by the operator boundary.
|
|
367
|
+
* @returns Stored acceptance, or null when this command has not been accepted.
|
|
368
|
+
*/
|
|
369
|
+
operatorCommand(commandId) {
|
|
370
|
+
assertDurableOperatorId(commandId, "command");
|
|
371
|
+
const row = this.#db.prepare("select * from operator_commands where command_id = ?").get(commandId);
|
|
372
|
+
if (!row)
|
|
373
|
+
return null;
|
|
374
|
+
assertDurableOperatorId(row.command_id, "command");
|
|
375
|
+
const acceptedAt = Number(row.accepted_at);
|
|
376
|
+
if (!Number.isSafeInteger(acceptedAt) || acceptedAt < 0) {
|
|
377
|
+
throw new ProtocolError("validation_failed", "Operator command is corrupt");
|
|
378
|
+
}
|
|
379
|
+
if (row.type === "ping_agent") {
|
|
380
|
+
if (typeof row.control_id !== "string")
|
|
381
|
+
throw new ProtocolError("validation_failed", "Ping command is corrupt");
|
|
382
|
+
assertDurableOperatorId(row.control_id, "control");
|
|
383
|
+
assertDurableAgentId(row.target_id);
|
|
384
|
+
return {
|
|
385
|
+
type: "ping_agent",
|
|
386
|
+
commandId: String(row.command_id),
|
|
387
|
+
targetId: row.target_id,
|
|
388
|
+
acceptedAt,
|
|
389
|
+
controlId: row.control_id,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (row.type === "archive_agent") {
|
|
393
|
+
assertDurableAgentId(row.target_id);
|
|
394
|
+
return { type: "archive_agent", commandId: String(row.command_id), targetId: row.target_id, acceptedAt };
|
|
395
|
+
}
|
|
396
|
+
if (row.type !== "expire_message") {
|
|
397
|
+
throw new ProtocolError("validation_failed", "Operator command is corrupt");
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
type: "expire_message",
|
|
401
|
+
commandId: String(row.command_id),
|
|
402
|
+
targetId: assertDurableOperatorId(row.target_id, "message"),
|
|
403
|
+
acceptedAt,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Read a durable message body without claiming or changing it.
|
|
408
|
+
* @param messageId - Durable message whose retained body is being read.
|
|
409
|
+
* @returns - Decoded body, its raw stored copy, and its retained size and representation.
|
|
410
|
+
*/
|
|
411
|
+
readMessageBody(messageId) {
|
|
412
|
+
const row = this.#db.prepare("select body, body_size, content_type from messages where id = ?").get(messageId);
|
|
413
|
+
if (!row || typeof row.body !== "string")
|
|
414
|
+
throw new ProtocolError("validation_failed", "Unknown message");
|
|
415
|
+
let body;
|
|
416
|
+
try {
|
|
417
|
+
body = decodeMessageBody(row.body);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
throw new ProtocolError("validation_failed", "Stored message body is not valid JSON");
|
|
421
|
+
}
|
|
422
|
+
const contentType = typeof row.content_type === "string" ? row.content_type : "json";
|
|
423
|
+
return {
|
|
424
|
+
messageId,
|
|
425
|
+
body,
|
|
426
|
+
rawCopy: contentType === "text" && typeof body === "string" ? body : row.body,
|
|
427
|
+
sizeBytes: Number(row.body_size ?? Buffer.byteLength(row.body)),
|
|
428
|
+
contentType,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Return the agent-derived state an operator projection reads, without any delivery.
|
|
433
|
+
* @param workspaceId - Restricts the read to one workspace, or reads every workspace.
|
|
434
|
+
* @returns - Agent-derived snapshot sections only.
|
|
435
|
+
*/
|
|
436
|
+
readAgentSource(workspaceId) {
|
|
437
|
+
const now = this.#now();
|
|
438
|
+
const activeWorkspace = workspaceId ?? null;
|
|
439
|
+
const agents = this.#db
|
|
440
|
+
.prepare(activeWorkspace
|
|
441
|
+
? `select rowid, * from agents where workspace_id = ? and ${RETAINED_AGENT_PREDICATE} order by created_at, id`
|
|
442
|
+
: `select rowid, * from agents where ${RETAINED_AGENT_PREDICATE} order by created_at, id`)
|
|
443
|
+
.all(...(activeWorkspace ? [activeWorkspace] : []));
|
|
444
|
+
const aliasHistory = this.#db.prepare("select agent_id, alias from alias_history order by id").all();
|
|
445
|
+
const previousAliasesByAgent = new Map();
|
|
446
|
+
for (const row of aliasHistory) {
|
|
447
|
+
const agentId = String(row.agent_id);
|
|
448
|
+
const aliases = previousAliasesByAgent.get(agentId) ?? [];
|
|
449
|
+
aliases.push(String(row.alias));
|
|
450
|
+
previousAliasesByAgent.set(agentId, aliases);
|
|
451
|
+
}
|
|
452
|
+
const sessions = this.#db
|
|
453
|
+
.prepare("select agent_id, expires_at, active, created_at from sessions order by agent_id, created_at")
|
|
454
|
+
.all();
|
|
455
|
+
const sessionsByAgent = new Map();
|
|
456
|
+
for (const row of sessions) {
|
|
457
|
+
if (Number(row.active) !== 1)
|
|
458
|
+
continue;
|
|
459
|
+
const agentId = String(row.agent_id);
|
|
460
|
+
const current = sessionsByAgent.get(agentId);
|
|
461
|
+
const candidateAttached = Number(row.expires_at) > now;
|
|
462
|
+
const currentAttached = current !== undefined && Number(current.expires_at) > now;
|
|
463
|
+
if (current === undefined ||
|
|
464
|
+
(candidateAttached && !currentAttached) ||
|
|
465
|
+
(candidateAttached === currentAttached && Number(row.created_at) > Number(current.created_at))) {
|
|
466
|
+
sessionsByAgent.set(agentId, row);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
const projected = agents.flatMap((agent) => {
|
|
470
|
+
const stored = readableAgentColumns(agent);
|
|
471
|
+
if ("unreadable" in stored) {
|
|
472
|
+
// Held under the rowid: `agents.id` may be the column that failed.
|
|
473
|
+
this.#recordOnce({ type: "agent_row_skipped", agentId: skippedRowName(agent.id, agent.rowid), unreadable: stored.unreadable }, `agent_row_skipped\u0000${String(agent.rowid)}`);
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
return [{ agent, stored }];
|
|
477
|
+
});
|
|
478
|
+
return {
|
|
479
|
+
workspaces: this.#workspaceSummaries(),
|
|
480
|
+
agents: projected.map(({ agent, stored }) => {
|
|
481
|
+
return {
|
|
482
|
+
id: stored.id,
|
|
483
|
+
createdAt: Number(agent.created_at),
|
|
484
|
+
teamId: String(agent.team_id),
|
|
485
|
+
workspaceId: String(agent.workspace_id),
|
|
486
|
+
parentId: typeof agent.parent_id === "string" ? agent.parent_id : null,
|
|
487
|
+
state: stored.state,
|
|
488
|
+
implementation: typeof agent.implementation_id === "string"
|
|
489
|
+
? {
|
|
490
|
+
id: agent.implementation_id,
|
|
491
|
+
version: typeof agent.implementation_version === "string"
|
|
492
|
+
? { known: true, value: agent.implementation_version }
|
|
493
|
+
: { known: false },
|
|
494
|
+
}
|
|
495
|
+
: { id: null, version: { known: false } },
|
|
496
|
+
alias: normalizeAlias(String(agent.registration_name)),
|
|
497
|
+
previousAliases: previousAliasesByAgent.get(String(agent.id)) ?? [],
|
|
498
|
+
archivedAt: typeof agent.archived_at === "number" ? agent.archived_at : null,
|
|
499
|
+
};
|
|
500
|
+
}),
|
|
501
|
+
// The same rows the projection above kept: an agent it left out is not a connection to report.
|
|
502
|
+
connections: projected.map(({ agent }) => {
|
|
503
|
+
const session = sessionsByAgent.get(String(agent.id));
|
|
504
|
+
return {
|
|
505
|
+
agentId: String(agent.id),
|
|
506
|
+
state: session && Number(session.expires_at) > now ? "connected" : "disconnected",
|
|
507
|
+
expiresAt: session ? Number(session.expires_at) : null,
|
|
508
|
+
lastSeenAt: session ? Number(session.created_at) : null,
|
|
509
|
+
};
|
|
510
|
+
}),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Read the complete broker snapshot, optionally scoped to one workspace.
|
|
515
|
+
*
|
|
516
|
+
* Never writes, so claim state is as the last `Broker.expireClaims` left it.
|
|
517
|
+
* @param workspaceId - Restricts the read to one workspace's agents and the messages they sent or
|
|
518
|
+
* received, or reads every workspace.
|
|
519
|
+
* @param sequence - Snapshot sequence stamp, taken by the caller from the shared clock.
|
|
520
|
+
* @param health - Broker generation and durability, computed by the caller from owned state.
|
|
521
|
+
* @returns - Broker snapshot, optionally scoped to the requested workspace.
|
|
522
|
+
*/
|
|
523
|
+
readSnapshot(workspaceId, sequence, health) {
|
|
524
|
+
const activeWorkspace = workspaceId ?? null;
|
|
525
|
+
const agentSource = this.readAgentSource(workspaceId);
|
|
526
|
+
const messages = this.#db
|
|
527
|
+
.prepare(activeWorkspace
|
|
528
|
+
? "select rowid, id, team_id, workspace_id, sender_id, recipient_id, addressed_to, address_outcome, subject, type, state, created_at, claim_expires_at, sequence, body, body_size, content_type from messages where workspace_id = ? or recipient_id in (select id from agents where workspace_id = ?) order by sequence"
|
|
529
|
+
: "select rowid, id, team_id, workspace_id, sender_id, recipient_id, addressed_to, address_outcome, subject, type, state, created_at, claim_expires_at, sequence, body, body_size, content_type from messages order by sequence")
|
|
530
|
+
.all(...(activeWorkspace ? [activeWorkspace, activeWorkspace] : []));
|
|
531
|
+
const claims = this.#db
|
|
532
|
+
.prepare("select message_id, count(*) as attempts from claims group by message_id")
|
|
533
|
+
.all();
|
|
534
|
+
const attemptsByMessage = new Map(claims.map((row) => [String(row.message_id), Number(row.attempts)]));
|
|
535
|
+
const lifecycleByMessage = this.#eventsForMessages(messages.map((message) => String(message.id)));
|
|
536
|
+
// Skip-and-name, through the same guard as the traffic window so the two agree on which rows exist.
|
|
537
|
+
const deliveries = messages.flatMap((message) => {
|
|
538
|
+
const messageId = String(message.id);
|
|
539
|
+
const stored = readableColumns(message);
|
|
540
|
+
if ("unreadable" in stored) {
|
|
541
|
+
this.#recordOnce({
|
|
542
|
+
type: "traffic_row_skipped",
|
|
543
|
+
messageId: skippedRowName(message.id, message.rowid),
|
|
544
|
+
unreadable: stored.unreadable,
|
|
545
|
+
}, `traffic_row_skipped\u0000${String(message.rowid)}`);
|
|
546
|
+
return [];
|
|
547
|
+
}
|
|
548
|
+
return [
|
|
549
|
+
{
|
|
550
|
+
messageId,
|
|
551
|
+
sequence: stored.sequence,
|
|
552
|
+
teamId: String(message.team_id),
|
|
553
|
+
workspaceId: String(message.workspace_id),
|
|
554
|
+
from: stored.senderAgentId,
|
|
555
|
+
to: stored.recipientAgentId,
|
|
556
|
+
subject: String(message.subject),
|
|
557
|
+
type: String(message.type),
|
|
558
|
+
state: stored.state,
|
|
559
|
+
createdAt: Number(message.created_at),
|
|
560
|
+
attempts: attemptsByMessage.get(messageId) ?? 0,
|
|
561
|
+
body: { sizeBytes: stored.bodySizeBytes, contentType: stored.contentType },
|
|
562
|
+
lifecycle: lifecycleByMessage.get(messageId) ?? [],
|
|
563
|
+
},
|
|
564
|
+
];
|
|
565
|
+
});
|
|
566
|
+
return {
|
|
567
|
+
version: 1,
|
|
568
|
+
sequence,
|
|
569
|
+
health,
|
|
570
|
+
...agentSource,
|
|
571
|
+
deliveries,
|
|
572
|
+
diagnostics: {
|
|
573
|
+
connectionState: this.#connectionState(),
|
|
574
|
+
operationalEvents: this.operationalEvents(),
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Name every row of one table whose stored position this build cannot order, up to the window bound.
|
|
580
|
+
*
|
|
581
|
+
* Reports every such row on every call, with no dedup across reads: this channel is live, and what
|
|
582
|
+
* an operator sees must not depend on when they connected. The ring is the deduplicated view.
|
|
583
|
+
* @param index - Partial index over the table's unorderable rows, which both reads go through.
|
|
584
|
+
* @param name - Called per row with its rowid and its durable identity.
|
|
585
|
+
* @param truncate - Called once when the table holds more such rows than the bound.
|
|
586
|
+
*/
|
|
587
|
+
#nameUnorderable(index, name, truncate) {
|
|
588
|
+
// `indexed by` pins both reads to the partial index; the planner does not choose it for a count
|
|
589
|
+
// on a database that has never been analysed.
|
|
590
|
+
const from = `from ${index.table} indexed by ${index.name} where ${unorderablePosition(index.position)}`;
|
|
591
|
+
const rows = this.#db
|
|
592
|
+
.prepare(`select rowid, ${index.column} as id ${from} order by ${index.column} limit ?`)
|
|
593
|
+
.all(BROKER_OPERATOR_TRAFFIC_LIMIT);
|
|
594
|
+
if (rows.length === 0)
|
|
595
|
+
return;
|
|
596
|
+
for (const row of rows)
|
|
597
|
+
name(String(row.rowid), skippedRowName(row.id, row.rowid));
|
|
598
|
+
if (rows.length < BROKER_OPERATOR_TRAFFIC_LIMIT)
|
|
599
|
+
return;
|
|
600
|
+
const total = Number(this.#db.prepare(`select count(*) as total ${from}`).get().total);
|
|
601
|
+
if (total > rows.length) {
|
|
602
|
+
const truncated = {
|
|
603
|
+
type: "skips_truncated",
|
|
604
|
+
table: index.table,
|
|
605
|
+
named: rows.length,
|
|
606
|
+
total,
|
|
607
|
+
};
|
|
608
|
+
truncate(cloneOperationalEvent(truncated));
|
|
609
|
+
this.#recordOnce(truncated);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Read the lifecycle of many messages in one pass, because the snapshot is rebuilt on every commit.
|
|
614
|
+
* @param messageIds - Messages whose lifecycles the projection needs.
|
|
615
|
+
* @returns - Lifecycle entries by message id, ordered oldest first.
|
|
616
|
+
*/
|
|
617
|
+
#eventsForMessages(messageIds) {
|
|
618
|
+
const unique = new Set(messageIds);
|
|
619
|
+
const grouped = new Map();
|
|
620
|
+
if (unique.size === 0)
|
|
621
|
+
return grouped;
|
|
622
|
+
const rows = this.#eventRows([...unique], "*");
|
|
623
|
+
for (const row of rows) {
|
|
624
|
+
const source = MESSAGE_EVENT_SOURCES.find((candidate) => candidate === row.source);
|
|
625
|
+
const tone = MESSAGE_EVENT_TONES.find((candidate) => candidate === row.tone);
|
|
626
|
+
const messageId = String(row.message_id);
|
|
627
|
+
// Per-row degrade: the delivery still projects, one observation short.
|
|
628
|
+
if (source === undefined || tone === undefined) {
|
|
629
|
+
const unreadable = [source === undefined ? "source" : null, tone === undefined ? "tone" : null].filter((column) => column !== null);
|
|
630
|
+
// Held under the lifecycle row's id: two bad entries on one delivery are two reports.
|
|
631
|
+
this.#recordOnce({ type: "lifecycle_entry_skipped", messageId, unreadable }, `lifecycle_entry_skipped\u0000${String(row.id)}`);
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const lifecycle = grouped.get(messageId) ?? [];
|
|
635
|
+
lifecycle.push({
|
|
636
|
+
id: Number(row.id),
|
|
637
|
+
messageId,
|
|
638
|
+
at: Number(row.at),
|
|
639
|
+
source,
|
|
640
|
+
type: String(row.type),
|
|
641
|
+
text: String(row.text),
|
|
642
|
+
tone,
|
|
643
|
+
});
|
|
644
|
+
grouped.set(messageId, lifecycle);
|
|
645
|
+
}
|
|
646
|
+
return grouped;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Read the lifecycle of every message in one traffic window in one pass.
|
|
650
|
+
*
|
|
651
|
+
* A message whose lifecycle reaches the window limit still needs the truncation and latest-state
|
|
652
|
+
* reconciliation the per-message read performs, so it falls back to that path rather than being
|
|
653
|
+
* approximated here.
|
|
654
|
+
* @param messageIds - Message ids in the window whose lifecycles are needed.
|
|
655
|
+
* @returns - Lifecycle records by message id, ordered oldest first.
|
|
656
|
+
*/
|
|
657
|
+
#trafficEventsForWindow(messageIds) {
|
|
658
|
+
const grouped = new Map();
|
|
659
|
+
const ids = [...new Set(messageIds)];
|
|
660
|
+
if (ids.length === 0)
|
|
661
|
+
return grouped;
|
|
662
|
+
const rows = this.#eventRows(ids, "message_id, id, at, type");
|
|
663
|
+
for (const row of rows) {
|
|
664
|
+
const messageId = String(row.message_id);
|
|
665
|
+
const lifecycle = grouped.get(messageId) ?? [];
|
|
666
|
+
lifecycle.push({ id: Number(row.id), at: Number(row.at), type: String(row.type) });
|
|
667
|
+
grouped.set(messageId, lifecycle);
|
|
668
|
+
}
|
|
669
|
+
for (const [messageId, lifecycle] of grouped) {
|
|
670
|
+
if (lifecycle.length >= BROKER_OPERATOR_TRAFFIC_LIMIT)
|
|
671
|
+
grouped.set(messageId, this.#trafficEventsForMessage(messageId));
|
|
672
|
+
}
|
|
673
|
+
return grouped;
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* Read the lifecycle rows of many messages, splitting the identity list across statements.
|
|
677
|
+
* @param messageIds - Distinct message identities whose lifecycle rows are wanted.
|
|
678
|
+
* @param columns - Projection-owned select list, never caller input.
|
|
679
|
+
* @returns - Flat rows for the callers to group. Ordered within each statement's chunk, not across
|
|
680
|
+
* them, which is enough because every caller groups by message before reading the order.
|
|
681
|
+
*/
|
|
682
|
+
#eventRows(messageIds, columns) {
|
|
683
|
+
const rows = [];
|
|
684
|
+
for (const chunk of chunked(messageIds, SQL_PARAMETER_LIMIT)) {
|
|
685
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
686
|
+
rows.push(...this.#db
|
|
687
|
+
.prepare(`select ${columns} from message_events where message_id in (${placeholders}) order by message_id, id`)
|
|
688
|
+
.all(...chunk));
|
|
689
|
+
}
|
|
690
|
+
return rows;
|
|
691
|
+
}
|
|
692
|
+
#trafficEventsForMessage(messageId) {
|
|
693
|
+
const rows = this.#db
|
|
694
|
+
.prepare("select id, at, type from message_events where message_id = ? order by id desc limit ?")
|
|
695
|
+
.all(messageId, BROKER_OPERATOR_TRAFFIC_LIMIT);
|
|
696
|
+
rows.reverse();
|
|
697
|
+
const latestState = this.#db
|
|
698
|
+
.prepare(`select id, at, type from message_events where message_id = ? and type in (${STATE_OBSERVING_EVENT_TYPES.map(() => "?").join(",")}) order by id desc limit 1`)
|
|
699
|
+
.get(messageId, ...STATE_OBSERVING_EVENT_TYPES);
|
|
700
|
+
if (latestState && rows.every((row) => row.id !== latestState.id)) {
|
|
701
|
+
if (rows.length >= BROKER_OPERATOR_TRAFFIC_LIMIT)
|
|
702
|
+
rows.shift();
|
|
703
|
+
rows.push(latestState);
|
|
704
|
+
rows.sort((left, right) => Number(left.id) - Number(right.id));
|
|
705
|
+
}
|
|
706
|
+
return rows.map((row) => ({ id: Number(row.id), at: Number(row.at), type: String(row.type) }));
|
|
707
|
+
}
|
|
708
|
+
#workspaceSummaries() {
|
|
709
|
+
const rows = this.#db.prepare("select * from workspaces order by path").all();
|
|
710
|
+
return rows.map((workspace) => ({
|
|
711
|
+
id: String(workspace.id),
|
|
712
|
+
path: String(workspace.path),
|
|
713
|
+
branch: typeof workspace.branch === "string" ? workspace.branch : null,
|
|
714
|
+
}));
|
|
715
|
+
}
|
|
716
|
+
#connectionState() {
|
|
717
|
+
const recentFailure = this.#db
|
|
718
|
+
.prepare("select 1 from message_events where type in ('failed','dead_lettered') and at >= ? order by at desc limit 1")
|
|
719
|
+
.get(this.#now() - RECENT_FAILURE_WINDOW_MS);
|
|
720
|
+
return recentFailure ? "degraded" : "connected";
|
|
721
|
+
}
|
|
722
|
+
}
|