blun-king-cli 9.1.563 → 9.1.565
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/agent-spine-plugin/.claude-plugin/marketplace.json +20 -20
- package/agent-spine-plugin/.claude-plugin/plugin.json +14 -14
- package/agent-spine-plugin/.codex-plugin/plugin.json +34 -34
- package/agent-spine-plugin/.mcp.json +8 -8
- package/agent-spine-plugin/CONTRIBUTING.md +52 -0
- package/agent-spine-plugin/LICENSE +186 -186
- package/agent-spine-plugin/README.md +394 -0
- package/agent-spine-plugin/SECURITY.md +47 -0
- package/agent-spine-plugin/assets/agentspine-banner.svg +32 -32
- package/agent-spine-plugin/bin/agentspine-mcp.js +4 -4
- package/agent-spine-plugin/bin/agentspine.js +7 -7
- package/agent-spine-plugin/blun.plugin.json +33 -33
- package/agent-spine-plugin/hooks/codex.json +47 -47
- package/agent-spine-plugin/hooks/hooks.json +106 -106
- package/agent-spine-plugin/hooks/version.json +5 -5
- package/agent-spine-plugin/package.json +69 -69
- package/agent-spine-plugin/scripts/check-hosts.js +199 -199
- package/agent-spine-plugin/skill/SKILL.md +76 -76
- package/agent-spine-plugin/skills/agent-spine/SKILL.md +85 -85
- package/agent-spine-plugin/src/cli.js +1488 -1442
- package/agent-spine-plugin/src/hook.js +886 -812
- package/agent-spine-plugin/src/index.js +93 -93
- package/agent-spine-plugin/src/lib/acceptance.js +333 -333
- package/agent-spine-plugin/src/lib/attention.js +755 -755
- package/agent-spine-plugin/src/lib/audit.js +351 -342
- package/agent-spine-plugin/src/lib/authentication.js +515 -515
- package/agent-spine-plugin/src/lib/briefing.js +317 -317
- package/agent-spine-plugin/src/lib/catalog.js +167 -167
- package/agent-spine-plugin/src/lib/channel-runtime.js +665 -665
- package/agent-spine-plugin/src/lib/context.js +154 -154
- package/agent-spine-plugin/src/lib/continuity.js +338 -338
- package/agent-spine-plugin/src/lib/coordination.js +577 -577
- package/agent-spine-plugin/src/lib/documents.js +217 -217
- package/agent-spine-plugin/src/lib/feed-transport.js +499 -499
- package/agent-spine-plugin/src/lib/filesystem-retry.js +32 -32
- package/agent-spine-plugin/src/lib/gateway-runtime.js +1119 -760
- package/agent-spine-plugin/src/lib/graph.js +337 -337
- package/agent-spine-plugin/src/lib/hook-audit.js +29 -0
- package/agent-spine-plugin/src/lib/https-transport.js +392 -392
- package/agent-spine-plugin/src/lib/indexed-memory-offline.js +40 -40
- package/agent-spine-plugin/src/lib/indexed-memory.js +281 -281
- package/agent-spine-plugin/src/lib/learning.js +6923 -6466
- package/agent-spine-plugin/src/lib/object-transport.js +206 -206
- package/agent-spine-plugin/src/lib/owned-file-lock.js +143 -143
- package/agent-spine-plugin/src/lib/paths.js +109 -109
- package/agent-spine-plugin/src/lib/peer-transport.js +283 -283
- package/agent-spine-plugin/src/lib/persona-runtime.js +581 -581
- package/agent-spine-plugin/src/lib/preflight.js +702 -702
- package/agent-spine-plugin/src/lib/runtime.js +13 -13
- package/agent-spine-plugin/src/lib/selfstarter.js +892 -819
- package/agent-spine-plugin/src/lib/sharing.js +969 -969
- package/agent-spine-plugin/src/lib/source-roots.js +529 -482
- package/agent-spine-plugin/src/lib/sqlite-transport.js +501 -501
- package/agent-spine-plugin/src/lib/telegram-adapter.js +119 -119
- package/agent-spine-plugin/src/lib/voice-runtime.js +39 -39
- package/agent-spine-plugin/src/mcp.js +597 -572
- package/agent-spine-plugin/src/version.js +1 -1
- package/agent-spine-plugin/src/worker.js +202 -195
- package/bin/active-steer-priority-policy.cjs +24 -0
- package/bin/launcher-runtime.js +8 -1
- package/bin/mnemo-tool-agent-policy.cjs +22 -0
- package/bin/thinking-activity-status-policy.cjs +132 -0
- package/bin/thinking-only-guard.cjs +75 -0
- package/bin/tool-call-loop-policy.cjs +53 -0
- package/bin/turn-thinking-policy.cjs +25 -1
- package/blun.mjs +554 -64
- package/package.json +4 -1
- package/standard-skills/translate-native/LICENSE +21 -21
- package/standard-skills/translate-native/references/evaluation-protocol.md +95 -95
- package/standard-skills/translate-native/references/native-orthography.md +79 -79
- package/standard-skills/translate-native/references/native-translation-standard.md +94 -94
- package/standard-skills/translate-native/references/structured-content.md +72 -72
- package/standard-skills/translate-native/references/translationese-review.md +77 -77
- package/standard-skills/translate-native/scripts/blun_language_guard.py +697 -697
- package/standard-skills/translate-native/scripts/check_diacritics.py +353 -353
- package/standard-skills/translate-native/scripts/guard_service_client.py +264 -264
- package/standard-skills/translate-native/scripts/language_gateway.py +62 -62
- package/standard-skills/translate-native/scripts/language_quality.py +377 -377
- package/standard-skills/translate-native/scripts/pre_output_guard.py +64 -64
- package/standard-skills/translate-native/scripts/translation_guard.py +916 -916
- package/standard-tools/language-guard/blun_language_guard.py +697 -697
- package/standard-tools/language-guard/check_diacritics.py +353 -353
- package/standard-tools/language-guard/guard_service_client.py +264 -264
- package/standard-tools/language-guard/language_quality.py +377 -377
- package/standard-tools/language-guard/translation_guard.py +916 -916
|
@@ -1,665 +1,665 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createHash, createHmac, randomUUID, timingSafeEqual
|
|
3
|
-
} from "node:crypto";
|
|
4
|
-
import {
|
|
5
|
-
open, readFile, rename, stat, unlink, writeFile
|
|
6
|
-
} from "node:fs/promises";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import { buildCatalog } from "./catalog.js";
|
|
9
|
-
import { isFileLockContention } from "./filesystem-retry.js";
|
|
10
|
-
import { loadGraph } from "./graph.js";
|
|
11
|
-
import { projectStateDir } from "./paths.js";
|
|
12
|
-
|
|
13
|
-
const POLICY_SCHEMA = "agentspine.channel-policy/v1";
|
|
14
|
-
const RUNTIME_SCHEMA = "agentspine.channel-runtime/v1";
|
|
15
|
-
const EVENT_SCHEMA = "agentspine.channel-event/v1";
|
|
16
|
-
const CONFIRMATION = "local-owner-confirmed";
|
|
17
|
-
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
18
|
-
const MAX_TEXT_BYTES = 16 * 1024;
|
|
19
|
-
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/+~-]{0,255}$/;
|
|
20
|
-
const ROUTE_ID_RE = /^[A-Za-z0-9_+~-][A-Za-z0-9:_.@/+~-]{0,255}$/;
|
|
21
|
-
const ENV_RE = /^[A-Z][A-Z0-9_]{2,127}$/;
|
|
22
|
-
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
23
|
-
const SIGNATURE_RE = /^sha256=([a-f0-9]{64})$/;
|
|
24
|
-
const PRIVACY = new Set(["private", "shared", "group"]);
|
|
25
|
-
const CAPABILITIES = new Set(["receive", "reply"]);
|
|
26
|
-
const BINDING_STATUSES = new Set(["active", "revoked"]);
|
|
27
|
-
const EVENT_STATUSES = new Set(["pending", "leased", "completed", "failed", "cancelled"]);
|
|
28
|
-
const TERMINAL_STATUSES = new Set(["completed", "cancelled"]);
|
|
29
|
-
const INBOUND_FIELDS = new Set([
|
|
30
|
-
"schema", "eventId", "provider", "tenantId", "accountId", "chatId", "threadId",
|
|
31
|
-
"senderId", "replyTo", "observedAt", "privacy", "text"
|
|
32
|
-
]);
|
|
33
|
-
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret|passwort|geheimnis)\s*[:=]\s*\S{8,}/i;
|
|
34
|
-
|
|
35
|
-
function emptyPolicy(root) {
|
|
36
|
-
return { schema: POLICY_SCHEMA, root, revision: 0, bindings: [], history: [] };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function emptyRuntime(root) {
|
|
40
|
-
return { schema: RUNTIME_SCHEMA, root, events: [], history: [], receipts: [] };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function stableId(value, field, { nullable = false, route = false } = {}) {
|
|
44
|
-
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
45
|
-
const expression = route ? ROUTE_ID_RE : ID_RE;
|
|
46
|
-
if (typeof value !== "string" || !expression.test(value) || value.includes("*")) {
|
|
47
|
-
throw new Error(`${field} must be an exact stable ID without wildcards`);
|
|
48
|
-
}
|
|
49
|
-
return value;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function timestamp(value, field = "timestamp") {
|
|
53
|
-
const parsed = value instanceof Date ? value : new Date(value);
|
|
54
|
-
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
55
|
-
return parsed.toISOString();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function safeText(value, field, maximumBytes = MAX_TEXT_BYTES) {
|
|
59
|
-
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
|
60
|
-
const normalized = value.trim().replace(/\r\n?/g, "\n");
|
|
61
|
-
if (Buffer.byteLength(normalized) > maximumBytes) throw new Error(`${field} exceeds ${maximumBytes} bytes`);
|
|
62
|
-
if (SECRET_RE.test(normalized)) throw new Error(`${field} appears to contain a secret and cannot enter channel state`);
|
|
63
|
-
return normalized;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function exactCapabilities(value) {
|
|
67
|
-
if (!Array.isArray(value) || !value.length) throw new Error("channel capabilities are required");
|
|
68
|
-
const unique = [...new Set(value)].sort();
|
|
69
|
-
if (unique.some((item) => !CAPABILITIES.has(item))) throw new Error("channel capabilities may contain only receive and reply");
|
|
70
|
-
if (!unique.includes("receive")) throw new Error("channel bindings must include receive");
|
|
71
|
-
return unique;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function exactSenderIds(value) {
|
|
75
|
-
if (!Array.isArray(value) || !value.length) throw new Error("senderIds must contain exact authenticated sender IDs");
|
|
76
|
-
return [...new Set(value.map((item) => stableId(item, "senderId", { route: true })))].sort();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function sha256(value) {
|
|
80
|
-
return createHash("sha256").update(value).digest("hex");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function bindingRoute(binding) {
|
|
84
|
-
return [binding.provider, binding.tenantId, binding.accountId, binding.chatId, binding.threadId || ""].join("\0");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function normalizeInboundEvent(value, { inspectContent = false } = {}) {
|
|
88
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("channel event must be one object");
|
|
89
|
-
const unknown = Object.keys(value).filter((key) => !INBOUND_FIELDS.has(key));
|
|
90
|
-
if (unknown.length) throw new Error(`channel event contains unknown field: ${unknown.sort()[0]}`);
|
|
91
|
-
if (value.schema !== EVENT_SCHEMA) throw new Error(`channel event schema must be ${EVENT_SCHEMA}`);
|
|
92
|
-
if (!PRIVACY.has(value.privacy)) throw new Error("channel event privacy must be private, shared, or group");
|
|
93
|
-
const text = inspectContent ? safeText(value.text, "channel event text") : String(value.text ?? "").trim().replace(/\r\n?/g, "\n");
|
|
94
|
-
if (!text || Buffer.byteLength(text) > MAX_TEXT_BYTES) throw new Error(`channel event text must be 1-${MAX_TEXT_BYTES} bytes`);
|
|
95
|
-
return {
|
|
96
|
-
schema: EVENT_SCHEMA,
|
|
97
|
-
eventId: stableId(value.eventId, "eventId"),
|
|
98
|
-
provider: stableId(value.provider, "provider", { route: true }),
|
|
99
|
-
tenantId: stableId(value.tenantId, "tenantId", { route: true }),
|
|
100
|
-
accountId: stableId(value.accountId, "accountId", { route: true }),
|
|
101
|
-
chatId: stableId(value.chatId, "chatId", { route: true }),
|
|
102
|
-
threadId: stableId(value.threadId, "threadId", { nullable: true, route: true }),
|
|
103
|
-
senderId: stableId(value.senderId, "senderId", { route: true }),
|
|
104
|
-
replyTo: stableId(value.replyTo, "replyTo", { nullable: true, route: true }),
|
|
105
|
-
observedAt: timestamp(value.observedAt, "observedAt"),
|
|
106
|
-
privacy: value.privacy,
|
|
107
|
-
text
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export function channelEventSigningPayload(event) {
|
|
112
|
-
return JSON.stringify(normalizeInboundEvent(event));
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function validBinding(binding) {
|
|
116
|
-
return binding && ID_RE.test(binding.id || "") && BINDING_STATUSES.has(binding.status)
|
|
117
|
-
&& ROUTE_ID_RE.test(binding.provider || "") && ROUTE_ID_RE.test(binding.tenantId || "")
|
|
118
|
-
&& ROUTE_ID_RE.test(binding.accountId || "") && ROUTE_ID_RE.test(binding.chatId || "")
|
|
119
|
-
&& (binding.threadId === null || ROUTE_ID_RE.test(binding.threadId || ""))
|
|
120
|
-
&& Array.isArray(binding.senderIds) && binding.senderIds.length > 0
|
|
121
|
-
&& binding.senderIds.every((id) => ROUTE_ID_RE.test(id) && !id.includes("*"))
|
|
122
|
-
&& ID_RE.test(binding.agentId || "") && ID_RE.test(binding.projectId || "")
|
|
123
|
-
&& (binding.groupId === null || ID_RE.test(binding.groupId || ""))
|
|
124
|
-
&& ID_RE.test(binding.sessionKey || "") && ENV_RE.test(binding.secretEnv || "")
|
|
125
|
-
&& (binding.outboundSecretEnv === null || ENV_RE.test(binding.outboundSecretEnv || ""))
|
|
126
|
-
&& Array.isArray(binding.capabilities) && binding.capabilities.includes("receive")
|
|
127
|
-
&& binding.capabilities.every((item) => CAPABILITIES.has(item))
|
|
128
|
-
&& binding.authority === "explicit-local-channel-policy"
|
|
129
|
-
&& Number.isFinite(new Date(binding.createdAt).getTime())
|
|
130
|
-
&& Number.isFinite(new Date(binding.updatedAt).getTime())
|
|
131
|
-
&& (binding.revokedAt === null || Number.isFinite(new Date(binding.revokedAt).getTime()));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function validEvent(event) {
|
|
135
|
-
return event && event.schema === EVENT_SCHEMA && ID_RE.test(event.eventId || "")
|
|
136
|
-
&& ID_RE.test(event.bindingId || "") && ROUTE_ID_RE.test(event.provider || "")
|
|
137
|
-
&& ROUTE_ID_RE.test(event.tenantId || "") && ROUTE_ID_RE.test(event.accountId || "")
|
|
138
|
-
&& ROUTE_ID_RE.test(event.chatId || "") && (event.threadId === null || ROUTE_ID_RE.test(event.threadId || ""))
|
|
139
|
-
&& ROUTE_ID_RE.test(event.senderId || "") && (event.replyTo === null || ROUTE_ID_RE.test(event.replyTo || ""))
|
|
140
|
-
&& ID_RE.test(event.agentId || "") && ID_RE.test(event.projectId || "")
|
|
141
|
-
&& (event.groupId === null || ID_RE.test(event.groupId || "")) && ID_RE.test(event.sessionKey || "")
|
|
142
|
-
&& PRIVACY.has(event.privacy) && typeof event.text === "string" && event.text.length > 0
|
|
143
|
-
&& Buffer.byteLength(event.text) <= MAX_TEXT_BYTES && !SECRET_RE.test(event.text)
|
|
144
|
-
&& EVENT_STATUSES.has(event.status) && Number.isInteger(event.attempts) && event.attempts >= 0
|
|
145
|
-
&& event.authority === "execution-state-only" && DIGEST_RE.test(event.payloadDigest || "")
|
|
146
|
-
&& Number.isFinite(new Date(event.observedAt).getTime()) && Number.isFinite(new Date(event.receivedAt).getTime())
|
|
147
|
-
&& (event.completedAt === null || Number.isFinite(new Date(event.completedAt).getTime()))
|
|
148
|
-
&& (event.lease === null || (
|
|
149
|
-
event.status === "leased" && ID_RE.test(event.lease.workerId || "")
|
|
150
|
-
&& Number.isFinite(new Date(event.lease.claimedAt).getTime())
|
|
151
|
-
&& Number.isFinite(new Date(event.lease.expiresAt).getTime())
|
|
152
|
-
));
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function validReceipt(receipt) {
|
|
156
|
-
if (!(receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
|
|
157
|
-
&& typeof receipt.event === "string" && DIGEST_RE.test(receipt.digest || "")
|
|
158
|
-
&& Number.isFinite(new Date(receipt.at).getTime()) && receipt.authority === "execution-state-only")) return false;
|
|
159
|
-
const { id, digest, ...material } = receipt;
|
|
160
|
-
return id === `channel-receipt:${digest.slice(0, 24)}` && sha256(JSON.stringify(material)) === digest;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function validPolicyHistory(record) {
|
|
164
|
-
return record && record.kind === "channel-binding" && ID_RE.test(record.recordId || "")
|
|
165
|
-
&& record.recordId === record.value?.id && validBinding(record.value)
|
|
166
|
-
&& Number.isFinite(new Date(record.supersededAt).getTime())
|
|
167
|
-
&& record.authority === "explicit-local-channel-policy";
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function validRuntimeHistory(record) {
|
|
171
|
-
return record && record.kind === "channel-event" && ID_RE.test(record.recordId || "")
|
|
172
|
-
&& record.recordId === record.value?.eventId && validEvent(record.value)
|
|
173
|
-
&& typeof record.transition === "string" && record.transition.length > 0
|
|
174
|
-
&& Number.isFinite(new Date(record.supersededAt).getTime())
|
|
175
|
-
&& record.authority === "execution-state-only";
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function validPayloadDigest(event) {
|
|
179
|
-
if (!validEvent(event)) return false;
|
|
180
|
-
const inbound = Object.fromEntries([...INBOUND_FIELDS].map((field) => [field, event[field]]));
|
|
181
|
-
return sha256(channelEventSigningPayload(inbound)) === event.payloadDigest;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
function normalizePolicy(value, root) {
|
|
185
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== POLICY_SCHEMA
|
|
186
|
-
|| value.root !== root || !Number.isInteger(value.revision) || value.revision < 0
|
|
187
|
-
|| !Array.isArray(value.bindings) || !Array.isArray(value.history)) {
|
|
188
|
-
throw new Error("channel policy structure is invalid; channel runtime is disabled until repaired");
|
|
189
|
-
}
|
|
190
|
-
return value;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function normalizeRuntime(value, root) {
|
|
194
|
-
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== RUNTIME_SCHEMA
|
|
195
|
-
|| value.root !== root || !Array.isArray(value.events) || !Array.isArray(value.history)
|
|
196
|
-
|| !Array.isArray(value.receipts)) {
|
|
197
|
-
throw new Error("channel runtime structure is invalid; channel processing is disabled until repaired");
|
|
198
|
-
}
|
|
199
|
-
return value;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
async function readJson(path, root, normalize, empty) {
|
|
203
|
-
try {
|
|
204
|
-
const metadata = await stat(path);
|
|
205
|
-
if (metadata.size > MAX_STATE_BYTES) throw new Error("channel state exceeds the 5 MiB read limit");
|
|
206
|
-
return normalize(JSON.parse(await readFile(path, "utf8")), root);
|
|
207
|
-
} catch (error) {
|
|
208
|
-
if (error.code !== "ENOENT") throw error;
|
|
209
|
-
return empty(root);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async function writeJson(path, value) {
|
|
214
|
-
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
215
|
-
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("channel state exceeds 5 MiB; purge terminal events first");
|
|
216
|
-
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
217
|
-
try {
|
|
218
|
-
await writeFile(temporary, content, { mode: 0o600 });
|
|
219
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
220
|
-
try {
|
|
221
|
-
await rename(temporary, path);
|
|
222
|
-
break;
|
|
223
|
-
} catch (error) {
|
|
224
|
-
if (attempt >= 5 || !["EACCES", "EPERM"].includes(error.code)) throw error;
|
|
225
|
-
await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
} finally {
|
|
229
|
-
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function pathsFor(root, providedCatalog = null) {
|
|
234
|
-
const catalog = providedCatalog || await buildCatalog(root);
|
|
235
|
-
const directory = await projectStateDir(catalog.root);
|
|
236
|
-
return {
|
|
237
|
-
catalog, directory,
|
|
238
|
-
channelPolicyPath: join(directory, "channel-policy.json"),
|
|
239
|
-
channelRuntimePath: join(directory, "channel-runtime.json")
|
|
240
|
-
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function withChannelLock(paths, task) {
|
|
244
|
-
const lockPath = join(paths.directory, "channel-runtime.lock");
|
|
245
|
-
let handle;
|
|
246
|
-
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
247
|
-
try {
|
|
248
|
-
handle = await open(lockPath, "wx", 0o600);
|
|
249
|
-
break;
|
|
250
|
-
} catch (error) {
|
|
251
|
-
if (!isFileLockContention(error)) throw error;
|
|
252
|
-
try {
|
|
253
|
-
const metadata = await stat(lockPath);
|
|
254
|
-
if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
|
|
255
|
-
} catch (lockError) {
|
|
256
|
-
if (lockError.code !== "ENOENT") throw lockError;
|
|
257
|
-
}
|
|
258
|
-
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
if (!handle) throw new Error("channel state is busy; retry later");
|
|
262
|
-
try {
|
|
263
|
-
return await task();
|
|
264
|
-
} finally {
|
|
265
|
-
await handle.close();
|
|
266
|
-
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function requireConfirmation(value) {
|
|
271
|
-
if (value !== CONFIRMATION) throw new Error("channel policy changes require explicit local owner confirmation");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function referenceFindings(policy, runtime, graph) {
|
|
275
|
-
const findings = [];
|
|
276
|
-
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
277
|
-
const bindings = new Map();
|
|
278
|
-
const routes = new Map();
|
|
279
|
-
for (const binding of policy.bindings) {
|
|
280
|
-
if (!validBinding(binding)) findings.push(`invalid-binding:${binding?.id || "unknown"}`);
|
|
281
|
-
if (bindings.has(binding.id)) findings.push(`duplicate-binding:${binding.id}`);
|
|
282
|
-
bindings.set(binding.id, binding);
|
|
283
|
-
if (binding.status === "active") {
|
|
284
|
-
const route = bindingRoute(binding);
|
|
285
|
-
if (routes.has(route)) findings.push(`duplicate-active-route:${binding.id}`);
|
|
286
|
-
routes.set(route, binding.id);
|
|
287
|
-
}
|
|
288
|
-
if (entities.get(binding.agentId)?.kind !== "agent") findings.push(`binding-agent-mismatch:${binding.id}`);
|
|
289
|
-
if (entities.get(binding.projectId)?.kind !== "project") findings.push(`binding-project-mismatch:${binding.id}`);
|
|
290
|
-
if (binding.groupId !== null) {
|
|
291
|
-
if (entities.get(binding.groupId)?.kind !== "group") findings.push(`binding-group-mismatch:${binding.id}`);
|
|
292
|
-
const member = graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private"
|
|
293
|
-
&& ((edge.from === binding.agentId && edge.to === binding.groupId)
|
|
294
|
-
|| (edge.to === binding.agentId && edge.from === binding.groupId)));
|
|
295
|
-
if (!member) findings.push(`binding-group-membership-mismatch:${binding.id}`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
for (const record of policy.history) {
|
|
299
|
-
if (!validPolicyHistory(record)) findings.push(`invalid-binding-history:${record?.recordId || "unknown"}`);
|
|
300
|
-
}
|
|
301
|
-
const eventIds = new Set();
|
|
302
|
-
for (const event of runtime.events) {
|
|
303
|
-
if (!validEvent(event)) findings.push(`invalid-channel-event:${event?.eventId || "unknown"}`);
|
|
304
|
-
else if (!validPayloadDigest(event)) findings.push(`channel-event-digest-mismatch:${event.eventId}`);
|
|
305
|
-
if (eventIds.has(event.eventId)) findings.push(`duplicate-channel-event:${event.eventId}`);
|
|
306
|
-
eventIds.add(event.eventId);
|
|
307
|
-
const binding = bindings.get(event.bindingId);
|
|
308
|
-
if (!binding || [event.provider, event.tenantId, event.accountId, event.chatId, event.threadId || ""].join("\0") !== bindingRoute(binding)
|
|
309
|
-
|| event.agentId !== binding?.agentId || event.projectId !== binding?.projectId
|
|
310
|
-
|| event.groupId !== binding?.groupId || event.sessionKey !== binding?.sessionKey
|
|
311
|
-
|| !binding?.senderIds.includes(event.senderId)) findings.push(`event-binding-mismatch:${event.eventId}`);
|
|
312
|
-
}
|
|
313
|
-
for (const record of runtime.history) {
|
|
314
|
-
if (!validRuntimeHistory(record)) findings.push(`invalid-channel-history:${record?.recordId || "unknown"}`);
|
|
315
|
-
else if (!validPayloadDigest(record.value)) findings.push(`channel-history-digest-mismatch:${record.recordId}`);
|
|
316
|
-
}
|
|
317
|
-
const receiptIds = new Set();
|
|
318
|
-
for (const receipt of runtime.receipts) {
|
|
319
|
-
if (!validReceipt(receipt)) findings.push(`invalid-channel-receipt:${receipt?.id || "unknown"}`);
|
|
320
|
-
if (receiptIds.has(receipt.id)) findings.push(`duplicate-channel-receipt:${receipt.id}`);
|
|
321
|
-
if (!eventIds.has(receipt.eventId)) findings.push(`orphan-channel-receipt:${receipt.id}`);
|
|
322
|
-
receiptIds.add(receipt.id);
|
|
323
|
-
}
|
|
324
|
-
return findings;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export function channelPolicyFindings(policy, graph) {
|
|
328
|
-
return referenceFindings(policy, emptyRuntime(policy.root), graph).filter((item) => item.includes("binding") || item.includes("route"));
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
export function channelRuntimeFindings(runtime, policy, graph) {
|
|
332
|
-
return referenceFindings(policy, runtime, graph);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export async function loadChannelPolicy(root = process.cwd(), providedCatalog = null) {
|
|
336
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
337
|
-
return { policy: await readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), ...paths };
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
export async function loadChannelRuntime(root = process.cwd(), providedCatalog = null) {
|
|
341
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
342
|
-
return { runtime: await readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime), ...paths };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export async function inspectChannelRuntime(root = process.cwd(), providedCatalog = null) {
|
|
346
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
347
|
-
let policy = emptyPolicy(paths.catalog.root);
|
|
348
|
-
let runtime = emptyRuntime(paths.catalog.root);
|
|
349
|
-
const errors = [];
|
|
350
|
-
try { policy = await readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
|
|
351
|
-
try { runtime = await readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime); } catch (error) { errors.push(`runtime:${error.message}`); }
|
|
352
|
-
return { policy, runtime, errors, ...paths };
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
function preserveBinding(policy, binding, at) {
|
|
356
|
-
policy.history.push({
|
|
357
|
-
kind: "channel-binding", recordId: binding.id, supersededAt: at,
|
|
358
|
-
value: structuredClone(binding), authority: "explicit-local-channel-policy"
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function preserveEvent(runtime, event, transition, at) {
|
|
363
|
-
runtime.history.push({
|
|
364
|
-
kind: "channel-event", recordId: event.eventId, transition, supersededAt: at,
|
|
365
|
-
value: structuredClone(event), authority: "execution-state-only"
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function appendReceipt(runtime, eventId, event, at, details = {}) {
|
|
370
|
-
const material = { eventId, event, at, details, authority: "execution-state-only" };
|
|
371
|
-
const digest = sha256(JSON.stringify(material));
|
|
372
|
-
const id = `channel-receipt:${digest.slice(0, 24)}`;
|
|
373
|
-
const existing = runtime.receipts.find((receipt) => receipt.id === id);
|
|
374
|
-
if (existing) return { receipt: existing, duplicate: true };
|
|
375
|
-
const receipt = { id, ...material, digest };
|
|
376
|
-
runtime.receipts.push(receipt);
|
|
377
|
-
return { receipt, duplicate: false };
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function ensureHealthy(policy, runtime, graph) {
|
|
381
|
-
const findings = referenceFindings(policy, runtime, graph);
|
|
382
|
-
if (findings.length) throw new Error(`channel runtime failed closed: ${findings.join(", ")}`);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
export async function grantChannelBinding({
|
|
386
|
-
root = process.cwd(), id = `channel-binding:${randomUUID()}`, provider, tenantId, accountId,
|
|
387
|
-
chatId, threadId = null, senderIds, agentId, projectId, groupId = null, sessionKey,
|
|
388
|
-
secretEnv, outboundSecretEnv = null, capabilities = ["receive"], confirmation, now = new Date()
|
|
389
|
-
}) {
|
|
390
|
-
requireConfirmation(confirmation);
|
|
391
|
-
const paths = await pathsFor(root);
|
|
392
|
-
return withChannelLock(paths, async () => {
|
|
393
|
-
const [policy, runtime, { graph }] = await Promise.all([
|
|
394
|
-
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
395
|
-
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
396
|
-
loadGraph(paths.catalog.root, paths.catalog)
|
|
397
|
-
]);
|
|
398
|
-
ensureHealthy(policy, runtime, graph);
|
|
399
|
-
const at = timestamp(now, "now");
|
|
400
|
-
const binding = {
|
|
401
|
-
id: stableId(id, "bindingId"),
|
|
402
|
-
provider: stableId(provider, "provider", { route: true }),
|
|
403
|
-
tenantId: stableId(tenantId, "tenantId", { route: true }),
|
|
404
|
-
accountId: stableId(accountId, "accountId", { route: true }),
|
|
405
|
-
chatId: stableId(chatId, "chatId", { route: true }),
|
|
406
|
-
threadId: stableId(threadId, "threadId", { nullable: true, route: true }),
|
|
407
|
-
senderIds: exactSenderIds(senderIds),
|
|
408
|
-
agentId: stableId(agentId, "agentId"), projectId: stableId(projectId, "projectId"),
|
|
409
|
-
groupId: stableId(groupId, "groupId", { nullable: true }),
|
|
410
|
-
sessionKey: stableId(sessionKey, "sessionKey"),
|
|
411
|
-
secretEnv: ENV_RE.test(secretEnv || "") ? secretEnv : (() => { throw new Error("secretEnv must be an uppercase environment variable name"); })(),
|
|
412
|
-
outboundSecretEnv: outboundSecretEnv === null ? null : (ENV_RE.test(outboundSecretEnv || "")
|
|
413
|
-
? outboundSecretEnv : (() => { throw new Error("outboundSecretEnv must be an uppercase environment variable name"); })()),
|
|
414
|
-
capabilities: exactCapabilities(capabilities), status: "active", createdAt: at, updatedAt: at,
|
|
415
|
-
revokedAt: null, revokeReason: null, authority: "explicit-local-channel-policy"
|
|
416
|
-
};
|
|
417
|
-
const agent = graph.entities.find((entity) => entity.id === binding.agentId && entity.kind === "agent");
|
|
418
|
-
const project = graph.entities.find((entity) => entity.id === binding.projectId && entity.kind === "project");
|
|
419
|
-
if (binding.capabilities.includes("reply") && !binding.outboundSecretEnv) {
|
|
420
|
-
throw new Error("reply capability requires an environment-only outbound adapter credential name");
|
|
421
|
-
}
|
|
422
|
-
if (!agent) throw new Error(`unknown agent entity: ${binding.agentId}`);
|
|
423
|
-
if (!project) throw new Error(`unknown project entity: ${binding.projectId}`);
|
|
424
|
-
if (binding.groupId !== null) {
|
|
425
|
-
if (!graph.entities.some((entity) => entity.id === binding.groupId && entity.kind === "group")) throw new Error(`unknown group entity: ${binding.groupId}`);
|
|
426
|
-
const member = graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private"
|
|
427
|
-
&& ((edge.from === binding.agentId && edge.to === binding.groupId)
|
|
428
|
-
|| (edge.to === binding.agentId && edge.from === binding.groupId)));
|
|
429
|
-
if (!member) throw new Error("channel binding agent must be a visible member of its exact group");
|
|
430
|
-
}
|
|
431
|
-
const activeRoute = policy.bindings.find((item) => item.status === "active" && bindingRoute(item) === bindingRoute(binding) && item.id !== binding.id);
|
|
432
|
-
if (activeRoute) throw new Error(`channel route is already bound by ${activeRoute.id}`);
|
|
433
|
-
const previous = policy.bindings.find((item) => item.id === binding.id);
|
|
434
|
-
if (previous) preserveBinding(policy, previous, at);
|
|
435
|
-
policy.bindings = policy.bindings.filter((item) => item.id !== binding.id);
|
|
436
|
-
policy.bindings.push(binding);
|
|
437
|
-
policy.bindings.sort((left, right) => left.id.localeCompare(right.id));
|
|
438
|
-
policy.revision += 1;
|
|
439
|
-
ensureHealthy(policy, runtime, graph);
|
|
440
|
-
await writeJson(paths.channelPolicyPath, policy);
|
|
441
|
-
return { binding, revision: policy.revision, channelPolicyPath: paths.channelPolicyPath };
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
export async function revokeChannelBinding({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
|
|
446
|
-
requireConfirmation(confirmation);
|
|
447
|
-
const paths = await pathsFor(root);
|
|
448
|
-
return withChannelLock(paths, async () => {
|
|
449
|
-
const [policy, runtime, { graph }] = await Promise.all([
|
|
450
|
-
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
451
|
-
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
452
|
-
loadGraph(paths.catalog.root, paths.catalog)
|
|
453
|
-
]);
|
|
454
|
-
ensureHealthy(policy, runtime, graph);
|
|
455
|
-
const binding = policy.bindings.find((item) => item.id === stableId(id, "bindingId"));
|
|
456
|
-
if (!binding) throw new Error(`unknown channel binding: ${id}`);
|
|
457
|
-
const at = timestamp(now, "now");
|
|
458
|
-
if (binding.status === "revoked") return { binding, duplicate: true };
|
|
459
|
-
preserveBinding(policy, binding, at);
|
|
460
|
-
binding.status = "revoked";
|
|
461
|
-
binding.revokedAt = at;
|
|
462
|
-
binding.updatedAt = at;
|
|
463
|
-
binding.revokeReason = safeText(reason, "revoke reason", 1000);
|
|
464
|
-
for (const event of runtime.events.filter((item) => item.bindingId === binding.id && !TERMINAL_STATUSES.has(item.status))) {
|
|
465
|
-
preserveEvent(runtime, event, "binding-revoked", at);
|
|
466
|
-
event.status = "cancelled";
|
|
467
|
-
event.lease = null;
|
|
468
|
-
event.completedAt = at;
|
|
469
|
-
appendReceipt(runtime, event.eventId, "cancelled", at, { reason: "binding-revoked" });
|
|
470
|
-
}
|
|
471
|
-
policy.revision += 1;
|
|
472
|
-
ensureHealthy(policy, runtime, graph);
|
|
473
|
-
await Promise.all([writeJson(paths.channelPolicyPath, policy), writeJson(paths.channelRuntimePath, runtime)]);
|
|
474
|
-
return { binding, duplicate: false, revision: policy.revision };
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
function verifySignature(event, signature, binding, env) {
|
|
479
|
-
const match = String(signature || "").match(SIGNATURE_RE);
|
|
480
|
-
if (!match) throw new Error("channel event signature is missing or invalid");
|
|
481
|
-
const secret = env?.[binding.secretEnv];
|
|
482
|
-
if (typeof secret !== "string" || Buffer.byteLength(secret) < 32) throw new Error(`channel adapter secret ${binding.secretEnv} is unavailable or too short`);
|
|
483
|
-
const expected = createHmac("sha256", secret).update(channelEventSigningPayload(event)).digest();
|
|
484
|
-
const supplied = Buffer.from(match[1], "hex");
|
|
485
|
-
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error("channel event signature verification failed");
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
export async function ingestChannelEvent({ root = process.cwd(), event, signature, env = process.env, now = new Date() }) {
|
|
489
|
-
const normalized = normalizeInboundEvent(event, { inspectContent: true });
|
|
490
|
-
const paths = await pathsFor(root);
|
|
491
|
-
return withChannelLock(paths, async () => {
|
|
492
|
-
const [policy, runtime, { graph }] = await Promise.all([
|
|
493
|
-
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
494
|
-
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
495
|
-
loadGraph(paths.catalog.root, paths.catalog)
|
|
496
|
-
]);
|
|
497
|
-
ensureHealthy(policy, runtime, graph);
|
|
498
|
-
const route = [normalized.provider, normalized.tenantId, normalized.accountId, normalized.chatId, normalized.threadId || ""].join("\0");
|
|
499
|
-
const routeBindings = policy.bindings.filter((binding) => binding.status === "active" && bindingRoute(binding) === route);
|
|
500
|
-
if (!routeBindings.length) throw new Error("no active exact channel binding matches this event");
|
|
501
|
-
const binding = routeBindings.find((item) => item.senderIds.includes(normalized.senderId));
|
|
502
|
-
if (!binding) throw new Error("channel sender is not permitted by the exact binding");
|
|
503
|
-
if (!binding.capabilities.includes("receive")) throw new Error("channel binding lacks receive capability");
|
|
504
|
-
verifySignature(event, signature, binding, env);
|
|
505
|
-
const payloadDigest = sha256(channelEventSigningPayload(event));
|
|
506
|
-
const existing = runtime.events.find((item) => item.eventId === normalized.eventId);
|
|
507
|
-
if (existing) {
|
|
508
|
-
if (existing.payloadDigest !== payloadDigest || existing.bindingId !== binding.id) throw new Error("channel event ID collision; processing stopped");
|
|
509
|
-
return { event: existing, duplicate: true, receipt: runtime.receipts.find((item) => item.eventId === existing.eventId && item.event === "ingested") || null };
|
|
510
|
-
}
|
|
511
|
-
const at = timestamp(now, "now");
|
|
512
|
-
const stored = {
|
|
513
|
-
...normalized, bindingId: binding.id, agentId: binding.agentId, projectId: binding.projectId,
|
|
514
|
-
groupId: binding.groupId, sessionKey: binding.sessionKey, status: "pending", attempts: 0,
|
|
515
|
-
lease: null, receivedAt: at, completedAt: null, payloadDigest,
|
|
516
|
-
authority: "execution-state-only"
|
|
517
|
-
};
|
|
518
|
-
runtime.events.push(stored);
|
|
519
|
-
runtime.events.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.eventId.localeCompare(right.eventId));
|
|
520
|
-
const { receipt } = appendReceipt(runtime, stored.eventId, "ingested", at, { bindingId: binding.id, payloadDigest });
|
|
521
|
-
ensureHealthy(policy, runtime, graph);
|
|
522
|
-
await writeJson(paths.channelRuntimePath, runtime);
|
|
523
|
-
return { event: stored, duplicate: false, receipt };
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
function activeBinding(policy, event) {
|
|
528
|
-
const binding = policy.bindings.find((item) => item.id === event.bindingId && item.status === "active");
|
|
529
|
-
return binding && binding.capabilities.includes("receive") ? binding : null;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
export async function claimChannelEvent({
|
|
533
|
-
root = process.cwd(), agentId, projectId, groupId = null, provider, workerId,
|
|
534
|
-
eventId = null, leaseSeconds = 120, now = new Date(), catalog: providedCatalog = null
|
|
535
|
-
}) {
|
|
536
|
-
agentId = stableId(agentId, "agentId");
|
|
537
|
-
projectId = stableId(projectId, "projectId");
|
|
538
|
-
groupId = stableId(groupId, "groupId", { nullable: true });
|
|
539
|
-
provider = stableId(provider, "provider", { route: true });
|
|
540
|
-
workerId = stableId(workerId, "workerId");
|
|
541
|
-
eventId = stableId(eventId, "eventId", { nullable: true });
|
|
542
|
-
const seconds = Number(leaseSeconds);
|
|
543
|
-
if (!Number.isInteger(seconds) || seconds < 15 || seconds > 900) throw new Error("leaseSeconds must be an integer between 15 and 900");
|
|
544
|
-
const paths = await pathsFor(root, providedCatalog);
|
|
545
|
-
return withChannelLock(paths, async () => {
|
|
546
|
-
const [policy, runtime, { graph }] = await Promise.all([
|
|
547
|
-
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
548
|
-
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
549
|
-
loadGraph(paths.catalog.root, paths.catalog)
|
|
550
|
-
]);
|
|
551
|
-
ensureHealthy(policy, runtime, graph);
|
|
552
|
-
const at = timestamp(now, "now");
|
|
553
|
-
for (const event of runtime.events) {
|
|
554
|
-
if (event.status === "leased" && new Date(event.lease.expiresAt) <= new Date(at)) {
|
|
555
|
-
preserveEvent(runtime, event, "lease-expired", at);
|
|
556
|
-
event.status = "pending";
|
|
557
|
-
event.lease = null;
|
|
558
|
-
appendReceipt(runtime, event.eventId, "lease-expired", at);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
const candidates = runtime.events.filter((event) => !TERMINAL_STATUSES.has(event.status)
|
|
562
|
-
&& event.status !== "leased" && event.agentId === agentId && event.projectId === projectId
|
|
563
|
-
&& event.groupId === groupId && event.provider === provider
|
|
564
|
-
&& (eventId === null || event.eventId === eventId) && activeBinding(policy, event));
|
|
565
|
-
candidates.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.eventId.localeCompare(right.eventId));
|
|
566
|
-
const event = candidates[0] || null;
|
|
567
|
-
if (!event) {
|
|
568
|
-
await writeJson(paths.channelRuntimePath, runtime);
|
|
569
|
-
return { event: null, receipt: null };
|
|
570
|
-
}
|
|
571
|
-
preserveEvent(runtime, event, "leased", at);
|
|
572
|
-
event.status = "leased";
|
|
573
|
-
event.attempts += 1;
|
|
574
|
-
event.lease = {
|
|
575
|
-
workerId, claimedAt: at,
|
|
576
|
-
expiresAt: new Date(new Date(at).getTime() + seconds * 1000).toISOString()
|
|
577
|
-
};
|
|
578
|
-
const { receipt } = appendReceipt(runtime, event.eventId, "leased", at, { workerId, attempt: event.attempts });
|
|
579
|
-
ensureHealthy(policy, runtime, graph);
|
|
580
|
-
await writeJson(paths.channelRuntimePath, runtime);
|
|
581
|
-
return { event, receipt };
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
export async function completeChannelEvent({ root = process.cwd(), eventId, workerId, status, now = new Date() }) {
|
|
586
|
-
eventId = stableId(eventId, "eventId");
|
|
587
|
-
workerId = stableId(workerId, "workerId");
|
|
588
|
-
if (!new Set(["completed", "failed"]).has(status)) throw new Error("channel completion status must be completed or failed");
|
|
589
|
-
const paths = await pathsFor(root);
|
|
590
|
-
return withChannelLock(paths, async () => {
|
|
591
|
-
const [policy, runtime, { graph }] = await Promise.all([
|
|
592
|
-
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
593
|
-
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
594
|
-
loadGraph(paths.catalog.root, paths.catalog)
|
|
595
|
-
]);
|
|
596
|
-
ensureHealthy(policy, runtime, graph);
|
|
597
|
-
const event = runtime.events.find((item) => item.eventId === eventId);
|
|
598
|
-
if (!event) throw new Error(`unknown channel event: ${eventId}`);
|
|
599
|
-
if (event.status === status && event.completedAt) return { event, duplicate: true };
|
|
600
|
-
if (event.status !== "leased" || event.lease?.workerId !== workerId) throw new Error("channel event completion requires its exact active worker lease");
|
|
601
|
-
if (!activeBinding(policy, event)) throw new Error("channel binding was revoked before completion");
|
|
602
|
-
const at = timestamp(now, "now");
|
|
603
|
-
preserveEvent(runtime, event, status, at);
|
|
604
|
-
event.status = status;
|
|
605
|
-
event.lease = null;
|
|
606
|
-
event.completedAt = status === "completed" ? at : null;
|
|
607
|
-
const { receipt } = appendReceipt(runtime, event.eventId, status, at, { workerId, attempt: event.attempts });
|
|
608
|
-
ensureHealthy(policy, runtime, graph);
|
|
609
|
-
await writeJson(paths.channelRuntimePath, runtime);
|
|
610
|
-
return { event, receipt, duplicate: false };
|
|
611
|
-
});
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
export async function acknowledgeChannelDelivery({ root = process.cwd(), eventId, bindingId, deliveryReceiptId, now = new Date() }) {
|
|
615
|
-
eventId = stableId(eventId, "eventId");
|
|
616
|
-
bindingId = stableId(bindingId, "bindingId");
|
|
617
|
-
deliveryReceiptId = stableId(deliveryReceiptId, "deliveryReceiptId");
|
|
618
|
-
const paths = await pathsFor(root);
|
|
619
|
-
return withChannelLock(paths, async () => {
|
|
620
|
-
const [policy, runtime, { graph }] = await Promise.all([
|
|
621
|
-
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
622
|
-
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
623
|
-
loadGraph(paths.catalog.root, paths.catalog)
|
|
624
|
-
]);
|
|
625
|
-
ensureHealthy(policy, runtime, graph);
|
|
626
|
-
const event = runtime.events.find((item) => item.eventId === eventId && item.bindingId === bindingId);
|
|
627
|
-
if (!event) throw new Error("delivered channel event is unavailable in the exact binding");
|
|
628
|
-
if (event.status === "completed") return { event, duplicate: true };
|
|
629
|
-
if (!activeBinding(policy, event)?.capabilities.includes("reply")) throw new Error("channel reply capability was revoked before delivery acknowledgement");
|
|
630
|
-
const completedAt = timestamp(now, "now");
|
|
631
|
-
preserveEvent(runtime, event, "delivery-acknowledged", completedAt);
|
|
632
|
-
event.status = "completed"; event.lease = null; event.completedAt = completedAt;
|
|
633
|
-
const { receipt } = appendReceipt(runtime, event.eventId, "completed", completedAt, { deliveryReceiptId });
|
|
634
|
-
ensureHealthy(policy, runtime, graph);
|
|
635
|
-
await writeJson(paths.channelRuntimePath, runtime);
|
|
636
|
-
return { event, receipt, duplicate: false };
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
export async function channelRuntimeContext({
|
|
641
|
-
root = process.cwd(), agentId = null, projectId = null, groupId = undefined,
|
|
642
|
-
provider = null, includeTerminal = false, maxItems = 20
|
|
643
|
-
} = {}) {
|
|
644
|
-
const [{ policy }, { runtime }, { graph }] = await Promise.all([
|
|
645
|
-
loadChannelPolicy(root), loadChannelRuntime(root), loadGraph(root)
|
|
646
|
-
]);
|
|
647
|
-
ensureHealthy(policy, runtime, graph);
|
|
648
|
-
const limit = Number(maxItems);
|
|
649
|
-
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("maxItems must be an integer between 1 and 100");
|
|
650
|
-
const items = runtime.events.filter((event) => {
|
|
651
|
-
if (!includeTerminal && TERMINAL_STATUSES.has(event.status)) return false;
|
|
652
|
-
if (agentId !== null && event.agentId !== stableId(agentId, "agentId")) return false;
|
|
653
|
-
if (projectId !== null && event.projectId !== stableId(projectId, "projectId")) return false;
|
|
654
|
-
if (groupId !== undefined && event.groupId !== stableId(groupId, "groupId", { nullable: true })) return false;
|
|
655
|
-
if (provider !== null && event.provider !== stableId(provider, "provider", { route: true })) return false;
|
|
656
|
-
return true;
|
|
657
|
-
}).slice(0, limit).map((event) => structuredClone(event));
|
|
658
|
-
return {
|
|
659
|
-
schema: "agentspine.channel-context/v1", items,
|
|
660
|
-
pending: items.filter((event) => !TERMINAL_STATUSES.has(event.status)).length,
|
|
661
|
-
authority: "execution-state-only"
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
export { EVENT_SCHEMA as CHANNEL_EVENT_SCHEMA, POLICY_SCHEMA as CHANNEL_POLICY_SCHEMA, RUNTIME_SCHEMA as CHANNEL_RUNTIME_SCHEMA };
|
|
1
|
+
import {
|
|
2
|
+
createHash, createHmac, randomUUID, timingSafeEqual
|
|
3
|
+
} from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
open, readFile, rename, stat, unlink, writeFile
|
|
6
|
+
} from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { buildCatalog } from "./catalog.js";
|
|
9
|
+
import { isFileLockContention } from "./filesystem-retry.js";
|
|
10
|
+
import { loadGraph } from "./graph.js";
|
|
11
|
+
import { projectStateDir } from "./paths.js";
|
|
12
|
+
|
|
13
|
+
const POLICY_SCHEMA = "agentspine.channel-policy/v1";
|
|
14
|
+
const RUNTIME_SCHEMA = "agentspine.channel-runtime/v1";
|
|
15
|
+
const EVENT_SCHEMA = "agentspine.channel-event/v1";
|
|
16
|
+
const CONFIRMATION = "local-owner-confirmed";
|
|
17
|
+
const MAX_STATE_BYTES = 5 * 1024 * 1024;
|
|
18
|
+
const MAX_TEXT_BYTES = 16 * 1024;
|
|
19
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9:_.@/+~-]{0,255}$/;
|
|
20
|
+
const ROUTE_ID_RE = /^[A-Za-z0-9_+~-][A-Za-z0-9:_.@/+~-]{0,255}$/;
|
|
21
|
+
const ENV_RE = /^[A-Z][A-Z0-9_]{2,127}$/;
|
|
22
|
+
const DIGEST_RE = /^[a-f0-9]{64}$/;
|
|
23
|
+
const SIGNATURE_RE = /^sha256=([a-f0-9]{64})$/;
|
|
24
|
+
const PRIVACY = new Set(["private", "shared", "group"]);
|
|
25
|
+
const CAPABILITIES = new Set(["receive", "reply"]);
|
|
26
|
+
const BINDING_STATUSES = new Set(["active", "revoked"]);
|
|
27
|
+
const EVENT_STATUSES = new Set(["pending", "leased", "completed", "failed", "cancelled"]);
|
|
28
|
+
const TERMINAL_STATUSES = new Set(["completed", "cancelled"]);
|
|
29
|
+
const INBOUND_FIELDS = new Set([
|
|
30
|
+
"schema", "eventId", "provider", "tenantId", "accountId", "chatId", "threadId",
|
|
31
|
+
"senderId", "replyTo", "observedAt", "privacy", "text"
|
|
32
|
+
]);
|
|
33
|
+
const SECRET_RE = /-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:sk|gh[opusu])_[A-Za-z0-9_-]{20,}\b|\bBearer\s+[A-Za-z0-9._~+/-]{20,}|\b(?:api[-_ ]?key|token|password|secret|passwort|geheimnis)\s*[:=]\s*\S{8,}/i;
|
|
34
|
+
|
|
35
|
+
function emptyPolicy(root) {
|
|
36
|
+
return { schema: POLICY_SCHEMA, root, revision: 0, bindings: [], history: [] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function emptyRuntime(root) {
|
|
40
|
+
return { schema: RUNTIME_SCHEMA, root, events: [], history: [], receipts: [] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stableId(value, field, { nullable = false, route = false } = {}) {
|
|
44
|
+
if ((value === null || value === undefined || value === "") && nullable) return null;
|
|
45
|
+
const expression = route ? ROUTE_ID_RE : ID_RE;
|
|
46
|
+
if (typeof value !== "string" || !expression.test(value) || value.includes("*")) {
|
|
47
|
+
throw new Error(`${field} must be an exact stable ID without wildcards`);
|
|
48
|
+
}
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function timestamp(value, field = "timestamp") {
|
|
53
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
54
|
+
if (!Number.isFinite(parsed.getTime())) throw new Error(`${field} must be a valid date`);
|
|
55
|
+
return parsed.toISOString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function safeText(value, field, maximumBytes = MAX_TEXT_BYTES) {
|
|
59
|
+
if (typeof value !== "string" || !value.trim()) throw new Error(`${field} is required`);
|
|
60
|
+
const normalized = value.trim().replace(/\r\n?/g, "\n");
|
|
61
|
+
if (Buffer.byteLength(normalized) > maximumBytes) throw new Error(`${field} exceeds ${maximumBytes} bytes`);
|
|
62
|
+
if (SECRET_RE.test(normalized)) throw new Error(`${field} appears to contain a secret and cannot enter channel state`);
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function exactCapabilities(value) {
|
|
67
|
+
if (!Array.isArray(value) || !value.length) throw new Error("channel capabilities are required");
|
|
68
|
+
const unique = [...new Set(value)].sort();
|
|
69
|
+
if (unique.some((item) => !CAPABILITIES.has(item))) throw new Error("channel capabilities may contain only receive and reply");
|
|
70
|
+
if (!unique.includes("receive")) throw new Error("channel bindings must include receive");
|
|
71
|
+
return unique;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function exactSenderIds(value) {
|
|
75
|
+
if (!Array.isArray(value) || !value.length) throw new Error("senderIds must contain exact authenticated sender IDs");
|
|
76
|
+
return [...new Set(value.map((item) => stableId(item, "senderId", { route: true })))].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sha256(value) {
|
|
80
|
+
return createHash("sha256").update(value).digest("hex");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bindingRoute(binding) {
|
|
84
|
+
return [binding.provider, binding.tenantId, binding.accountId, binding.chatId, binding.threadId || ""].join("\0");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalizeInboundEvent(value, { inspectContent = false } = {}) {
|
|
88
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("channel event must be one object");
|
|
89
|
+
const unknown = Object.keys(value).filter((key) => !INBOUND_FIELDS.has(key));
|
|
90
|
+
if (unknown.length) throw new Error(`channel event contains unknown field: ${unknown.sort()[0]}`);
|
|
91
|
+
if (value.schema !== EVENT_SCHEMA) throw new Error(`channel event schema must be ${EVENT_SCHEMA}`);
|
|
92
|
+
if (!PRIVACY.has(value.privacy)) throw new Error("channel event privacy must be private, shared, or group");
|
|
93
|
+
const text = inspectContent ? safeText(value.text, "channel event text") : String(value.text ?? "").trim().replace(/\r\n?/g, "\n");
|
|
94
|
+
if (!text || Buffer.byteLength(text) > MAX_TEXT_BYTES) throw new Error(`channel event text must be 1-${MAX_TEXT_BYTES} bytes`);
|
|
95
|
+
return {
|
|
96
|
+
schema: EVENT_SCHEMA,
|
|
97
|
+
eventId: stableId(value.eventId, "eventId"),
|
|
98
|
+
provider: stableId(value.provider, "provider", { route: true }),
|
|
99
|
+
tenantId: stableId(value.tenantId, "tenantId", { route: true }),
|
|
100
|
+
accountId: stableId(value.accountId, "accountId", { route: true }),
|
|
101
|
+
chatId: stableId(value.chatId, "chatId", { route: true }),
|
|
102
|
+
threadId: stableId(value.threadId, "threadId", { nullable: true, route: true }),
|
|
103
|
+
senderId: stableId(value.senderId, "senderId", { route: true }),
|
|
104
|
+
replyTo: stableId(value.replyTo, "replyTo", { nullable: true, route: true }),
|
|
105
|
+
observedAt: timestamp(value.observedAt, "observedAt"),
|
|
106
|
+
privacy: value.privacy,
|
|
107
|
+
text
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function channelEventSigningPayload(event) {
|
|
112
|
+
return JSON.stringify(normalizeInboundEvent(event));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function validBinding(binding) {
|
|
116
|
+
return binding && ID_RE.test(binding.id || "") && BINDING_STATUSES.has(binding.status)
|
|
117
|
+
&& ROUTE_ID_RE.test(binding.provider || "") && ROUTE_ID_RE.test(binding.tenantId || "")
|
|
118
|
+
&& ROUTE_ID_RE.test(binding.accountId || "") && ROUTE_ID_RE.test(binding.chatId || "")
|
|
119
|
+
&& (binding.threadId === null || ROUTE_ID_RE.test(binding.threadId || ""))
|
|
120
|
+
&& Array.isArray(binding.senderIds) && binding.senderIds.length > 0
|
|
121
|
+
&& binding.senderIds.every((id) => ROUTE_ID_RE.test(id) && !id.includes("*"))
|
|
122
|
+
&& ID_RE.test(binding.agentId || "") && ID_RE.test(binding.projectId || "")
|
|
123
|
+
&& (binding.groupId === null || ID_RE.test(binding.groupId || ""))
|
|
124
|
+
&& ID_RE.test(binding.sessionKey || "") && ENV_RE.test(binding.secretEnv || "")
|
|
125
|
+
&& (binding.outboundSecretEnv === null || ENV_RE.test(binding.outboundSecretEnv || ""))
|
|
126
|
+
&& Array.isArray(binding.capabilities) && binding.capabilities.includes("receive")
|
|
127
|
+
&& binding.capabilities.every((item) => CAPABILITIES.has(item))
|
|
128
|
+
&& binding.authority === "explicit-local-channel-policy"
|
|
129
|
+
&& Number.isFinite(new Date(binding.createdAt).getTime())
|
|
130
|
+
&& Number.isFinite(new Date(binding.updatedAt).getTime())
|
|
131
|
+
&& (binding.revokedAt === null || Number.isFinite(new Date(binding.revokedAt).getTime()));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validEvent(event) {
|
|
135
|
+
return event && event.schema === EVENT_SCHEMA && ID_RE.test(event.eventId || "")
|
|
136
|
+
&& ID_RE.test(event.bindingId || "") && ROUTE_ID_RE.test(event.provider || "")
|
|
137
|
+
&& ROUTE_ID_RE.test(event.tenantId || "") && ROUTE_ID_RE.test(event.accountId || "")
|
|
138
|
+
&& ROUTE_ID_RE.test(event.chatId || "") && (event.threadId === null || ROUTE_ID_RE.test(event.threadId || ""))
|
|
139
|
+
&& ROUTE_ID_RE.test(event.senderId || "") && (event.replyTo === null || ROUTE_ID_RE.test(event.replyTo || ""))
|
|
140
|
+
&& ID_RE.test(event.agentId || "") && ID_RE.test(event.projectId || "")
|
|
141
|
+
&& (event.groupId === null || ID_RE.test(event.groupId || "")) && ID_RE.test(event.sessionKey || "")
|
|
142
|
+
&& PRIVACY.has(event.privacy) && typeof event.text === "string" && event.text.length > 0
|
|
143
|
+
&& Buffer.byteLength(event.text) <= MAX_TEXT_BYTES && !SECRET_RE.test(event.text)
|
|
144
|
+
&& EVENT_STATUSES.has(event.status) && Number.isInteger(event.attempts) && event.attempts >= 0
|
|
145
|
+
&& event.authority === "execution-state-only" && DIGEST_RE.test(event.payloadDigest || "")
|
|
146
|
+
&& Number.isFinite(new Date(event.observedAt).getTime()) && Number.isFinite(new Date(event.receivedAt).getTime())
|
|
147
|
+
&& (event.completedAt === null || Number.isFinite(new Date(event.completedAt).getTime()))
|
|
148
|
+
&& (event.lease === null || (
|
|
149
|
+
event.status === "leased" && ID_RE.test(event.lease.workerId || "")
|
|
150
|
+
&& Number.isFinite(new Date(event.lease.claimedAt).getTime())
|
|
151
|
+
&& Number.isFinite(new Date(event.lease.expiresAt).getTime())
|
|
152
|
+
));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validReceipt(receipt) {
|
|
156
|
+
if (!(receipt && ID_RE.test(receipt.id || "") && ID_RE.test(receipt.eventId || "")
|
|
157
|
+
&& typeof receipt.event === "string" && DIGEST_RE.test(receipt.digest || "")
|
|
158
|
+
&& Number.isFinite(new Date(receipt.at).getTime()) && receipt.authority === "execution-state-only")) return false;
|
|
159
|
+
const { id, digest, ...material } = receipt;
|
|
160
|
+
return id === `channel-receipt:${digest.slice(0, 24)}` && sha256(JSON.stringify(material)) === digest;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validPolicyHistory(record) {
|
|
164
|
+
return record && record.kind === "channel-binding" && ID_RE.test(record.recordId || "")
|
|
165
|
+
&& record.recordId === record.value?.id && validBinding(record.value)
|
|
166
|
+
&& Number.isFinite(new Date(record.supersededAt).getTime())
|
|
167
|
+
&& record.authority === "explicit-local-channel-policy";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validRuntimeHistory(record) {
|
|
171
|
+
return record && record.kind === "channel-event" && ID_RE.test(record.recordId || "")
|
|
172
|
+
&& record.recordId === record.value?.eventId && validEvent(record.value)
|
|
173
|
+
&& typeof record.transition === "string" && record.transition.length > 0
|
|
174
|
+
&& Number.isFinite(new Date(record.supersededAt).getTime())
|
|
175
|
+
&& record.authority === "execution-state-only";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function validPayloadDigest(event) {
|
|
179
|
+
if (!validEvent(event)) return false;
|
|
180
|
+
const inbound = Object.fromEntries([...INBOUND_FIELDS].map((field) => [field, event[field]]));
|
|
181
|
+
return sha256(channelEventSigningPayload(inbound)) === event.payloadDigest;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function normalizePolicy(value, root) {
|
|
185
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== POLICY_SCHEMA
|
|
186
|
+
|| value.root !== root || !Number.isInteger(value.revision) || value.revision < 0
|
|
187
|
+
|| !Array.isArray(value.bindings) || !Array.isArray(value.history)) {
|
|
188
|
+
throw new Error("channel policy structure is invalid; channel runtime is disabled until repaired");
|
|
189
|
+
}
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeRuntime(value, root) {
|
|
194
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || value.schema !== RUNTIME_SCHEMA
|
|
195
|
+
|| value.root !== root || !Array.isArray(value.events) || !Array.isArray(value.history)
|
|
196
|
+
|| !Array.isArray(value.receipts)) {
|
|
197
|
+
throw new Error("channel runtime structure is invalid; channel processing is disabled until repaired");
|
|
198
|
+
}
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function readJson(path, root, normalize, empty) {
|
|
203
|
+
try {
|
|
204
|
+
const metadata = await stat(path);
|
|
205
|
+
if (metadata.size > MAX_STATE_BYTES) throw new Error("channel state exceeds the 5 MiB read limit");
|
|
206
|
+
return normalize(JSON.parse(await readFile(path, "utf8")), root);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error.code !== "ENOENT") throw error;
|
|
209
|
+
return empty(root);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function writeJson(path, value) {
|
|
214
|
+
const content = `${JSON.stringify(value, null, 2)}\n`;
|
|
215
|
+
if (Buffer.byteLength(content) > MAX_STATE_BYTES) throw new Error("channel state exceeds 5 MiB; purge terminal events first");
|
|
216
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
217
|
+
try {
|
|
218
|
+
await writeFile(temporary, content, { mode: 0o600 });
|
|
219
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
220
|
+
try {
|
|
221
|
+
await rename(temporary, path);
|
|
222
|
+
break;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
if (attempt >= 5 || !["EACCES", "EPERM"].includes(error.code)) throw error;
|
|
225
|
+
await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} finally {
|
|
229
|
+
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function pathsFor(root, providedCatalog = null) {
|
|
234
|
+
const catalog = providedCatalog || await buildCatalog(root);
|
|
235
|
+
const directory = await projectStateDir(catalog.root);
|
|
236
|
+
return {
|
|
237
|
+
catalog, directory,
|
|
238
|
+
channelPolicyPath: join(directory, "channel-policy.json"),
|
|
239
|
+
channelRuntimePath: join(directory, "channel-runtime.json")
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function withChannelLock(paths, task) {
|
|
244
|
+
const lockPath = join(paths.directory, "channel-runtime.lock");
|
|
245
|
+
let handle;
|
|
246
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
247
|
+
try {
|
|
248
|
+
handle = await open(lockPath, "wx", 0o600);
|
|
249
|
+
break;
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (!isFileLockContention(error)) throw error;
|
|
252
|
+
try {
|
|
253
|
+
const metadata = await stat(lockPath);
|
|
254
|
+
if (Date.now() - metadata.mtimeMs > 90000) await unlink(lockPath);
|
|
255
|
+
} catch (lockError) {
|
|
256
|
+
if (lockError.code !== "ENOENT") throw lockError;
|
|
257
|
+
}
|
|
258
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!handle) throw new Error("channel state is busy; retry later");
|
|
262
|
+
try {
|
|
263
|
+
return await task();
|
|
264
|
+
} finally {
|
|
265
|
+
await handle.close();
|
|
266
|
+
await unlink(lockPath).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function requireConfirmation(value) {
|
|
271
|
+
if (value !== CONFIRMATION) throw new Error("channel policy changes require explicit local owner confirmation");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function referenceFindings(policy, runtime, graph) {
|
|
275
|
+
const findings = [];
|
|
276
|
+
const entities = new Map(graph.entities.map((entity) => [entity.id, entity]));
|
|
277
|
+
const bindings = new Map();
|
|
278
|
+
const routes = new Map();
|
|
279
|
+
for (const binding of policy.bindings) {
|
|
280
|
+
if (!validBinding(binding)) findings.push(`invalid-binding:${binding?.id || "unknown"}`);
|
|
281
|
+
if (bindings.has(binding.id)) findings.push(`duplicate-binding:${binding.id}`);
|
|
282
|
+
bindings.set(binding.id, binding);
|
|
283
|
+
if (binding.status === "active") {
|
|
284
|
+
const route = bindingRoute(binding);
|
|
285
|
+
if (routes.has(route)) findings.push(`duplicate-active-route:${binding.id}`);
|
|
286
|
+
routes.set(route, binding.id);
|
|
287
|
+
}
|
|
288
|
+
if (entities.get(binding.agentId)?.kind !== "agent") findings.push(`binding-agent-mismatch:${binding.id}`);
|
|
289
|
+
if (entities.get(binding.projectId)?.kind !== "project") findings.push(`binding-project-mismatch:${binding.id}`);
|
|
290
|
+
if (binding.groupId !== null) {
|
|
291
|
+
if (entities.get(binding.groupId)?.kind !== "group") findings.push(`binding-group-mismatch:${binding.id}`);
|
|
292
|
+
const member = graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private"
|
|
293
|
+
&& ((edge.from === binding.agentId && edge.to === binding.groupId)
|
|
294
|
+
|| (edge.to === binding.agentId && edge.from === binding.groupId)));
|
|
295
|
+
if (!member) findings.push(`binding-group-membership-mismatch:${binding.id}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const record of policy.history) {
|
|
299
|
+
if (!validPolicyHistory(record)) findings.push(`invalid-binding-history:${record?.recordId || "unknown"}`);
|
|
300
|
+
}
|
|
301
|
+
const eventIds = new Set();
|
|
302
|
+
for (const event of runtime.events) {
|
|
303
|
+
if (!validEvent(event)) findings.push(`invalid-channel-event:${event?.eventId || "unknown"}`);
|
|
304
|
+
else if (!validPayloadDigest(event)) findings.push(`channel-event-digest-mismatch:${event.eventId}`);
|
|
305
|
+
if (eventIds.has(event.eventId)) findings.push(`duplicate-channel-event:${event.eventId}`);
|
|
306
|
+
eventIds.add(event.eventId);
|
|
307
|
+
const binding = bindings.get(event.bindingId);
|
|
308
|
+
if (!binding || [event.provider, event.tenantId, event.accountId, event.chatId, event.threadId || ""].join("\0") !== bindingRoute(binding)
|
|
309
|
+
|| event.agentId !== binding?.agentId || event.projectId !== binding?.projectId
|
|
310
|
+
|| event.groupId !== binding?.groupId || event.sessionKey !== binding?.sessionKey
|
|
311
|
+
|| !binding?.senderIds.includes(event.senderId)) findings.push(`event-binding-mismatch:${event.eventId}`);
|
|
312
|
+
}
|
|
313
|
+
for (const record of runtime.history) {
|
|
314
|
+
if (!validRuntimeHistory(record)) findings.push(`invalid-channel-history:${record?.recordId || "unknown"}`);
|
|
315
|
+
else if (!validPayloadDigest(record.value)) findings.push(`channel-history-digest-mismatch:${record.recordId}`);
|
|
316
|
+
}
|
|
317
|
+
const receiptIds = new Set();
|
|
318
|
+
for (const receipt of runtime.receipts) {
|
|
319
|
+
if (!validReceipt(receipt)) findings.push(`invalid-channel-receipt:${receipt?.id || "unknown"}`);
|
|
320
|
+
if (receiptIds.has(receipt.id)) findings.push(`duplicate-channel-receipt:${receipt.id}`);
|
|
321
|
+
if (!eventIds.has(receipt.eventId)) findings.push(`orphan-channel-receipt:${receipt.id}`);
|
|
322
|
+
receiptIds.add(receipt.id);
|
|
323
|
+
}
|
|
324
|
+
return findings;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function channelPolicyFindings(policy, graph) {
|
|
328
|
+
return referenceFindings(policy, emptyRuntime(policy.root), graph).filter((item) => item.includes("binding") || item.includes("route"));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function channelRuntimeFindings(runtime, policy, graph) {
|
|
332
|
+
return referenceFindings(policy, runtime, graph);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function loadChannelPolicy(root = process.cwd(), providedCatalog = null) {
|
|
336
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
337
|
+
return { policy: await readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy), ...paths };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function loadChannelRuntime(root = process.cwd(), providedCatalog = null) {
|
|
341
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
342
|
+
return { runtime: await readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime), ...paths };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function inspectChannelRuntime(root = process.cwd(), providedCatalog = null) {
|
|
346
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
347
|
+
let policy = emptyPolicy(paths.catalog.root);
|
|
348
|
+
let runtime = emptyRuntime(paths.catalog.root);
|
|
349
|
+
const errors = [];
|
|
350
|
+
try { policy = await readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy); } catch (error) { errors.push(`policy:${error.message}`); }
|
|
351
|
+
try { runtime = await readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime); } catch (error) { errors.push(`runtime:${error.message}`); }
|
|
352
|
+
return { policy, runtime, errors, ...paths };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function preserveBinding(policy, binding, at) {
|
|
356
|
+
policy.history.push({
|
|
357
|
+
kind: "channel-binding", recordId: binding.id, supersededAt: at,
|
|
358
|
+
value: structuredClone(binding), authority: "explicit-local-channel-policy"
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function preserveEvent(runtime, event, transition, at) {
|
|
363
|
+
runtime.history.push({
|
|
364
|
+
kind: "channel-event", recordId: event.eventId, transition, supersededAt: at,
|
|
365
|
+
value: structuredClone(event), authority: "execution-state-only"
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function appendReceipt(runtime, eventId, event, at, details = {}) {
|
|
370
|
+
const material = { eventId, event, at, details, authority: "execution-state-only" };
|
|
371
|
+
const digest = sha256(JSON.stringify(material));
|
|
372
|
+
const id = `channel-receipt:${digest.slice(0, 24)}`;
|
|
373
|
+
const existing = runtime.receipts.find((receipt) => receipt.id === id);
|
|
374
|
+
if (existing) return { receipt: existing, duplicate: true };
|
|
375
|
+
const receipt = { id, ...material, digest };
|
|
376
|
+
runtime.receipts.push(receipt);
|
|
377
|
+
return { receipt, duplicate: false };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function ensureHealthy(policy, runtime, graph) {
|
|
381
|
+
const findings = referenceFindings(policy, runtime, graph);
|
|
382
|
+
if (findings.length) throw new Error(`channel runtime failed closed: ${findings.join(", ")}`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function grantChannelBinding({
|
|
386
|
+
root = process.cwd(), id = `channel-binding:${randomUUID()}`, provider, tenantId, accountId,
|
|
387
|
+
chatId, threadId = null, senderIds, agentId, projectId, groupId = null, sessionKey,
|
|
388
|
+
secretEnv, outboundSecretEnv = null, capabilities = ["receive"], confirmation, now = new Date()
|
|
389
|
+
}) {
|
|
390
|
+
requireConfirmation(confirmation);
|
|
391
|
+
const paths = await pathsFor(root);
|
|
392
|
+
return withChannelLock(paths, async () => {
|
|
393
|
+
const [policy, runtime, { graph }] = await Promise.all([
|
|
394
|
+
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
395
|
+
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
396
|
+
loadGraph(paths.catalog.root, paths.catalog)
|
|
397
|
+
]);
|
|
398
|
+
ensureHealthy(policy, runtime, graph);
|
|
399
|
+
const at = timestamp(now, "now");
|
|
400
|
+
const binding = {
|
|
401
|
+
id: stableId(id, "bindingId"),
|
|
402
|
+
provider: stableId(provider, "provider", { route: true }),
|
|
403
|
+
tenantId: stableId(tenantId, "tenantId", { route: true }),
|
|
404
|
+
accountId: stableId(accountId, "accountId", { route: true }),
|
|
405
|
+
chatId: stableId(chatId, "chatId", { route: true }),
|
|
406
|
+
threadId: stableId(threadId, "threadId", { nullable: true, route: true }),
|
|
407
|
+
senderIds: exactSenderIds(senderIds),
|
|
408
|
+
agentId: stableId(agentId, "agentId"), projectId: stableId(projectId, "projectId"),
|
|
409
|
+
groupId: stableId(groupId, "groupId", { nullable: true }),
|
|
410
|
+
sessionKey: stableId(sessionKey, "sessionKey"),
|
|
411
|
+
secretEnv: ENV_RE.test(secretEnv || "") ? secretEnv : (() => { throw new Error("secretEnv must be an uppercase environment variable name"); })(),
|
|
412
|
+
outboundSecretEnv: outboundSecretEnv === null ? null : (ENV_RE.test(outboundSecretEnv || "")
|
|
413
|
+
? outboundSecretEnv : (() => { throw new Error("outboundSecretEnv must be an uppercase environment variable name"); })()),
|
|
414
|
+
capabilities: exactCapabilities(capabilities), status: "active", createdAt: at, updatedAt: at,
|
|
415
|
+
revokedAt: null, revokeReason: null, authority: "explicit-local-channel-policy"
|
|
416
|
+
};
|
|
417
|
+
const agent = graph.entities.find((entity) => entity.id === binding.agentId && entity.kind === "agent");
|
|
418
|
+
const project = graph.entities.find((entity) => entity.id === binding.projectId && entity.kind === "project");
|
|
419
|
+
if (binding.capabilities.includes("reply") && !binding.outboundSecretEnv) {
|
|
420
|
+
throw new Error("reply capability requires an environment-only outbound adapter credential name");
|
|
421
|
+
}
|
|
422
|
+
if (!agent) throw new Error(`unknown agent entity: ${binding.agentId}`);
|
|
423
|
+
if (!project) throw new Error(`unknown project entity: ${binding.projectId}`);
|
|
424
|
+
if (binding.groupId !== null) {
|
|
425
|
+
if (!graph.entities.some((entity) => entity.id === binding.groupId && entity.kind === "group")) throw new Error(`unknown group entity: ${binding.groupId}`);
|
|
426
|
+
const member = graph.entityEdges.some((edge) => edge.relation === "member-of" && edge.privacy !== "private"
|
|
427
|
+
&& ((edge.from === binding.agentId && edge.to === binding.groupId)
|
|
428
|
+
|| (edge.to === binding.agentId && edge.from === binding.groupId)));
|
|
429
|
+
if (!member) throw new Error("channel binding agent must be a visible member of its exact group");
|
|
430
|
+
}
|
|
431
|
+
const activeRoute = policy.bindings.find((item) => item.status === "active" && bindingRoute(item) === bindingRoute(binding) && item.id !== binding.id);
|
|
432
|
+
if (activeRoute) throw new Error(`channel route is already bound by ${activeRoute.id}`);
|
|
433
|
+
const previous = policy.bindings.find((item) => item.id === binding.id);
|
|
434
|
+
if (previous) preserveBinding(policy, previous, at);
|
|
435
|
+
policy.bindings = policy.bindings.filter((item) => item.id !== binding.id);
|
|
436
|
+
policy.bindings.push(binding);
|
|
437
|
+
policy.bindings.sort((left, right) => left.id.localeCompare(right.id));
|
|
438
|
+
policy.revision += 1;
|
|
439
|
+
ensureHealthy(policy, runtime, graph);
|
|
440
|
+
await writeJson(paths.channelPolicyPath, policy);
|
|
441
|
+
return { binding, revision: policy.revision, channelPolicyPath: paths.channelPolicyPath };
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function revokeChannelBinding({ root = process.cwd(), id, reason, confirmation, now = new Date() }) {
|
|
446
|
+
requireConfirmation(confirmation);
|
|
447
|
+
const paths = await pathsFor(root);
|
|
448
|
+
return withChannelLock(paths, async () => {
|
|
449
|
+
const [policy, runtime, { graph }] = await Promise.all([
|
|
450
|
+
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
451
|
+
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
452
|
+
loadGraph(paths.catalog.root, paths.catalog)
|
|
453
|
+
]);
|
|
454
|
+
ensureHealthy(policy, runtime, graph);
|
|
455
|
+
const binding = policy.bindings.find((item) => item.id === stableId(id, "bindingId"));
|
|
456
|
+
if (!binding) throw new Error(`unknown channel binding: ${id}`);
|
|
457
|
+
const at = timestamp(now, "now");
|
|
458
|
+
if (binding.status === "revoked") return { binding, duplicate: true };
|
|
459
|
+
preserveBinding(policy, binding, at);
|
|
460
|
+
binding.status = "revoked";
|
|
461
|
+
binding.revokedAt = at;
|
|
462
|
+
binding.updatedAt = at;
|
|
463
|
+
binding.revokeReason = safeText(reason, "revoke reason", 1000);
|
|
464
|
+
for (const event of runtime.events.filter((item) => item.bindingId === binding.id && !TERMINAL_STATUSES.has(item.status))) {
|
|
465
|
+
preserveEvent(runtime, event, "binding-revoked", at);
|
|
466
|
+
event.status = "cancelled";
|
|
467
|
+
event.lease = null;
|
|
468
|
+
event.completedAt = at;
|
|
469
|
+
appendReceipt(runtime, event.eventId, "cancelled", at, { reason: "binding-revoked" });
|
|
470
|
+
}
|
|
471
|
+
policy.revision += 1;
|
|
472
|
+
ensureHealthy(policy, runtime, graph);
|
|
473
|
+
await Promise.all([writeJson(paths.channelPolicyPath, policy), writeJson(paths.channelRuntimePath, runtime)]);
|
|
474
|
+
return { binding, duplicate: false, revision: policy.revision };
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function verifySignature(event, signature, binding, env) {
|
|
479
|
+
const match = String(signature || "").match(SIGNATURE_RE);
|
|
480
|
+
if (!match) throw new Error("channel event signature is missing or invalid");
|
|
481
|
+
const secret = env?.[binding.secretEnv];
|
|
482
|
+
if (typeof secret !== "string" || Buffer.byteLength(secret) < 32) throw new Error(`channel adapter secret ${binding.secretEnv} is unavailable or too short`);
|
|
483
|
+
const expected = createHmac("sha256", secret).update(channelEventSigningPayload(event)).digest();
|
|
484
|
+
const supplied = Buffer.from(match[1], "hex");
|
|
485
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) throw new Error("channel event signature verification failed");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function ingestChannelEvent({ root = process.cwd(), event, signature, env = process.env, now = new Date() }) {
|
|
489
|
+
const normalized = normalizeInboundEvent(event, { inspectContent: true });
|
|
490
|
+
const paths = await pathsFor(root);
|
|
491
|
+
return withChannelLock(paths, async () => {
|
|
492
|
+
const [policy, runtime, { graph }] = await Promise.all([
|
|
493
|
+
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
494
|
+
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
495
|
+
loadGraph(paths.catalog.root, paths.catalog)
|
|
496
|
+
]);
|
|
497
|
+
ensureHealthy(policy, runtime, graph);
|
|
498
|
+
const route = [normalized.provider, normalized.tenantId, normalized.accountId, normalized.chatId, normalized.threadId || ""].join("\0");
|
|
499
|
+
const routeBindings = policy.bindings.filter((binding) => binding.status === "active" && bindingRoute(binding) === route);
|
|
500
|
+
if (!routeBindings.length) throw new Error("no active exact channel binding matches this event");
|
|
501
|
+
const binding = routeBindings.find((item) => item.senderIds.includes(normalized.senderId));
|
|
502
|
+
if (!binding) throw new Error("channel sender is not permitted by the exact binding");
|
|
503
|
+
if (!binding.capabilities.includes("receive")) throw new Error("channel binding lacks receive capability");
|
|
504
|
+
verifySignature(event, signature, binding, env);
|
|
505
|
+
const payloadDigest = sha256(channelEventSigningPayload(event));
|
|
506
|
+
const existing = runtime.events.find((item) => item.eventId === normalized.eventId);
|
|
507
|
+
if (existing) {
|
|
508
|
+
if (existing.payloadDigest !== payloadDigest || existing.bindingId !== binding.id) throw new Error("channel event ID collision; processing stopped");
|
|
509
|
+
return { event: existing, duplicate: true, receipt: runtime.receipts.find((item) => item.eventId === existing.eventId && item.event === "ingested") || null };
|
|
510
|
+
}
|
|
511
|
+
const at = timestamp(now, "now");
|
|
512
|
+
const stored = {
|
|
513
|
+
...normalized, bindingId: binding.id, agentId: binding.agentId, projectId: binding.projectId,
|
|
514
|
+
groupId: binding.groupId, sessionKey: binding.sessionKey, status: "pending", attempts: 0,
|
|
515
|
+
lease: null, receivedAt: at, completedAt: null, payloadDigest,
|
|
516
|
+
authority: "execution-state-only"
|
|
517
|
+
};
|
|
518
|
+
runtime.events.push(stored);
|
|
519
|
+
runtime.events.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.eventId.localeCompare(right.eventId));
|
|
520
|
+
const { receipt } = appendReceipt(runtime, stored.eventId, "ingested", at, { bindingId: binding.id, payloadDigest });
|
|
521
|
+
ensureHealthy(policy, runtime, graph);
|
|
522
|
+
await writeJson(paths.channelRuntimePath, runtime);
|
|
523
|
+
return { event: stored, duplicate: false, receipt };
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function activeBinding(policy, event) {
|
|
528
|
+
const binding = policy.bindings.find((item) => item.id === event.bindingId && item.status === "active");
|
|
529
|
+
return binding && binding.capabilities.includes("receive") ? binding : null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export async function claimChannelEvent({
|
|
533
|
+
root = process.cwd(), agentId, projectId, groupId = null, provider, workerId,
|
|
534
|
+
eventId = null, leaseSeconds = 120, now = new Date(), catalog: providedCatalog = null
|
|
535
|
+
}) {
|
|
536
|
+
agentId = stableId(agentId, "agentId");
|
|
537
|
+
projectId = stableId(projectId, "projectId");
|
|
538
|
+
groupId = stableId(groupId, "groupId", { nullable: true });
|
|
539
|
+
provider = stableId(provider, "provider", { route: true });
|
|
540
|
+
workerId = stableId(workerId, "workerId");
|
|
541
|
+
eventId = stableId(eventId, "eventId", { nullable: true });
|
|
542
|
+
const seconds = Number(leaseSeconds);
|
|
543
|
+
if (!Number.isInteger(seconds) || seconds < 15 || seconds > 900) throw new Error("leaseSeconds must be an integer between 15 and 900");
|
|
544
|
+
const paths = await pathsFor(root, providedCatalog);
|
|
545
|
+
return withChannelLock(paths, async () => {
|
|
546
|
+
const [policy, runtime, { graph }] = await Promise.all([
|
|
547
|
+
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
548
|
+
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
549
|
+
loadGraph(paths.catalog.root, paths.catalog)
|
|
550
|
+
]);
|
|
551
|
+
ensureHealthy(policy, runtime, graph);
|
|
552
|
+
const at = timestamp(now, "now");
|
|
553
|
+
for (const event of runtime.events) {
|
|
554
|
+
if (event.status === "leased" && new Date(event.lease.expiresAt) <= new Date(at)) {
|
|
555
|
+
preserveEvent(runtime, event, "lease-expired", at);
|
|
556
|
+
event.status = "pending";
|
|
557
|
+
event.lease = null;
|
|
558
|
+
appendReceipt(runtime, event.eventId, "lease-expired", at);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const candidates = runtime.events.filter((event) => !TERMINAL_STATUSES.has(event.status)
|
|
562
|
+
&& event.status !== "leased" && event.agentId === agentId && event.projectId === projectId
|
|
563
|
+
&& event.groupId === groupId && event.provider === provider
|
|
564
|
+
&& (eventId === null || event.eventId === eventId) && activeBinding(policy, event));
|
|
565
|
+
candidates.sort((left, right) => left.receivedAt.localeCompare(right.receivedAt) || left.eventId.localeCompare(right.eventId));
|
|
566
|
+
const event = candidates[0] || null;
|
|
567
|
+
if (!event) {
|
|
568
|
+
await writeJson(paths.channelRuntimePath, runtime);
|
|
569
|
+
return { event: null, receipt: null };
|
|
570
|
+
}
|
|
571
|
+
preserveEvent(runtime, event, "leased", at);
|
|
572
|
+
event.status = "leased";
|
|
573
|
+
event.attempts += 1;
|
|
574
|
+
event.lease = {
|
|
575
|
+
workerId, claimedAt: at,
|
|
576
|
+
expiresAt: new Date(new Date(at).getTime() + seconds * 1000).toISOString()
|
|
577
|
+
};
|
|
578
|
+
const { receipt } = appendReceipt(runtime, event.eventId, "leased", at, { workerId, attempt: event.attempts });
|
|
579
|
+
ensureHealthy(policy, runtime, graph);
|
|
580
|
+
await writeJson(paths.channelRuntimePath, runtime);
|
|
581
|
+
return { event, receipt };
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function completeChannelEvent({ root = process.cwd(), eventId, workerId, status, now = new Date() }) {
|
|
586
|
+
eventId = stableId(eventId, "eventId");
|
|
587
|
+
workerId = stableId(workerId, "workerId");
|
|
588
|
+
if (!new Set(["completed", "failed"]).has(status)) throw new Error("channel completion status must be completed or failed");
|
|
589
|
+
const paths = await pathsFor(root);
|
|
590
|
+
return withChannelLock(paths, async () => {
|
|
591
|
+
const [policy, runtime, { graph }] = await Promise.all([
|
|
592
|
+
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
593
|
+
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
594
|
+
loadGraph(paths.catalog.root, paths.catalog)
|
|
595
|
+
]);
|
|
596
|
+
ensureHealthy(policy, runtime, graph);
|
|
597
|
+
const event = runtime.events.find((item) => item.eventId === eventId);
|
|
598
|
+
if (!event) throw new Error(`unknown channel event: ${eventId}`);
|
|
599
|
+
if (event.status === status && event.completedAt) return { event, duplicate: true };
|
|
600
|
+
if (event.status !== "leased" || event.lease?.workerId !== workerId) throw new Error("channel event completion requires its exact active worker lease");
|
|
601
|
+
if (!activeBinding(policy, event)) throw new Error("channel binding was revoked before completion");
|
|
602
|
+
const at = timestamp(now, "now");
|
|
603
|
+
preserveEvent(runtime, event, status, at);
|
|
604
|
+
event.status = status;
|
|
605
|
+
event.lease = null;
|
|
606
|
+
event.completedAt = status === "completed" ? at : null;
|
|
607
|
+
const { receipt } = appendReceipt(runtime, event.eventId, status, at, { workerId, attempt: event.attempts });
|
|
608
|
+
ensureHealthy(policy, runtime, graph);
|
|
609
|
+
await writeJson(paths.channelRuntimePath, runtime);
|
|
610
|
+
return { event, receipt, duplicate: false };
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export async function acknowledgeChannelDelivery({ root = process.cwd(), eventId, bindingId, deliveryReceiptId, now = new Date() }) {
|
|
615
|
+
eventId = stableId(eventId, "eventId");
|
|
616
|
+
bindingId = stableId(bindingId, "bindingId");
|
|
617
|
+
deliveryReceiptId = stableId(deliveryReceiptId, "deliveryReceiptId");
|
|
618
|
+
const paths = await pathsFor(root);
|
|
619
|
+
return withChannelLock(paths, async () => {
|
|
620
|
+
const [policy, runtime, { graph }] = await Promise.all([
|
|
621
|
+
readJson(paths.channelPolicyPath, paths.catalog.root, normalizePolicy, emptyPolicy),
|
|
622
|
+
readJson(paths.channelRuntimePath, paths.catalog.root, normalizeRuntime, emptyRuntime),
|
|
623
|
+
loadGraph(paths.catalog.root, paths.catalog)
|
|
624
|
+
]);
|
|
625
|
+
ensureHealthy(policy, runtime, graph);
|
|
626
|
+
const event = runtime.events.find((item) => item.eventId === eventId && item.bindingId === bindingId);
|
|
627
|
+
if (!event) throw new Error("delivered channel event is unavailable in the exact binding");
|
|
628
|
+
if (event.status === "completed") return { event, duplicate: true };
|
|
629
|
+
if (!activeBinding(policy, event)?.capabilities.includes("reply")) throw new Error("channel reply capability was revoked before delivery acknowledgement");
|
|
630
|
+
const completedAt = timestamp(now, "now");
|
|
631
|
+
preserveEvent(runtime, event, "delivery-acknowledged", completedAt);
|
|
632
|
+
event.status = "completed"; event.lease = null; event.completedAt = completedAt;
|
|
633
|
+
const { receipt } = appendReceipt(runtime, event.eventId, "completed", completedAt, { deliveryReceiptId });
|
|
634
|
+
ensureHealthy(policy, runtime, graph);
|
|
635
|
+
await writeJson(paths.channelRuntimePath, runtime);
|
|
636
|
+
return { event, receipt, duplicate: false };
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function channelRuntimeContext({
|
|
641
|
+
root = process.cwd(), agentId = null, projectId = null, groupId = undefined,
|
|
642
|
+
provider = null, includeTerminal = false, maxItems = 20
|
|
643
|
+
} = {}) {
|
|
644
|
+
const [{ policy }, { runtime }, { graph }] = await Promise.all([
|
|
645
|
+
loadChannelPolicy(root), loadChannelRuntime(root), loadGraph(root)
|
|
646
|
+
]);
|
|
647
|
+
ensureHealthy(policy, runtime, graph);
|
|
648
|
+
const limit = Number(maxItems);
|
|
649
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new Error("maxItems must be an integer between 1 and 100");
|
|
650
|
+
const items = runtime.events.filter((event) => {
|
|
651
|
+
if (!includeTerminal && TERMINAL_STATUSES.has(event.status)) return false;
|
|
652
|
+
if (agentId !== null && event.agentId !== stableId(agentId, "agentId")) return false;
|
|
653
|
+
if (projectId !== null && event.projectId !== stableId(projectId, "projectId")) return false;
|
|
654
|
+
if (groupId !== undefined && event.groupId !== stableId(groupId, "groupId", { nullable: true })) return false;
|
|
655
|
+
if (provider !== null && event.provider !== stableId(provider, "provider", { route: true })) return false;
|
|
656
|
+
return true;
|
|
657
|
+
}).slice(0, limit).map((event) => structuredClone(event));
|
|
658
|
+
return {
|
|
659
|
+
schema: "agentspine.channel-context/v1", items,
|
|
660
|
+
pending: items.filter((event) => !TERMINAL_STATUSES.has(event.status)).length,
|
|
661
|
+
authority: "execution-state-only"
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export { EVENT_SCHEMA as CHANNEL_EVENT_SCHEMA, POLICY_SCHEMA as CHANNEL_POLICY_SCHEMA, RUNTIME_SCHEMA as CHANNEL_RUNTIME_SCHEMA };
|