@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,916 @@
|
|
|
1
|
+
import { OPERATOR_DIAGNOSTIC_TEXT_LIMIT, OPERATOR_PROTOCOL_VERSION, OPERATOR_REJECTION_REASONS, OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES, } from "./protocol.js";
|
|
2
|
+
import { BROKER_OPERATOR_TRAFFIC_LIMIT, hasControlCharacter, isAgentId, isImplementationId, isOpaqueId, lifecycleEventState, MESSAGE_BODY_LIMIT_BYTES, MESSAGE_SUBJECT_LIMIT_CHARACTERS, MESSAGE_TYPE_LIMIT_CHARACTERS, } from "../broker/protocol.js";
|
|
3
|
+
import { MESSAGE_STATES } from "../broker/schema.js";
|
|
4
|
+
import { exactFields as guardExactFields, readArray as guardReadArray, readOwnEnumerableEntries as guardReadOwnEnumerableEntries, readRecord as guardReadRecord, } from "../shared/guards.js";
|
|
5
|
+
/**
|
|
6
|
+
* Bounds how many rows a projection admits into one operator snapshot array.
|
|
7
|
+
*
|
|
8
|
+
* The projections enforce it, not the validators in this file. Derived from the broker's traffic
|
|
9
|
+
* bound: an array smaller than what the broker projects would reject a snapshot the broker
|
|
10
|
+
* considers valid, and one larger admits rows nothing can produce.
|
|
11
|
+
*/
|
|
12
|
+
export const OPERATOR_ARRAY_LENGTH_LIMIT = BROKER_OPERATOR_TRAFFIC_LIMIT;
|
|
13
|
+
/** Reports that an untrusted value does not satisfy the operator protocol. */
|
|
14
|
+
export class OperatorValidationError extends Error {
|
|
15
|
+
path;
|
|
16
|
+
/**
|
|
17
|
+
* Constructs a boundary error at one public field path.
|
|
18
|
+
* @param path - Field path at which validation failed.
|
|
19
|
+
* @param reason - Safe explanation suitable for operator diagnostics.
|
|
20
|
+
*/
|
|
21
|
+
constructor(
|
|
22
|
+
/** Identifies the rejected field. */
|
|
23
|
+
path, reason) {
|
|
24
|
+
super(`${path}: ${reason}`);
|
|
25
|
+
this.path = path;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Validate an untrusted operator cursor.
|
|
30
|
+
* @param value - Candidate cursor received from an external boundary.
|
|
31
|
+
* @returns A fully validated epoch and monotonic sequence position.
|
|
32
|
+
*/
|
|
33
|
+
export function validateOperatorCursor(value) {
|
|
34
|
+
const record = readRecord(value, "cursor");
|
|
35
|
+
exactFields({ record, required: ["protocolVersion", "epoch", "sequence"], optional: [], path: "cursor" });
|
|
36
|
+
return {
|
|
37
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "cursor.protocolVersion"),
|
|
38
|
+
epoch: readNonemptyString(record.epoch, "cursor.epoch"),
|
|
39
|
+
sequence: readSequence(record.sequence, "cursor.sequence"),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Validate an untrusted repository projection.
|
|
44
|
+
* @param value - Candidate presentation grouping received from an external boundary.
|
|
45
|
+
* @returns A repository projection that carries no broker authority.
|
|
46
|
+
*/
|
|
47
|
+
export function validateOperatorRepository(value) {
|
|
48
|
+
const record = readRecord(value, "repository");
|
|
49
|
+
exactFields({ record, required: ["id", "rootPath", "name"], optional: [], path: "repository" });
|
|
50
|
+
return {
|
|
51
|
+
id: readNonemptyString(record.id, "repository.id"),
|
|
52
|
+
rootPath: readNonemptyString(record.rootPath, "repository.rootPath"),
|
|
53
|
+
name: readNonemptyString(record.name, "repository.name"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Validate an untrusted operator agent projection.
|
|
58
|
+
* @param value - Candidate agent received from an external boundary.
|
|
59
|
+
* @returns A fully typed agent with separated broker and repository identities.
|
|
60
|
+
*/
|
|
61
|
+
export function validateOperatorAgent(value) {
|
|
62
|
+
const record = readRecord(value, "agent");
|
|
63
|
+
exactFields({
|
|
64
|
+
record,
|
|
65
|
+
required: [
|
|
66
|
+
"id",
|
|
67
|
+
"alias",
|
|
68
|
+
"previousAliases",
|
|
69
|
+
"registeredAt",
|
|
70
|
+
"lifecycle",
|
|
71
|
+
"archivedAt",
|
|
72
|
+
"workspaceAvailable",
|
|
73
|
+
"workspace",
|
|
74
|
+
"brokerIdentity",
|
|
75
|
+
"repository",
|
|
76
|
+
"adapter",
|
|
77
|
+
"implementation",
|
|
78
|
+
],
|
|
79
|
+
optional: [],
|
|
80
|
+
path: "agent",
|
|
81
|
+
});
|
|
82
|
+
const brokerIdentity = readRecord(record.brokerIdentity, "agent.brokerIdentity");
|
|
83
|
+
exactFields({
|
|
84
|
+
record: brokerIdentity,
|
|
85
|
+
required: ["teamId", "workspaceId"],
|
|
86
|
+
optional: [],
|
|
87
|
+
path: "agent.brokerIdentity",
|
|
88
|
+
});
|
|
89
|
+
const repository = readRecord(record.repository, "agent.repository");
|
|
90
|
+
exactFields({ record: repository, required: ["repositoryId", "worktree"], optional: [], path: "agent.repository" });
|
|
91
|
+
return {
|
|
92
|
+
id: readAgentId(record.id, "agent.id"),
|
|
93
|
+
alias: readNonemptyString(record.alias, "agent.alias"),
|
|
94
|
+
previousAliases: readStringArray(record.previousAliases, "agent.previousAliases"),
|
|
95
|
+
registeredAt: readTimestamp(record.registeredAt, "agent.registeredAt"),
|
|
96
|
+
lifecycle: readAgentLifecycle(record.lifecycle, "agent.lifecycle"),
|
|
97
|
+
archivedAt: readNullableTimestamp(record.archivedAt, "agent.archivedAt"),
|
|
98
|
+
workspaceAvailable: readBoolean(record.workspaceAvailable, "agent.workspaceAvailable"),
|
|
99
|
+
workspace: readNonemptyString(record.workspace, "agent.workspace"),
|
|
100
|
+
brokerIdentity: {
|
|
101
|
+
teamId: readNonemptyString(brokerIdentity.teamId, "agent.brokerIdentity.teamId"),
|
|
102
|
+
workspaceId: readNonemptyString(brokerIdentity.workspaceId, "agent.brokerIdentity.workspaceId"),
|
|
103
|
+
},
|
|
104
|
+
repository: {
|
|
105
|
+
repositoryId: readNonemptyString(repository.repositoryId, "agent.repository.repositoryId"),
|
|
106
|
+
worktree: validateWorktree(repository.worktree, "agent.repository.worktree"),
|
|
107
|
+
},
|
|
108
|
+
adapter: validateAdapterState(record.adapter, "agent.adapter"),
|
|
109
|
+
implementation: validateImplementation(record.implementation, "agent.implementation"),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Validate an untrusted traffic projection.
|
|
114
|
+
* @param value - Candidate accepted delivery or audit received from an external boundary.
|
|
115
|
+
* @returns One member of the closed operator traffic union.
|
|
116
|
+
*/
|
|
117
|
+
export function validateOperatorTraffic(value) {
|
|
118
|
+
const record = readRecord(value, "traffic");
|
|
119
|
+
switch (record.kind) {
|
|
120
|
+
case "accepted_delivery":
|
|
121
|
+
return validateAcceptedDelivery(record);
|
|
122
|
+
case "ping_audit":
|
|
123
|
+
return validatePingAudit(record);
|
|
124
|
+
default:
|
|
125
|
+
throw new OperatorValidationError("traffic.kind", "unknown traffic kind");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Validate one public operator body.
|
|
130
|
+
* @param value - Candidate retained public body.
|
|
131
|
+
* @returns A bounded body representation whose metadata matches its public raw copy.
|
|
132
|
+
*/
|
|
133
|
+
export function validateOperatorMessageBody(value) {
|
|
134
|
+
return validateMessageBody(value, "traffic.body");
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Validate an untrusted operator diagnostic.
|
|
138
|
+
* @param value - Candidate diagnostic received from an external boundary.
|
|
139
|
+
* @returns A diagnostic with a bounded scope and summary.
|
|
140
|
+
*/
|
|
141
|
+
export function validateOperatorDiagnostic(value) {
|
|
142
|
+
const record = readRecord(value, "diagnostic");
|
|
143
|
+
exactFields({ record, required: ["at", "origin", "severity", "scope", "summary"], optional: [], path: "diagnostic" });
|
|
144
|
+
const origin = record.origin;
|
|
145
|
+
if (origin !== "broker" && origin !== "client") {
|
|
146
|
+
throw new OperatorValidationError("diagnostic.origin", "must name the observer");
|
|
147
|
+
}
|
|
148
|
+
const severity = record.severity;
|
|
149
|
+
if (severity !== "info" && severity !== "warning" && severity !== "error") {
|
|
150
|
+
throw new OperatorValidationError("diagnostic.severity", "must name a known severity");
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
at: readTimestamp(record.at, "diagnostic.at"),
|
|
154
|
+
origin,
|
|
155
|
+
severity,
|
|
156
|
+
scope: readDiagnosticText(record.scope, "diagnostic.scope"),
|
|
157
|
+
summary: readDiagnosticText(record.summary, "diagnostic.summary"),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function readDiagnosticText(value, path) {
|
|
161
|
+
const text = readNonemptyString(value, path);
|
|
162
|
+
if (text.length > OPERATOR_DIAGNOSTIC_TEXT_LIMIT) {
|
|
163
|
+
throw new OperatorValidationError(path, "exceeds the bounded diagnostic length");
|
|
164
|
+
}
|
|
165
|
+
return text;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Validate an untrusted complete operator projection.
|
|
169
|
+
* @param value - Candidate snapshot received from an external boundary.
|
|
170
|
+
* @returns A typed snapshot whose cross-record identities and sequences are consistent.
|
|
171
|
+
*/
|
|
172
|
+
export function validateOperatorSnapshot(value) {
|
|
173
|
+
const record = readRecord(value, "snapshot");
|
|
174
|
+
exactFields({
|
|
175
|
+
record,
|
|
176
|
+
required: ["protocolVersion", "epoch", "cursor", "generatedAt", "repositories", "agents", "traffic"],
|
|
177
|
+
optional: [],
|
|
178
|
+
path: "snapshot",
|
|
179
|
+
});
|
|
180
|
+
const protocolVersion = readProtocolVersion(record.protocolVersion, "snapshot.protocolVersion");
|
|
181
|
+
const epoch = readNonemptyString(record.epoch, "snapshot.epoch");
|
|
182
|
+
const cursor = validateOperatorCursor(record.cursor);
|
|
183
|
+
if (cursor.epoch !== epoch)
|
|
184
|
+
throw new OperatorValidationError("snapshot.cursor.epoch", "does not match snapshot epoch");
|
|
185
|
+
const repositories = readArray(record.repositories, "snapshot.repositories").map((entry) => validateOperatorRepository(entry));
|
|
186
|
+
const agents = readArray(record.agents, "snapshot.agents").map((entry) => validateOperatorAgent(entry));
|
|
187
|
+
const traffic = readArray(record.traffic, "snapshot.traffic").map((entry) => validateOperatorTraffic(entry));
|
|
188
|
+
assertUnique(repositories.map((repository) => repository.id), "snapshot.repositories", "repository id");
|
|
189
|
+
assertUnique(agents.map((agent) => agent.id), "snapshot.agents", "agent id");
|
|
190
|
+
const repositoryIds = new Set(repositories.map((repository) => repository.id));
|
|
191
|
+
for (const agent of agents) {
|
|
192
|
+
if (!repositoryIds.has(agent.repository.repositoryId))
|
|
193
|
+
throw new OperatorValidationError("snapshot.agents", "agent references an unknown repository projection");
|
|
194
|
+
}
|
|
195
|
+
assertUnique(traffic
|
|
196
|
+
.filter((entry) => entry.kind === "accepted_delivery")
|
|
197
|
+
.map((entry) => entry.acceptedSequence), "snapshot.traffic", "accepted traffic sequence");
|
|
198
|
+
assertUnique(traffic.map((entry) => entry.trafficPosition), "snapshot.traffic", "durable traffic position");
|
|
199
|
+
for (let index = 1; index < traffic.length; index += 1) {
|
|
200
|
+
const previous = traffic[index - 1];
|
|
201
|
+
const current = traffic[index];
|
|
202
|
+
if (previous && current && previous.trafficPosition >= current.trafficPosition) {
|
|
203
|
+
throw new OperatorValidationError("snapshot.traffic", "must follow durable traffic position order");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
assertUnique(traffic.map((entry) => entry.trafficId), "snapshot.traffic", "traffic id");
|
|
207
|
+
const snapshot = {
|
|
208
|
+
protocolVersion,
|
|
209
|
+
epoch,
|
|
210
|
+
cursor,
|
|
211
|
+
generatedAt: readTimestamp(record.generatedAt, "snapshot.generatedAt"),
|
|
212
|
+
repositories,
|
|
213
|
+
agents,
|
|
214
|
+
traffic,
|
|
215
|
+
};
|
|
216
|
+
validateOperatorSnapshotByteLength(Buffer.byteLength(JSON.stringify(snapshot), "utf8"));
|
|
217
|
+
return snapshot;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Validate the serialized size shared by snapshot producers and the transport.
|
|
221
|
+
* @param byteLength - UTF-8 JSON byte count for one complete snapshot.
|
|
222
|
+
* @returns The accepted byte count for subsequent bounded transfer.
|
|
223
|
+
*/
|
|
224
|
+
export function validateOperatorSnapshotByteLength(byteLength) {
|
|
225
|
+
if (!Number.isSafeInteger(byteLength) || byteLength < 1 || byteLength > OPERATOR_SNAPSHOT_TOTAL_LIMIT_BYTES) {
|
|
226
|
+
throw new OperatorValidationError("snapshot", "exceeds the aggregate operator snapshot limit");
|
|
227
|
+
}
|
|
228
|
+
return byteLength;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Validate an untrusted sequenced state event.
|
|
232
|
+
* @param value - Candidate event envelope received from an external boundary.
|
|
233
|
+
* @returns A fully typed authoritative state event.
|
|
234
|
+
*/
|
|
235
|
+
export function validateOperatorEventEnvelope(value) {
|
|
236
|
+
const record = readRecord(value, "eventEnvelope");
|
|
237
|
+
exactFields({
|
|
238
|
+
record,
|
|
239
|
+
required: ["protocolVersion", "epoch", "sequence", "event"],
|
|
240
|
+
optional: [],
|
|
241
|
+
path: "eventEnvelope",
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "eventEnvelope.protocolVersion"),
|
|
245
|
+
epoch: readNonemptyString(record.epoch, "eventEnvelope.epoch"),
|
|
246
|
+
sequence: readPositiveSequence(record.sequence, "eventEnvelope.sequence"),
|
|
247
|
+
event: validateStateEvent(record.event),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Classify an untrusted event against the current cursor.
|
|
252
|
+
* @param cursorValue - Candidate last authoritative event position, validated before classification.
|
|
253
|
+
* @param value - Candidate event envelope received from the stream.
|
|
254
|
+
* @returns A distinct apply, duplicate, gap, stale-epoch, or incompatible-version result.
|
|
255
|
+
*/
|
|
256
|
+
export function classifyOperatorEvent(cursorValue, value) {
|
|
257
|
+
const cursor = validateOperatorCursor(cursorValue);
|
|
258
|
+
const record = readRecord(value, "eventEnvelope");
|
|
259
|
+
const receivedVersion = readInteger(record.protocolVersion, "eventEnvelope.protocolVersion");
|
|
260
|
+
if (receivedVersion !== OPERATOR_PROTOCOL_VERSION) {
|
|
261
|
+
return { type: "incompatible_version", supportedVersion: OPERATOR_PROTOCOL_VERSION, receivedVersion };
|
|
262
|
+
}
|
|
263
|
+
const event = validateOperatorEventEnvelope(value);
|
|
264
|
+
if (event.epoch !== cursor.epoch) {
|
|
265
|
+
return { type: "stale_epoch", currentEpoch: cursor.epoch, receivedEpoch: event.epoch };
|
|
266
|
+
}
|
|
267
|
+
if (event.sequence <= cursor.sequence)
|
|
268
|
+
return { type: "duplicate", sequence: event.sequence };
|
|
269
|
+
const expectedSequence = cursor.sequence + 1;
|
|
270
|
+
if (event.sequence !== expectedSequence) {
|
|
271
|
+
return { type: "gap", expectedSequence, receivedSequence: event.sequence };
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
type: "apply",
|
|
275
|
+
event,
|
|
276
|
+
cursor: { protocolVersion: OPERATOR_PROTOCOL_VERSION, epoch: event.epoch, sequence: event.sequence },
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Validate one untrusted client-to-server transport frame.
|
|
281
|
+
* @param value - Candidate newline-delimited request received from a loopback client.
|
|
282
|
+
* @returns The validated request, as whichever member of the transport request union it names.
|
|
283
|
+
*/
|
|
284
|
+
export function validateOperatorTransportRequest(value) {
|
|
285
|
+
const record = readRecord(value, "transportRequest");
|
|
286
|
+
switch (record.type) {
|
|
287
|
+
case "subscribe": {
|
|
288
|
+
exactFields({ record, required: ["type", "protocolVersion"], optional: ["cursor"], path: "transportRequest" });
|
|
289
|
+
const request = {
|
|
290
|
+
type: "subscribe",
|
|
291
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "transportRequest.protocolVersion"),
|
|
292
|
+
};
|
|
293
|
+
if (record.cursor !== undefined)
|
|
294
|
+
request.cursor = validateOperatorCursor(record.cursor);
|
|
295
|
+
return request;
|
|
296
|
+
}
|
|
297
|
+
case "ack":
|
|
298
|
+
exactFields({ record, required: ["type", "protocolVersion", "cursor"], optional: [], path: "transportRequest" });
|
|
299
|
+
return {
|
|
300
|
+
type: "ack",
|
|
301
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "transportRequest.protocolVersion"),
|
|
302
|
+
cursor: validateOperatorCursor(record.cursor),
|
|
303
|
+
};
|
|
304
|
+
case "command":
|
|
305
|
+
exactFields({ record, required: ["type", "protocolVersion", "command"], optional: [], path: "transportRequest" });
|
|
306
|
+
return {
|
|
307
|
+
type: "command",
|
|
308
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "transportRequest.protocolVersion"),
|
|
309
|
+
command: validateOperatorCommand(record.command),
|
|
310
|
+
};
|
|
311
|
+
default:
|
|
312
|
+
throw new OperatorValidationError("transportRequest.type", "unknown transport request");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Validate one generated operator control correlation before it reaches scheduling or durability.
|
|
317
|
+
* @param value - Candidate correlation returned by an injected generator.
|
|
318
|
+
* @returns Bounded public-safe opaque control identity.
|
|
319
|
+
*/
|
|
320
|
+
export function validateOperatorControlId(value) {
|
|
321
|
+
return readOpaqueId(value, "controlId");
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Validate an untrusted operator command without granting repository projection authority.
|
|
325
|
+
* @param value - Candidate versioned command received from an external boundary.
|
|
326
|
+
* @returns A typed operator command envelope.
|
|
327
|
+
*/
|
|
328
|
+
export function validateOperatorCommand(value) {
|
|
329
|
+
const record = readRecord(value, "commandEnvelope");
|
|
330
|
+
exactFields({
|
|
331
|
+
record,
|
|
332
|
+
required: ["protocolVersion", "commandId", "issuedAt", "command"],
|
|
333
|
+
optional: [],
|
|
334
|
+
path: "commandEnvelope",
|
|
335
|
+
});
|
|
336
|
+
return {
|
|
337
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "commandEnvelope.protocolVersion"),
|
|
338
|
+
commandId: readOpaqueId(record.commandId, "commandEnvelope.commandId"),
|
|
339
|
+
issuedAt: readTimestamp(record.issuedAt, "commandEnvelope.issuedAt"),
|
|
340
|
+
command: validateCommandMember(record.command),
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Validate an untrusted command admission result.
|
|
345
|
+
* @param value - Candidate result received from the command endpoint.
|
|
346
|
+
* @returns A typed admission outcome that contains no authoritative state event.
|
|
347
|
+
*/
|
|
348
|
+
export function validateOperatorCommandResult(value) {
|
|
349
|
+
const record = readRecord(value, "commandResult");
|
|
350
|
+
switch (record.type) {
|
|
351
|
+
case "ping_accepted":
|
|
352
|
+
exactFields({
|
|
353
|
+
record,
|
|
354
|
+
required: ["protocolVersion", "type", "status", "commandId", "acceptedAt", "controlId"],
|
|
355
|
+
optional: [],
|
|
356
|
+
path: "commandResult",
|
|
357
|
+
});
|
|
358
|
+
if (record.status !== "accepted")
|
|
359
|
+
throw new OperatorValidationError("commandResult.status", "ping result must be accepted");
|
|
360
|
+
return {
|
|
361
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "commandResult.protocolVersion"),
|
|
362
|
+
type: "ping_accepted",
|
|
363
|
+
status: "accepted",
|
|
364
|
+
commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
|
|
365
|
+
acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
|
|
366
|
+
controlId: readOpaqueId(record.controlId, "commandResult.controlId"),
|
|
367
|
+
};
|
|
368
|
+
case "expiry_accepted":
|
|
369
|
+
exactFields({
|
|
370
|
+
record,
|
|
371
|
+
required: ["protocolVersion", "type", "status", "commandId", "acceptedAt"],
|
|
372
|
+
optional: [],
|
|
373
|
+
path: "commandResult",
|
|
374
|
+
});
|
|
375
|
+
if (record.status !== "accepted")
|
|
376
|
+
throw new OperatorValidationError("commandResult.status", "expiry result must be accepted");
|
|
377
|
+
return {
|
|
378
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "commandResult.protocolVersion"),
|
|
379
|
+
type: "expiry_accepted",
|
|
380
|
+
status: "accepted",
|
|
381
|
+
commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
|
|
382
|
+
acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
|
|
383
|
+
};
|
|
384
|
+
case "archive_accepted":
|
|
385
|
+
exactFields({
|
|
386
|
+
record,
|
|
387
|
+
required: ["protocolVersion", "type", "status", "commandId", "acceptedAt"],
|
|
388
|
+
optional: [],
|
|
389
|
+
path: "commandResult",
|
|
390
|
+
});
|
|
391
|
+
if (record.status !== "accepted")
|
|
392
|
+
throw new OperatorValidationError("commandResult.status", "archive result must be accepted");
|
|
393
|
+
return {
|
|
394
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "commandResult.protocolVersion"),
|
|
395
|
+
type: "archive_accepted",
|
|
396
|
+
status: "accepted",
|
|
397
|
+
commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
|
|
398
|
+
acceptedAt: readTimestamp(record.acceptedAt, "commandResult.acceptedAt"),
|
|
399
|
+
};
|
|
400
|
+
case "command_rejected":
|
|
401
|
+
exactFields({
|
|
402
|
+
record,
|
|
403
|
+
required: ["protocolVersion", "type", "status", "commandId", "reason"],
|
|
404
|
+
optional: [],
|
|
405
|
+
path: "commandResult",
|
|
406
|
+
});
|
|
407
|
+
if (record.status !== "rejected")
|
|
408
|
+
throw new OperatorValidationError("commandResult.status", "rejected result must be rejected");
|
|
409
|
+
return {
|
|
410
|
+
protocolVersion: readProtocolVersion(record.protocolVersion, "commandResult.protocolVersion"),
|
|
411
|
+
type: "command_rejected",
|
|
412
|
+
status: "rejected",
|
|
413
|
+
commandId: readOpaqueId(record.commandId, "commandResult.commandId"),
|
|
414
|
+
reason: readRejectionReason(record.reason, "commandResult.reason"),
|
|
415
|
+
};
|
|
416
|
+
default:
|
|
417
|
+
throw new OperatorValidationError("commandResult.type", "unknown command result");
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Validate and correlate a command result with the originating command kind and identity.
|
|
422
|
+
* @param commandValue - Candidate originating command received at the operator boundary.
|
|
423
|
+
* @param resultValue - Candidate result returned for that command.
|
|
424
|
+
* @returns A narrowed command/result pair whose accepted members cannot cross command kinds.
|
|
425
|
+
*/
|
|
426
|
+
export function validateOperatorCommandResultForCommand(commandValue, resultValue) {
|
|
427
|
+
const command = validateOperatorCommand(commandValue);
|
|
428
|
+
const result = validateOperatorCommandResult(resultValue);
|
|
429
|
+
if (result.commandId !== command.commandId) {
|
|
430
|
+
throw new OperatorValidationError("commandResult.commandId", "does not match originating command");
|
|
431
|
+
}
|
|
432
|
+
switch (command.command.type) {
|
|
433
|
+
case "ping_agent":
|
|
434
|
+
if (result.type === "ping_accepted" || result.type === "command_rejected")
|
|
435
|
+
return { command: { ...command, command: command.command }, result };
|
|
436
|
+
break;
|
|
437
|
+
case "expire_message":
|
|
438
|
+
if (result.type === "expiry_accepted" || result.type === "command_rejected")
|
|
439
|
+
return { command: { ...command, command: command.command }, result };
|
|
440
|
+
break;
|
|
441
|
+
case "archive_agent":
|
|
442
|
+
if (result.type === "archive_accepted" || result.type === "command_rejected")
|
|
443
|
+
return { command: { ...command, command: command.command }, result };
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
throw new OperatorValidationError("commandResult.type", `${resultDescriptions[result.type]} cannot answer ${commandDescriptions[command.command.type]}`);
|
|
447
|
+
}
|
|
448
|
+
function validateAcceptedDelivery(record) {
|
|
449
|
+
exactFields({
|
|
450
|
+
record,
|
|
451
|
+
required: [
|
|
452
|
+
"kind",
|
|
453
|
+
"trafficId",
|
|
454
|
+
"messageId",
|
|
455
|
+
"trafficPosition",
|
|
456
|
+
"acceptedSequence",
|
|
457
|
+
"fromAgentId",
|
|
458
|
+
"toAgentId",
|
|
459
|
+
"teamId",
|
|
460
|
+
"workspaceId",
|
|
461
|
+
"acceptedAt",
|
|
462
|
+
"subject",
|
|
463
|
+
"type",
|
|
464
|
+
"state",
|
|
465
|
+
"body",
|
|
466
|
+
"lifecycle",
|
|
467
|
+
],
|
|
468
|
+
optional: [],
|
|
469
|
+
path: "traffic",
|
|
470
|
+
});
|
|
471
|
+
const trafficId = readOpaqueId(record.trafficId, "traffic.trafficId");
|
|
472
|
+
const messageId = readOpaqueId(record.messageId, "traffic.messageId");
|
|
473
|
+
if (trafficId !== messageId) {
|
|
474
|
+
throw new OperatorValidationError("traffic.trafficId", "must equal the durable message identity");
|
|
475
|
+
}
|
|
476
|
+
const trafficPosition = readPositiveSequence(record.trafficPosition, "traffic.trafficPosition");
|
|
477
|
+
const acceptedSequence = readPositiveSequence(record.acceptedSequence, "traffic.acceptedSequence");
|
|
478
|
+
if (acceptedSequence !== trafficPosition) {
|
|
479
|
+
throw new OperatorValidationError("traffic.acceptedSequence", "must equal the durable traffic position");
|
|
480
|
+
}
|
|
481
|
+
const state = readMessageState(record.state, "traffic.state");
|
|
482
|
+
const body = validateMessageBody(record.body, "traffic.body");
|
|
483
|
+
// Entries naming a type outside the closed vocabulary are dropped here; validateLifecycleEntry
|
|
484
|
+
// owns why.
|
|
485
|
+
const rawLifecycle = readArray(record.lifecycle, "traffic.lifecycle");
|
|
486
|
+
const validatedLifecycle = rawLifecycle.map((entry, index) => validateLifecycleEntry(entry, `traffic.lifecycle[${String(index)}]`));
|
|
487
|
+
const lifecycle = validatedLifecycle
|
|
488
|
+
.filter((result) => result.recognized)
|
|
489
|
+
.map((result) => result.entry);
|
|
490
|
+
assertLifecycleOrder(lifecycle, "traffic.lifecycle");
|
|
491
|
+
const latestStateBearingEntry = lifecycle.findLast((entry) => entry.state !== null);
|
|
492
|
+
const latestState = latestStateBearingEntry?.state;
|
|
493
|
+
// A mismatch is excusable only when some dropped entry's id exceeds the entry that produced
|
|
494
|
+
// latestState: that dropped entry is newer than the newest state this build could read, so it may
|
|
495
|
+
// itself have observed the delivery's real current state. A dropped entry that is not newer than
|
|
496
|
+
// that observation carries no such excuse, whatever its own id is relative to other entries.
|
|
497
|
+
const excusedByNewerUnrecognizedEntry = validatedLifecycle.some((result) => !result.recognized && result.id > (latestStateBearingEntry?.id ?? -Infinity));
|
|
498
|
+
if (latestState !== state && !excusedByNewerUnrecognizedEntry) {
|
|
499
|
+
throw new OperatorValidationError("traffic.lifecycle", "latest durable state observation must match current state");
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
kind: "accepted_delivery",
|
|
503
|
+
trafficId,
|
|
504
|
+
messageId,
|
|
505
|
+
trafficPosition,
|
|
506
|
+
acceptedSequence,
|
|
507
|
+
fromAgentId: readAgentId(record.fromAgentId, "traffic.fromAgentId"),
|
|
508
|
+
toAgentId: readAgentId(record.toAgentId, "traffic.toAgentId"),
|
|
509
|
+
teamId: readNonemptyString(record.teamId, "traffic.teamId"),
|
|
510
|
+
workspaceId: readNonemptyString(record.workspaceId, "traffic.workspaceId"),
|
|
511
|
+
acceptedAt: readTimestamp(record.acceptedAt, "traffic.acceptedAt"),
|
|
512
|
+
subject: readMessageSubject(record.subject, "traffic.subject"),
|
|
513
|
+
type: readMessageType(record.type, "traffic.type"),
|
|
514
|
+
state,
|
|
515
|
+
body,
|
|
516
|
+
lifecycle,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function validatePingAudit(record) {
|
|
520
|
+
exactFields({
|
|
521
|
+
record,
|
|
522
|
+
required: ["kind", "trafficId", "auditId", "trafficPosition", "controlId", "targetAgentId", "actor", "acceptedAt"],
|
|
523
|
+
optional: [],
|
|
524
|
+
path: "traffic",
|
|
525
|
+
});
|
|
526
|
+
if (record.actor !== "operator")
|
|
527
|
+
throw new OperatorValidationError("traffic.actor", "unknown ping actor");
|
|
528
|
+
const trafficId = readOpaqueId(record.trafficId, "traffic.trafficId");
|
|
529
|
+
const auditId = readOpaqueId(record.auditId, "traffic.auditId");
|
|
530
|
+
if (trafficId !== auditId) {
|
|
531
|
+
throw new OperatorValidationError("traffic.trafficId", "must equal the durable audit identity");
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
kind: "ping_audit",
|
|
535
|
+
trafficId,
|
|
536
|
+
auditId,
|
|
537
|
+
trafficPosition: readPositiveSequence(record.trafficPosition, "traffic.trafficPosition"),
|
|
538
|
+
controlId: readOpaqueId(record.controlId, "traffic.controlId"),
|
|
539
|
+
targetAgentId: readAgentId(record.targetAgentId, "traffic.targetAgentId"),
|
|
540
|
+
actor: "operator",
|
|
541
|
+
acceptedAt: readTimestamp(record.acceptedAt, "traffic.acceptedAt"),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function validateMessageBody(value, path) {
|
|
545
|
+
const record = readRecord(value, path);
|
|
546
|
+
exactFields({ record, required: ["content", "rawCopy", "sizeBytes", "contentType"], optional: [], path });
|
|
547
|
+
const content = validatePublicJson(record.content, `${path}.content`);
|
|
548
|
+
const contentType = readNonemptyString(record.contentType, `${path}.contentType`);
|
|
549
|
+
if (contentType !== "text" && contentType !== "json") {
|
|
550
|
+
throw new OperatorValidationError(`${path}.contentType`, "unknown public content type");
|
|
551
|
+
}
|
|
552
|
+
if (typeof record.rawCopy !== "string") {
|
|
553
|
+
throw new OperatorValidationError(`${path}.rawCopy`, "must be text");
|
|
554
|
+
}
|
|
555
|
+
if (Buffer.byteLength(record.rawCopy, "utf8") > MESSAGE_BODY_LIMIT_BYTES) {
|
|
556
|
+
throw new OperatorValidationError(`${path}.rawCopy`, "exceeds the message body limit");
|
|
557
|
+
}
|
|
558
|
+
const expectedRaw = contentType === "text" && typeof content === "string" ? content : JSON.stringify(content);
|
|
559
|
+
if (record.rawCopy !== expectedRaw) {
|
|
560
|
+
throw new OperatorValidationError(`${path}.rawCopy`, "must exactly represent public content");
|
|
561
|
+
}
|
|
562
|
+
const sizeBytes = readNonnegativeInteger(record.sizeBytes, `${path}.sizeBytes`);
|
|
563
|
+
if (sizeBytes !== Buffer.byteLength(record.rawCopy, "utf8")) {
|
|
564
|
+
throw new OperatorValidationError(`${path}.sizeBytes`, "must equal the public raw-copy size");
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
content,
|
|
568
|
+
rawCopy: record.rawCopy,
|
|
569
|
+
sizeBytes,
|
|
570
|
+
contentType,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Validate one durable lifecycle entry, or signal that it names a type outside the closed
|
|
575
|
+
* vocabulary.
|
|
576
|
+
*
|
|
577
|
+
* A durable row can predate this build's vocabulary, or this build can be validating wire traffic
|
|
578
|
+
* from a differently-built live broker. Either way an unrecognized type is expected drift, not
|
|
579
|
+
* corruption, so the caller drops the entry instead of failing the whole delivery over it. The
|
|
580
|
+
* tolerance is exactly one cause wide: every other field is validated first, so a structurally
|
|
581
|
+
* malformed entry still throws whatever its type says. The id survives the drop: the caller needs it to tell
|
|
582
|
+
* whether a dropped entry is the newest one, and array position is not durable order.
|
|
583
|
+
* @param value - Untrusted lifecycle entry read from durable storage or the operator wire.
|
|
584
|
+
* @param path - Field path identifying this entry for a validation failure.
|
|
585
|
+
* @returns - The validated entry, or just its id when it is structurally sound but names a type
|
|
586
|
+
* outside the known vocabulary.
|
|
587
|
+
*/
|
|
588
|
+
function validateLifecycleEntry(value, path) {
|
|
589
|
+
const record = readRecord(value, path);
|
|
590
|
+
exactFields({ record, required: ["id", "at", "type", "state"], optional: [], path });
|
|
591
|
+
const type = readLifecycleType(record.type, `${path}.type`);
|
|
592
|
+
const id = readPositiveSequence(record.id, `${path}.id`);
|
|
593
|
+
const at = readTimestamp(record.at, `${path}.at`);
|
|
594
|
+
const state = record.state === null ? null : readMessageState(record.state, `${path}.state`);
|
|
595
|
+
const knownState = lifecycleEventState(type);
|
|
596
|
+
if (knownState === undefined)
|
|
597
|
+
return { recognized: false, id };
|
|
598
|
+
if (state !== knownState) {
|
|
599
|
+
throw new OperatorValidationError(`${path}.state`, "does not match the durable lifecycle type");
|
|
600
|
+
}
|
|
601
|
+
return { recognized: true, entry: { id, at, type, state } };
|
|
602
|
+
}
|
|
603
|
+
function validateWorktree(value, path) {
|
|
604
|
+
const record = readRecord(value, path);
|
|
605
|
+
exactFields({ record, required: ["kind", "path"], optional: [], path });
|
|
606
|
+
if (record.kind === "repository_root") {
|
|
607
|
+
if (record.path !== null)
|
|
608
|
+
throw new OperatorValidationError(`${path}.path`, "must be null at repository root");
|
|
609
|
+
return { kind: "repository_root", path: null };
|
|
610
|
+
}
|
|
611
|
+
if (record.kind === "worktree") {
|
|
612
|
+
return { kind: "worktree", path: readNonemptyString(record.path, `${path}.path`) };
|
|
613
|
+
}
|
|
614
|
+
throw new OperatorValidationError(`${path}.kind`, "unknown worktree kind");
|
|
615
|
+
}
|
|
616
|
+
function validateAdapterState(value, path) {
|
|
617
|
+
const record = readRecord(value, path);
|
|
618
|
+
exactFields({ record, required: ["state", "lastSeenAt"], optional: [], path });
|
|
619
|
+
switch (record.state) {
|
|
620
|
+
case "connected":
|
|
621
|
+
return { state: "connected", lastSeenAt: readTimestamp(record.lastSeenAt, `${path}.lastSeenAt`) };
|
|
622
|
+
case "disconnected":
|
|
623
|
+
return { state: "disconnected", lastSeenAt: readNullableTimestamp(record.lastSeenAt, `${path}.lastSeenAt`) };
|
|
624
|
+
case "expired":
|
|
625
|
+
return { state: "expired", lastSeenAt: readNullableTimestamp(record.lastSeenAt, `${path}.lastSeenAt`) };
|
|
626
|
+
default:
|
|
627
|
+
throw new OperatorValidationError(`${path}.state`, "unknown adapter state");
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
function validateImplementation(value, path) {
|
|
631
|
+
const record = readRecord(value, path);
|
|
632
|
+
if (record.available === true) {
|
|
633
|
+
exactFields({ record, required: ["available", "id", "displayName", "version", "metadata"], optional: [], path });
|
|
634
|
+
return {
|
|
635
|
+
available: true,
|
|
636
|
+
id: readOpenImplementationId(record.id, `${path}.id`),
|
|
637
|
+
displayName: readNonemptyString(record.displayName, `${path}.displayName`),
|
|
638
|
+
version: validateImplementationVersion(record.version, `${path}.version`),
|
|
639
|
+
metadata: validatePublicJsonObject(record.metadata, `${path}.metadata`),
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
if (record.available === false) {
|
|
643
|
+
exactFields({ record, required: ["available", "id", "version"], optional: [], path });
|
|
644
|
+
if (record.id === null) {
|
|
645
|
+
const version = validateImplementationVersion(record.version, `${path}.version`);
|
|
646
|
+
if (version.known) {
|
|
647
|
+
throw new OperatorValidationError(`${path}.version.known`, "must be false when implementation id is null");
|
|
648
|
+
}
|
|
649
|
+
return { available: false, id: null, version };
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
available: false,
|
|
653
|
+
id: readOpenImplementationId(record.id, `${path}.id`),
|
|
654
|
+
version: validateImplementationVersion(record.version, `${path}.version`),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
throw new OperatorValidationError(`${path}.available`, "must be a boolean discriminator");
|
|
658
|
+
}
|
|
659
|
+
function validateImplementationVersion(value, path) {
|
|
660
|
+
const record = readRecord(value, path);
|
|
661
|
+
if (record.known === true) {
|
|
662
|
+
exactFields({ record, required: ["known", "value"], optional: [], path });
|
|
663
|
+
return { known: true, value: readNonemptyString(record.value, `${path}.value`) };
|
|
664
|
+
}
|
|
665
|
+
if (record.known === false) {
|
|
666
|
+
exactFields({ record, required: ["known"], optional: [], path });
|
|
667
|
+
return { known: false };
|
|
668
|
+
}
|
|
669
|
+
throw new OperatorValidationError(`${path}.known`, "must be a boolean discriminator");
|
|
670
|
+
}
|
|
671
|
+
function validateStateEvent(value) {
|
|
672
|
+
const record = readRecord(value, "eventEnvelope.event");
|
|
673
|
+
switch (record.type) {
|
|
674
|
+
case "repository_upserted":
|
|
675
|
+
exactFields({ record, required: ["type", "repository"], optional: [], path: "eventEnvelope.event" });
|
|
676
|
+
return { type: "repository_upserted", repository: validateOperatorRepository(record.repository) };
|
|
677
|
+
case "repository_removed":
|
|
678
|
+
exactFields({ record, required: ["type", "repositoryId"], optional: [], path: "eventEnvelope.event" });
|
|
679
|
+
return { type: "repository_removed", repositoryId: readNonemptyString(record.repositoryId, "event.repositoryId") };
|
|
680
|
+
case "agent_upserted":
|
|
681
|
+
exactFields({ record, required: ["type", "agent"], optional: [], path: "eventEnvelope.event" });
|
|
682
|
+
return { type: "agent_upserted", agent: validateOperatorAgent(record.agent) };
|
|
683
|
+
case "agent_removed":
|
|
684
|
+
exactFields({ record, required: ["type", "agentId"], optional: [], path: "eventEnvelope.event" });
|
|
685
|
+
return { type: "agent_removed", agentId: readAgentId(record.agentId, "event.agentId") };
|
|
686
|
+
case "traffic_upserted":
|
|
687
|
+
exactFields({ record, required: ["type", "traffic"], optional: [], path: "eventEnvelope.event" });
|
|
688
|
+
return { type: "traffic_upserted", traffic: validateOperatorTraffic(record.traffic) };
|
|
689
|
+
case "traffic_removed":
|
|
690
|
+
exactFields({ record, required: ["type", "trafficId"], optional: [], path: "eventEnvelope.event" });
|
|
691
|
+
return { type: "traffic_removed", trafficId: readOpaqueId(record.trafficId, "event.trafficId") };
|
|
692
|
+
default:
|
|
693
|
+
throw new OperatorValidationError("eventEnvelope.event.type", "unknown state event");
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
/** Names each command kind in the error raised when a result of another kind answers it. */
|
|
697
|
+
const commandDescriptions = {
|
|
698
|
+
ping_agent: "a ping command",
|
|
699
|
+
expire_message: "an expiry command",
|
|
700
|
+
archive_agent: "an archive command",
|
|
701
|
+
};
|
|
702
|
+
const resultDescriptions = {
|
|
703
|
+
ping_accepted: "ping acceptance",
|
|
704
|
+
expiry_accepted: "expiry acceptance",
|
|
705
|
+
archive_accepted: "archive acceptance",
|
|
706
|
+
command_rejected: "rejection",
|
|
707
|
+
};
|
|
708
|
+
function validateCommandMember(value) {
|
|
709
|
+
const record = readRecord(value, "commandEnvelope.command");
|
|
710
|
+
switch (record.type) {
|
|
711
|
+
case "ping_agent":
|
|
712
|
+
exactFields({ record, required: ["type", "targetAgentId"], optional: [], path: "commandEnvelope.command" });
|
|
713
|
+
return { type: "ping_agent", targetAgentId: readAgentId(record.targetAgentId, "command.targetAgentId") };
|
|
714
|
+
case "expire_message":
|
|
715
|
+
exactFields({ record, required: ["type", "messageId"], optional: [], path: "commandEnvelope.command" });
|
|
716
|
+
return { type: "expire_message", messageId: readOpaqueId(record.messageId, "command.messageId") };
|
|
717
|
+
case "archive_agent":
|
|
718
|
+
exactFields({ record, required: ["type", "targetAgentId"], optional: [], path: "commandEnvelope.command" });
|
|
719
|
+
return { type: "archive_agent", targetAgentId: readAgentId(record.targetAgentId, "command.targetAgentId") };
|
|
720
|
+
default:
|
|
721
|
+
throw new OperatorValidationError("commandEnvelope.command.type", "unknown command");
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
function validatePublicJson(value, path, traversal = { seen: new WeakSet() }) {
|
|
725
|
+
if (value === null || typeof value === "boolean")
|
|
726
|
+
return value;
|
|
727
|
+
if (typeof value === "number") {
|
|
728
|
+
if (!Number.isFinite(value))
|
|
729
|
+
throw new OperatorValidationError(path, "must be a finite JSON number");
|
|
730
|
+
return value;
|
|
731
|
+
}
|
|
732
|
+
if (typeof value === "string")
|
|
733
|
+
return value;
|
|
734
|
+
if (Array.isArray(value)) {
|
|
735
|
+
enterPublicJsonContainer(value, path, traversal);
|
|
736
|
+
return readArray(value, path).map((entry, index) => validatePublicJson(entry, `${path}[${String(index)}]`, traversal));
|
|
737
|
+
}
|
|
738
|
+
return validatePublicJsonObject(value, path, traversal);
|
|
739
|
+
}
|
|
740
|
+
function validatePublicJsonObject(value, path, traversal = { seen: new WeakSet() }) {
|
|
741
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
742
|
+
throw new OperatorValidationError(path, "must be an object");
|
|
743
|
+
}
|
|
744
|
+
enterPublicJsonContainer(value, path, traversal);
|
|
745
|
+
const output = {};
|
|
746
|
+
for (const [key, entry] of readOwnEnumerableEntries(value, path)) {
|
|
747
|
+
Object.defineProperty(output, key, {
|
|
748
|
+
value: validatePublicJson(entry, `${path}.${key}`, traversal),
|
|
749
|
+
enumerable: true,
|
|
750
|
+
configurable: true,
|
|
751
|
+
writable: true,
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
return output;
|
|
755
|
+
}
|
|
756
|
+
function enterPublicJsonContainer(value, path, traversal) {
|
|
757
|
+
if (traversal.seen.has(value)) {
|
|
758
|
+
throw new OperatorValidationError(path, "contains a cyclic or repeated container reference");
|
|
759
|
+
}
|
|
760
|
+
traversal.seen.add(value);
|
|
761
|
+
}
|
|
762
|
+
function guardFail(path, reason) {
|
|
763
|
+
throw new OperatorValidationError(path, reason);
|
|
764
|
+
}
|
|
765
|
+
function readRecord(value, path) {
|
|
766
|
+
return guardReadRecord(value, path, guardFail);
|
|
767
|
+
}
|
|
768
|
+
function readArray(value, path) {
|
|
769
|
+
return guardReadArray(value, path, guardFail);
|
|
770
|
+
}
|
|
771
|
+
function readOwnEnumerableEntries(value, path) {
|
|
772
|
+
return guardReadOwnEnumerableEntries(value, path, guardFail);
|
|
773
|
+
}
|
|
774
|
+
function exactFields(check) {
|
|
775
|
+
guardExactFields({ ...check, fail: guardFail });
|
|
776
|
+
}
|
|
777
|
+
function readProtocolVersion(value, path) {
|
|
778
|
+
if (value !== OPERATOR_PROTOCOL_VERSION)
|
|
779
|
+
throw new OperatorValidationError(path, "unsupported protocol version");
|
|
780
|
+
return OPERATOR_PROTOCOL_VERSION;
|
|
781
|
+
}
|
|
782
|
+
function readNonemptyString(value, path) {
|
|
783
|
+
if (typeof value !== "string" || value.length === 0)
|
|
784
|
+
throw new OperatorValidationError(path, "must be non-empty text");
|
|
785
|
+
return value;
|
|
786
|
+
}
|
|
787
|
+
function readBoundedLine(value, limit, label, path) {
|
|
788
|
+
if (typeof value !== "string" || value.trim().length === 0 || value.length > limit) {
|
|
789
|
+
throw new OperatorValidationError(path, `must be a bounded one-line ${label}`);
|
|
790
|
+
}
|
|
791
|
+
if (hasControlCharacter(value))
|
|
792
|
+
throw new OperatorValidationError(path, `must be a bounded one-line ${label}`);
|
|
793
|
+
return value;
|
|
794
|
+
}
|
|
795
|
+
function readMessageSubject(value, path) {
|
|
796
|
+
return readBoundedLine(value, MESSAGE_SUBJECT_LIMIT_CHARACTERS, "subject", path);
|
|
797
|
+
}
|
|
798
|
+
function readMessageType(value, path) {
|
|
799
|
+
return readBoundedLine(value, MESSAGE_TYPE_LIMIT_CHARACTERS, "type", path);
|
|
800
|
+
}
|
|
801
|
+
function readBoolean(value, path) {
|
|
802
|
+
if (typeof value !== "boolean")
|
|
803
|
+
throw new OperatorValidationError(path, "must be a boolean");
|
|
804
|
+
return value;
|
|
805
|
+
}
|
|
806
|
+
function readLifecycleType(value, path) {
|
|
807
|
+
if (typeof value !== "string" || !/^[a-z][a-z0-9_]{0,127}$/u.test(value)) {
|
|
808
|
+
throw new OperatorValidationError(path, "invalid lifecycle type");
|
|
809
|
+
}
|
|
810
|
+
return value;
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Read a provider identity off the operator wire.
|
|
814
|
+
*
|
|
815
|
+
* Held to the broker's own registration rule, so it admits exactly the identities a registration can
|
|
816
|
+
* have written and enumerates no installed implementation.
|
|
817
|
+
*
|
|
818
|
+
* The consuming side maps this over `snapshot.agents` with no per-row catch, so one refused row costs
|
|
819
|
+
* the whole frame. The producer skips such a row before it reaches the wire, and
|
|
820
|
+
* {@link OPERATOR_PROTOCOL_VERSION} keeps peers that disagree on this rule from pairing.
|
|
821
|
+
* @param value - Untrusted candidate identity.
|
|
822
|
+
* @param path - Field path named in the rejection.
|
|
823
|
+
* @returns - The validated provider identity.
|
|
824
|
+
*/
|
|
825
|
+
function readOpenImplementationId(value, path) {
|
|
826
|
+
const id = readNonemptyString(value, path);
|
|
827
|
+
if (!isImplementationId(id))
|
|
828
|
+
throw new OperatorValidationError(path, "invalid provider identity");
|
|
829
|
+
return id;
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Read an agent identity off the operator wire.
|
|
833
|
+
*
|
|
834
|
+
* An id whose tail exceeds the protocol's bound carries the same legacy exposure, and the same
|
|
835
|
+
* version answer to it, as {@link readOpenImplementationId} describes.
|
|
836
|
+
*
|
|
837
|
+
* The consumers of {@link isAgentId} outside this projection — the notification, delivery-client and
|
|
838
|
+
* adapter paths — are unversioned and hard-fail their own delivery instead. That is the outcome an
|
|
839
|
+
* unaddressable id deserves there: a wake or a send aimed at an id nothing can route to has no
|
|
840
|
+
* degraded form worth reaching for.
|
|
841
|
+
* @param value - Untrusted candidate identity.
|
|
842
|
+
* @param path - Field path named in the rejection.
|
|
843
|
+
* @returns - The validated agent identity.
|
|
844
|
+
*/
|
|
845
|
+
function readAgentId(value, path) {
|
|
846
|
+
const id = readNonemptyString(value, path);
|
|
847
|
+
if (!isAgentId(id))
|
|
848
|
+
throw new OperatorValidationError(path, "invalid agent identity");
|
|
849
|
+
return id;
|
|
850
|
+
}
|
|
851
|
+
function readOpaqueId(value, path) {
|
|
852
|
+
const id = readNonemptyString(value, path);
|
|
853
|
+
if (!isOpaqueId(id))
|
|
854
|
+
throw new OperatorValidationError(path, "invalid opaque identity");
|
|
855
|
+
return id;
|
|
856
|
+
}
|
|
857
|
+
function readRejectionReason(value, path) {
|
|
858
|
+
const reason = readNonemptyString(value, path);
|
|
859
|
+
const known = OPERATOR_REJECTION_REASONS.find((candidate) => candidate === reason);
|
|
860
|
+
if (!known)
|
|
861
|
+
throw new OperatorValidationError(path, "unknown rejection reason");
|
|
862
|
+
return known;
|
|
863
|
+
}
|
|
864
|
+
function readStringArray(value, path) {
|
|
865
|
+
return readArray(value, path).map((entry, index) => readNonemptyString(entry, `${path}[${String(index)}]`));
|
|
866
|
+
}
|
|
867
|
+
function readInteger(value, path) {
|
|
868
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value)) {
|
|
869
|
+
throw new OperatorValidationError(path, "must be a safe integer");
|
|
870
|
+
}
|
|
871
|
+
return value;
|
|
872
|
+
}
|
|
873
|
+
function readNonnegativeInteger(value, path) {
|
|
874
|
+
const integer = readInteger(value, path);
|
|
875
|
+
if (integer < 0)
|
|
876
|
+
throw new OperatorValidationError(path, "must be non-negative");
|
|
877
|
+
return integer;
|
|
878
|
+
}
|
|
879
|
+
function readSequence(value, path) {
|
|
880
|
+
return readNonnegativeInteger(value, path);
|
|
881
|
+
}
|
|
882
|
+
function readPositiveSequence(value, path) {
|
|
883
|
+
const sequence = readInteger(value, path);
|
|
884
|
+
if (sequence < 1)
|
|
885
|
+
throw new OperatorValidationError(path, "must be positive");
|
|
886
|
+
return sequence;
|
|
887
|
+
}
|
|
888
|
+
function readTimestamp(value, path) {
|
|
889
|
+
return readNonnegativeInteger(value, path);
|
|
890
|
+
}
|
|
891
|
+
function readNullableTimestamp(value, path) {
|
|
892
|
+
return value === null ? null : readTimestamp(value, path);
|
|
893
|
+
}
|
|
894
|
+
function readAgentLifecycle(value, path) {
|
|
895
|
+
if (value === "active" || value === "terminal" || value === "reaped")
|
|
896
|
+
return value;
|
|
897
|
+
throw new OperatorValidationError(path, "unknown agent lifecycle");
|
|
898
|
+
}
|
|
899
|
+
function readMessageState(value, path) {
|
|
900
|
+
if (!MESSAGE_STATES.some((candidate) => candidate === value))
|
|
901
|
+
throw new OperatorValidationError(path, "unknown message state");
|
|
902
|
+
return value;
|
|
903
|
+
}
|
|
904
|
+
function assertLifecycleOrder(lifecycle, path) {
|
|
905
|
+
for (let index = 1; index < lifecycle.length; index += 1) {
|
|
906
|
+
const previous = lifecycle[index - 1];
|
|
907
|
+
const current = lifecycle[index];
|
|
908
|
+
if (previous && current && previous.id >= current.id) {
|
|
909
|
+
throw new OperatorValidationError(path, "must follow strict durable lifecycle-id order");
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
function assertUnique(values, path, label) {
|
|
914
|
+
if (new Set(values).size !== values.length)
|
|
915
|
+
throw new OperatorValidationError(path, `duplicate ${label}`);
|
|
916
|
+
}
|