@telnyx/agent-harness 0.1.0-beta.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/README.md +179 -0
- package/dist/approvals.d.ts +140 -0
- package/dist/approvals.js +699 -0
- package/dist/channel.d.ts +178 -0
- package/dist/channel.js +184 -0
- package/dist/contract.d.ts +50 -0
- package/dist/contract.js +278 -0
- package/dist/durable.d.ts +81 -0
- package/dist/durable.js +650 -0
- package/dist/harness.d.ts +199 -0
- package/dist/harness.js +1223 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +19 -0
- package/dist/lifecycle.d.ts +9 -0
- package/dist/lifecycle.js +25 -0
- package/dist/node-adapter.d.ts +71 -0
- package/dist/node-adapter.js +736 -0
- package/dist/ports.d.ts +99 -0
- package/dist/ports.js +3 -0
- package/dist/runtime-adapter.d.ts +15 -0
- package/dist/runtime-adapter.js +70 -0
- package/dist/runtime-config.d.ts +38 -0
- package/dist/runtime-config.js +104 -0
- package/dist/scheduling.d.ts +46 -0
- package/dist/scheduling.js +425 -0
- package/dist/steps.d.ts +52 -0
- package/dist/steps.js +310 -0
- package/dist/tool-context.d.ts +8 -0
- package/dist/tool-context.js +9 -0
- package/dist/workspace.d.ts +151 -0
- package/dist/workspace.js +404 -0
- package/package.json +58 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import { ensureHarnessDurabilitySchema } from "./durable.js";
|
|
2
|
+
export const HARNESS_APPROVAL_STATUSES = ["pending", "granted", "denied", "expired", "consumed"];
|
|
3
|
+
export class HarnessAuthorizationError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code) {
|
|
6
|
+
super(`Harness authorization ${code}`);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.name = "HarnessAuthorizationError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class HarnessApprovalError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code) {
|
|
14
|
+
super(`Harness approval ${code}`);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.name = "HarnessApprovalError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const REDACTED = "[REDACTED]";
|
|
20
|
+
const SENSITIVE_KEY = /(?:authorization|bearer|credential|password|secret|token|api[_.-]?key)/i;
|
|
21
|
+
const MAX_ARGUMENT_BYTES = 64 * 1024;
|
|
22
|
+
const MAX_ID_BYTES = 512;
|
|
23
|
+
const APPROVAL_ACTIONS = Object.freeze({
|
|
24
|
+
read: "agent_harness.approvals.read",
|
|
25
|
+
grant: "agent_harness.approvals.grant",
|
|
26
|
+
deny: "agent_harness.approvals.deny",
|
|
27
|
+
});
|
|
28
|
+
function first(rows) { return rows[0]; }
|
|
29
|
+
function nonblank(value, name) {
|
|
30
|
+
if (!value.trim())
|
|
31
|
+
throw new Error(`${name} must not be blank`);
|
|
32
|
+
if (Buffer.byteLength(value, "utf8") > MAX_ID_BYTES)
|
|
33
|
+
throw new Error(`${name} exceeds ${MAX_ID_BYTES} UTF-8 bytes`);
|
|
34
|
+
}
|
|
35
|
+
function redacted(value) {
|
|
36
|
+
// COMPUTE-905: Use path-scoped cycle detection. A shared object referenced
|
|
37
|
+
// from multiple branches is acyclic and must be accepted. Only genuine
|
|
38
|
+
// cycles — where an object appears on its own ancestor path — are rejected.
|
|
39
|
+
const ancestors = new Set();
|
|
40
|
+
let nodes = 0;
|
|
41
|
+
const copy = (current, depth) => {
|
|
42
|
+
if (depth > 32)
|
|
43
|
+
throw new TypeError("approval arguments exceed the nesting limit");
|
|
44
|
+
if (current === null || typeof current === "string" || typeof current === "boolean")
|
|
45
|
+
return current;
|
|
46
|
+
if (typeof current === "number") {
|
|
47
|
+
if (!Number.isFinite(current))
|
|
48
|
+
throw new TypeError("approval arguments must be JSON-safe");
|
|
49
|
+
return current;
|
|
50
|
+
}
|
|
51
|
+
if (typeof current !== "object")
|
|
52
|
+
throw new TypeError("approval arguments must be JSON-safe");
|
|
53
|
+
if (ancestors.has(current))
|
|
54
|
+
throw new TypeError("approval arguments must not contain cycles");
|
|
55
|
+
ancestors.add(current);
|
|
56
|
+
nodes += 1;
|
|
57
|
+
if (nodes > 10_000)
|
|
58
|
+
throw new TypeError("approval arguments exceed the node limit");
|
|
59
|
+
try {
|
|
60
|
+
if (Array.isArray(current)) {
|
|
61
|
+
if (Object.getPrototypeOf(current) !== Array.prototype)
|
|
62
|
+
throw new TypeError("approval arguments must not contain inherited arrays");
|
|
63
|
+
const allowed = new Set(["length", ...Array.from({ length: current.length }, (_, index) => String(index))]);
|
|
64
|
+
for (const key of Object.getOwnPropertyNames(current)) {
|
|
65
|
+
if (!allowed.has(key))
|
|
66
|
+
throw new TypeError("approval arguments must not contain non-index array properties");
|
|
67
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
68
|
+
if (descriptor === undefined || !("value" in descriptor))
|
|
69
|
+
throw new TypeError("approval arguments must not contain accessors");
|
|
70
|
+
}
|
|
71
|
+
const output = [];
|
|
72
|
+
for (let index = 0; index < current.length; index += 1) {
|
|
73
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, String(index));
|
|
74
|
+
if (descriptor === undefined)
|
|
75
|
+
throw new TypeError("approval arguments must not contain sparse arrays");
|
|
76
|
+
if (!("value" in descriptor))
|
|
77
|
+
throw new TypeError("approval arguments must not contain accessors");
|
|
78
|
+
output.push(copy(descriptor.value, depth + 1));
|
|
79
|
+
}
|
|
80
|
+
return Object.freeze(output);
|
|
81
|
+
}
|
|
82
|
+
const prototype = Object.getPrototypeOf(current);
|
|
83
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
84
|
+
throw new TypeError("approval arguments must be plain JSON data");
|
|
85
|
+
const output = {};
|
|
86
|
+
for (const key of Object.keys(current)) {
|
|
87
|
+
if (Buffer.byteLength(key, "utf8") > MAX_ID_BYTES)
|
|
88
|
+
throw new TypeError(`approval argument key exceeds ${MAX_ID_BYTES} UTF-8 bytes`);
|
|
89
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
90
|
+
if (descriptor === undefined || !("value" in descriptor))
|
|
91
|
+
throw new TypeError("approval arguments must not contain accessors");
|
|
92
|
+
output[key] = SENSITIVE_KEY.test(key) ? REDACTED : copy(descriptor.value, depth + 1);
|
|
93
|
+
}
|
|
94
|
+
return Object.freeze(output);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
ancestors.delete(current);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
return copy(value, 0);
|
|
101
|
+
}
|
|
102
|
+
/** Private execution snapshot: validate/copy JSON without applying public redaction. */
|
|
103
|
+
function canonicalJson(value) {
|
|
104
|
+
// COMPUTE-905: Use path-scoped cycle detection, same as redacted().
|
|
105
|
+
const ancestors = new Set();
|
|
106
|
+
let nodes = 0;
|
|
107
|
+
const copy = (current, depth) => {
|
|
108
|
+
if (depth > 32)
|
|
109
|
+
throw new TypeError("canonical approval input exceeds the nesting limit");
|
|
110
|
+
if (current === null || typeof current === "string" || typeof current === "boolean")
|
|
111
|
+
return current;
|
|
112
|
+
if (typeof current === "number") {
|
|
113
|
+
if (!Number.isFinite(current))
|
|
114
|
+
throw new TypeError("canonical approval input must be JSON-safe");
|
|
115
|
+
return current;
|
|
116
|
+
}
|
|
117
|
+
if (typeof current !== "object")
|
|
118
|
+
throw new TypeError("canonical approval input must be JSON-safe");
|
|
119
|
+
if (ancestors.has(current))
|
|
120
|
+
throw new TypeError("canonical approval input must not contain cycles");
|
|
121
|
+
ancestors.add(current);
|
|
122
|
+
nodes += 1;
|
|
123
|
+
if (nodes > 10_000)
|
|
124
|
+
throw new TypeError("canonical approval input exceeds the node limit");
|
|
125
|
+
try {
|
|
126
|
+
if (Array.isArray(current)) {
|
|
127
|
+
if (Object.getPrototypeOf(current) !== Array.prototype)
|
|
128
|
+
throw new TypeError("canonical approval input must not contain inherited arrays");
|
|
129
|
+
const allowed = new Set(["length", ...Array.from({ length: current.length }, (_, index) => String(index))]);
|
|
130
|
+
for (const key of Object.getOwnPropertyNames(current)) {
|
|
131
|
+
if (!allowed.has(key))
|
|
132
|
+
throw new TypeError("canonical approval input must not contain non-index array properties");
|
|
133
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
134
|
+
if (descriptor === undefined || !("value" in descriptor))
|
|
135
|
+
throw new TypeError("canonical approval input must not contain accessors");
|
|
136
|
+
}
|
|
137
|
+
const output = [];
|
|
138
|
+
for (let index = 0; index < current.length; index += 1) {
|
|
139
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, String(index));
|
|
140
|
+
if (descriptor === undefined)
|
|
141
|
+
throw new TypeError("canonical approval input must not contain sparse arrays");
|
|
142
|
+
if (!("value" in descriptor))
|
|
143
|
+
throw new TypeError("canonical approval input must not contain accessors");
|
|
144
|
+
output.push(copy(descriptor.value, depth + 1));
|
|
145
|
+
}
|
|
146
|
+
return Object.freeze(output);
|
|
147
|
+
}
|
|
148
|
+
const prototype = Object.getPrototypeOf(current);
|
|
149
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
150
|
+
throw new TypeError("canonical approval input must be plain JSON data");
|
|
151
|
+
const output = {};
|
|
152
|
+
for (const key of Object.keys(current).sort()) {
|
|
153
|
+
if (Buffer.byteLength(key, "utf8") > MAX_ID_BYTES)
|
|
154
|
+
throw new TypeError(`canonical approval key exceeds ${MAX_ID_BYTES} UTF-8 bytes`);
|
|
155
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, key);
|
|
156
|
+
if (descriptor === undefined || !("value" in descriptor))
|
|
157
|
+
throw new TypeError("canonical approval input must not contain accessors");
|
|
158
|
+
output[key] = copy(descriptor.value, depth + 1);
|
|
159
|
+
}
|
|
160
|
+
return Object.freeze(output);
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
ancestors.delete(current);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
return copy(value, 0);
|
|
167
|
+
}
|
|
168
|
+
function parseArguments(value) {
|
|
169
|
+
try {
|
|
170
|
+
return redacted(JSON.parse(value));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new Error("stored approval arguments are invalid");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function publicApproval(row) {
|
|
177
|
+
return Object.freeze({
|
|
178
|
+
id: row.id, runId: row.run_id, toolName: row.tool_name, toolCallId: row.tool_call_id,
|
|
179
|
+
arguments: parseArguments(row.arguments), requiredAction: row.required_action,
|
|
180
|
+
authorization: Object.freeze({ serviceAccountId: row.service_account_id, organizationId: row.organization_id }),
|
|
181
|
+
status: row.status, expiresAt: row.expires_at, createdAt: row.created_at, updatedAt: row.updated_at,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
function ensureApprovalSchema(ports) {
|
|
185
|
+
ensureHarnessDurabilitySchema(ports);
|
|
186
|
+
}
|
|
187
|
+
function authIdentity(ports) {
|
|
188
|
+
const identity = ports.authorization?.identity();
|
|
189
|
+
if (identity === undefined)
|
|
190
|
+
throw new HarnessAuthorizationError("identity_unavailable");
|
|
191
|
+
if (!identity.serviceAccountId.trim() || !identity.organizationId.trim())
|
|
192
|
+
throw new HarnessAuthorizationError("unauthenticated");
|
|
193
|
+
return identity;
|
|
194
|
+
}
|
|
195
|
+
async function authorize(ports, action, row, persistedSubject = false) {
|
|
196
|
+
const liveIdentity = authIdentity(ports);
|
|
197
|
+
const identity = persistedSubject
|
|
198
|
+
? Object.freeze({ serviceAccountId: row.service_account_id, organizationId: row.organization_id })
|
|
199
|
+
: liveIdentity;
|
|
200
|
+
nonblank(identity.serviceAccountId, "approval service account id");
|
|
201
|
+
nonblank(identity.organizationId, "approval organization id");
|
|
202
|
+
const decision = await ports.authorization?.authorize({
|
|
203
|
+
identity, action, resource: `agent_harness/approvals/${row.id}`, runId: row.run_id, approvalId: row.id,
|
|
204
|
+
});
|
|
205
|
+
if (decision?.kind === "allow")
|
|
206
|
+
return;
|
|
207
|
+
if (decision?.kind === "deny")
|
|
208
|
+
throw new HarnessAuthorizationError("forbidden");
|
|
209
|
+
throw new HarnessAuthorizationError("policy_unavailable");
|
|
210
|
+
}
|
|
211
|
+
export function createHarnessApprovalLedger(ports) {
|
|
212
|
+
const execution = createHarnessApprovalExecutionLedger(ports);
|
|
213
|
+
return Object.freeze({
|
|
214
|
+
inspect: execution.inspect,
|
|
215
|
+
grant: execution.grant,
|
|
216
|
+
deny: execution.deny,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
export function createHarnessApprovalExecutionLedger(ports) {
|
|
220
|
+
let initialized = false;
|
|
221
|
+
const ensure = () => { if (!initialized) {
|
|
222
|
+
ensureApprovalSchema(ports);
|
|
223
|
+
initialized = true;
|
|
224
|
+
} };
|
|
225
|
+
const read = (id) => first(ports.sql.exec("SELECT id,run_id,tool_name,tool_call_id,arguments,required_action,service_account_id,organization_id,status,expires_at,created_at,updated_at FROM __telnyx_agent_harness_approvals WHERE id = ?", id).toArray());
|
|
226
|
+
const readEffect = (runId, toolCallId) => first(ports.sql.exec("SELECT id,run_id,tool_name,tool_call_id,arguments,required_action,service_account_id,organization_id,status,expires_at,created_at,updated_at FROM __telnyx_agent_harness_approvals WHERE run_id = ? AND tool_call_id = ?", runId, toolCallId).toArray());
|
|
227
|
+
const recordAudit = (id, event) => {
|
|
228
|
+
let actor = null;
|
|
229
|
+
try {
|
|
230
|
+
actor = ports.authorization?.identity()?.serviceAccountId ?? null;
|
|
231
|
+
}
|
|
232
|
+
catch { /* audit must not amplify an unavailable identity */ }
|
|
233
|
+
const normalized = event === "requested" ? "request_allowed"
|
|
234
|
+
: event === "granted" ? "grant_allowed"
|
|
235
|
+
: event === "denied" ? "deny_allowed"
|
|
236
|
+
: event === "expired" ? "expiry_expired"
|
|
237
|
+
: event === "consumed" ? "effect_consume_allowed"
|
|
238
|
+
: event === "conflicting" ? "effect_reauthorize_conflicting"
|
|
239
|
+
: event;
|
|
240
|
+
const operation = ["effect_reauthorize", "effect_consume", "inspect", "audit", "request", "grant", "deny", "expiry", "recovery"]
|
|
241
|
+
.find((candidate) => normalized.startsWith(`${candidate}_`));
|
|
242
|
+
if (operation === undefined)
|
|
243
|
+
throw new Error("approval audit event has no fixed operation class");
|
|
244
|
+
const outcome = normalized.slice(operation.length + 1);
|
|
245
|
+
if (!outcome)
|
|
246
|
+
throw new Error("approval audit event has no fixed outcome class");
|
|
247
|
+
// COMPUTE-904: Use a monotonic per-approval sequence as the causal
|
|
248
|
+
// tie-breaker instead of a random UUID. Same-millisecond events are
|
|
249
|
+
// returned in insertion order, preserving causal chronology.
|
|
250
|
+
const seqRow = first(ports.sql.exec("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM __telnyx_agent_harness_approval_audit WHERE approval_id = ?", id).toArray());
|
|
251
|
+
const seq = seqRow === undefined ? 1 : seqRow.next;
|
|
252
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_approval_audit(id,approval_id,event,actor,action,outcome,at,seq) VALUES (?,?,?,?,?,?,?,?)", crypto.randomUUID(), id, normalized, actor, operation, outcome, ports.clock.now(), seq);
|
|
253
|
+
};
|
|
254
|
+
const authorizeRead = async (surface, row) => {
|
|
255
|
+
try {
|
|
256
|
+
await authorize(ports, APPROVAL_ACTIONS.read, row);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
ports.sql.transactionSync(() => recordAudit(row.id, `${surface}_${error instanceof HarnessAuthorizationError ? error.code : "policy_unavailable"}`));
|
|
260
|
+
throw error;
|
|
261
|
+
}
|
|
262
|
+
ports.sql.transactionSync(() => recordAudit(row.id, `${surface}_allowed`));
|
|
263
|
+
};
|
|
264
|
+
const expiry = (row) => ports.sql.transactionSync(() => {
|
|
265
|
+
const current = read(row.id);
|
|
266
|
+
if ((current.status === "pending" || current.status === "granted") && current.expires_at <= ports.clock.now()) {
|
|
267
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_approvals SET status = 'expired', updated_at = ? WHERE id = ? AND status IN ('pending','granted')", ports.clock.now(), current.id);
|
|
268
|
+
recordAudit(current.id, "expired");
|
|
269
|
+
}
|
|
270
|
+
return read(row.id);
|
|
271
|
+
});
|
|
272
|
+
const rejectedDecision = (status, requested) => {
|
|
273
|
+
if (status === "expired")
|
|
274
|
+
return new HarnessApprovalError("expired");
|
|
275
|
+
if (status === "consumed")
|
|
276
|
+
return new HarnessApprovalError("consumed");
|
|
277
|
+
return new HarnessApprovalError(status === requested ? "replayed" : "conflicting");
|
|
278
|
+
};
|
|
279
|
+
return Object.freeze({
|
|
280
|
+
async request(request) {
|
|
281
|
+
return requestApproval(request);
|
|
282
|
+
},
|
|
283
|
+
async requestWithPause(request, pause) {
|
|
284
|
+
return requestApproval(request, pause);
|
|
285
|
+
},
|
|
286
|
+
async requestBatchWithPause(requests, pause) {
|
|
287
|
+
ensure();
|
|
288
|
+
if (requests.length === 0)
|
|
289
|
+
throw new Error("approval request batch is empty");
|
|
290
|
+
return ports.sql.transactionSync(() => {
|
|
291
|
+
const created = requests.map((request) => requestApproval(request));
|
|
292
|
+
pause(created);
|
|
293
|
+
return Object.freeze(created);
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
async inspect(id) {
|
|
297
|
+
ensure();
|
|
298
|
+
// Authenticate before target lookup to prevent existence disclosure.
|
|
299
|
+
authIdentity(ports);
|
|
300
|
+
const row = read(id);
|
|
301
|
+
if (!row)
|
|
302
|
+
throw new Error("approval not found");
|
|
303
|
+
await authorizeRead("inspect", row);
|
|
304
|
+
return publicApproval(expiry(row));
|
|
305
|
+
},
|
|
306
|
+
async inspectUnchecked(id) { ensure(); const row = read(id); return row === undefined ? undefined : publicApproval(expiry(row)); },
|
|
307
|
+
async grant(id) {
|
|
308
|
+
return grantApproval(id);
|
|
309
|
+
},
|
|
310
|
+
async grantWithResume(id, decide, resume) {
|
|
311
|
+
return grantApproval(id, (approval) => {
|
|
312
|
+
decide(approval);
|
|
313
|
+
if (runReadyToResume(approval.runId))
|
|
314
|
+
resume(approval);
|
|
315
|
+
});
|
|
316
|
+
},
|
|
317
|
+
async denyWithCancel(id, cancel) {
|
|
318
|
+
return denyApproval(id, cancel);
|
|
319
|
+
},
|
|
320
|
+
async deny(id) {
|
|
321
|
+
return denyApproval(id);
|
|
322
|
+
},
|
|
323
|
+
async reauthorizeEffect(runId, toolCallId, toolName, input) {
|
|
324
|
+
ensure();
|
|
325
|
+
if (toolCallId === undefined)
|
|
326
|
+
return undefined;
|
|
327
|
+
const row = readEffect(runId, toolCallId);
|
|
328
|
+
if (row === undefined)
|
|
329
|
+
return undefined;
|
|
330
|
+
const current = expiry(row);
|
|
331
|
+
if (current.status === "expired")
|
|
332
|
+
throw new HarnessApprovalError("expired");
|
|
333
|
+
if (current.status !== "granted")
|
|
334
|
+
throw new HarnessApprovalError(current.status === "consumed" ? "consumed" : "replayed");
|
|
335
|
+
let approvedInput;
|
|
336
|
+
try {
|
|
337
|
+
const message = first(ports.sql.exec("SELECT approval_id,codec_version,run_id,tool_call_id,tool_name,journal_seq,after_message_seq,approval_epoch,canonical_tool_call,signature,approved,reason,response_at FROM __telnyx_agent_harness_approval_messages WHERE approval_id = ? AND run_id = ? AND tool_call_id = ? AND approval_epoch = (SELECT MAX(approval_epoch) FROM __telnyx_agent_harness_approval_messages WHERE run_id = ?)", current.id, runId, toolCallId, runId).toArray());
|
|
338
|
+
if (message === undefined || message.approved !== 1 || message.response_at === null)
|
|
339
|
+
throw new Error("approval envelope is unavailable");
|
|
340
|
+
const linkage = first(ports.sql.exec("SELECT run_id,approval_epoch,tool_name,ordinal,journal_marker,provider_call_id,private_sequence,canonical_tool_call FROM __telnyx_agent_harness_approval_linkage WHERE approval_id = ?", current.id).toArray());
|
|
341
|
+
const durableRunExists = ports.sql.exec("SELECT 1 AS present FROM __telnyx_agent_harness_runs WHERE id = ?", runId).toArray().length > 0;
|
|
342
|
+
if (durableRunExists && (linkage === undefined
|
|
343
|
+
|| linkage.run_id !== runId
|
|
344
|
+
|| linkage.approval_epoch !== message.approval_epoch
|
|
345
|
+
|| linkage.tool_name !== message.tool_name
|
|
346
|
+
|| linkage.provider_call_id !== toolCallId
|
|
347
|
+
|| linkage.private_sequence !== message.journal_seq
|
|
348
|
+
|| linkage.ordinal !== message.journal_seq
|
|
349
|
+
|| linkage.journal_marker !== `approval:${current.id}:${message.journal_seq}`
|
|
350
|
+
|| linkage.canonical_tool_call !== message.canonical_tool_call))
|
|
351
|
+
throw new Error("approval durable linkage is invalid");
|
|
352
|
+
const approved = approvalCanonicalToolCall(message);
|
|
353
|
+
const resumed = canonicalToolCall({ toolCallId, toolName, input });
|
|
354
|
+
if (approved.toolCallId !== resumed.toolCallId || approved.toolName !== resumed.toolName || JSON.stringify(approved.input) !== JSON.stringify(resumed.input)) {
|
|
355
|
+
throw new Error("approval envelope conflicts");
|
|
356
|
+
}
|
|
357
|
+
approvedInput = approved.input;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
ports.sql.transactionSync(() => recordAudit(current.id, "conflicting"));
|
|
361
|
+
throw new HarnessApprovalError("conflicting");
|
|
362
|
+
}
|
|
363
|
+
try {
|
|
364
|
+
await authorize(ports, current.required_action, current, true);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
ports.sql.transactionSync(() => recordAudit(current.id, `effect_reauthorize_${error instanceof HarnessAuthorizationError ? error.code : "policy_unavailable"}`));
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
ports.sql.transactionSync(() => recordAudit(current.id, "effect_reauthorize_allowed"));
|
|
371
|
+
return Object.freeze({ id: current.id, input: approvedInput });
|
|
372
|
+
},
|
|
373
|
+
consumeAuthorizedEffect(runId, approvalId) {
|
|
374
|
+
ensure();
|
|
375
|
+
if (approvalId === undefined)
|
|
376
|
+
return;
|
|
377
|
+
// COMPUTE-906: Return a typed HarnessApprovalError for missing approvals
|
|
378
|
+
// instead of an untyped TypeError from the `!` assertion.
|
|
379
|
+
const row = read(approvalId);
|
|
380
|
+
if (row === undefined)
|
|
381
|
+
throw new HarnessApprovalError("missing");
|
|
382
|
+
const granted = expiry(row);
|
|
383
|
+
if (granted.run_id !== runId)
|
|
384
|
+
throw new HarnessApprovalError("conflicting");
|
|
385
|
+
if (granted.status === "expired")
|
|
386
|
+
throw new HarnessApprovalError("expired");
|
|
387
|
+
if (granted.status !== "granted")
|
|
388
|
+
throw new HarnessApprovalError(granted.status === "consumed" ? "consumed" : "replayed");
|
|
389
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_approvals SET status = 'consumed', updated_at = ? WHERE id = ? AND status = 'granted'", ports.clock.now(), granted.id);
|
|
390
|
+
if (read(granted.id)?.status !== "consumed")
|
|
391
|
+
throw new HarnessApprovalError("consumed");
|
|
392
|
+
recordAudit(granted.id, "consumed");
|
|
393
|
+
},
|
|
394
|
+
async audit(id) {
|
|
395
|
+
ensure();
|
|
396
|
+
authIdentity(ports);
|
|
397
|
+
const row = read(id);
|
|
398
|
+
if (!row)
|
|
399
|
+
throw new Error("approval not found");
|
|
400
|
+
await authorizeRead("audit", row);
|
|
401
|
+
// COMPUTE-904: Order by at, seq to preserve causal insertion order for
|
|
402
|
+
// same-timestamp events, instead of the random UUID id tie-breaker.
|
|
403
|
+
return Object.freeze(ports.sql.exec("SELECT event,action,outcome,at FROM __telnyx_agent_harness_approval_audit WHERE approval_id = ? ORDER BY at,seq", id).toArray().map((audit) => Object.freeze(audit)));
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
function requestApproval(request, afterCreate) {
|
|
407
|
+
ensure();
|
|
408
|
+
if (request.id !== undefined)
|
|
409
|
+
nonblank(request.id, "approval id");
|
|
410
|
+
nonblank(request.runId, "run id");
|
|
411
|
+
nonblank(request.toolName, "tool name");
|
|
412
|
+
nonblank(request.toolCallId, "tool call id");
|
|
413
|
+
nonblank(request.requiredAction, "tool action");
|
|
414
|
+
if (!Number.isSafeInteger(request.expiresAt) || request.expiresAt <= ports.clock.now())
|
|
415
|
+
throw new Error("approval expiry must be in the future");
|
|
416
|
+
const originalSubject = first(ports.sql.exec("SELECT service_account_id,organization_id FROM __telnyx_agent_harness_run_authority WHERE run_id = ?", request.runId).toArray());
|
|
417
|
+
const identity = originalSubject === undefined
|
|
418
|
+
? authIdentity(ports)
|
|
419
|
+
: Object.freeze({ serviceAccountId: originalSubject.service_account_id, organizationId: originalSubject.organization_id });
|
|
420
|
+
const argumentsValue = redacted(request.arguments);
|
|
421
|
+
const serializedArguments = JSON.stringify(argumentsValue);
|
|
422
|
+
if (Buffer.byteLength(serializedArguments, "utf8") > MAX_ARGUMENT_BYTES)
|
|
423
|
+
throw new Error(`approval arguments exceed ${MAX_ARGUMENT_BYTES} UTF-8 bytes`);
|
|
424
|
+
const created = ports.sql.transactionSync(() => {
|
|
425
|
+
const existing = first(ports.sql.exec("SELECT id,run_id,tool_name,tool_call_id,arguments,required_action,service_account_id,organization_id,status,expires_at,created_at,updated_at FROM __telnyx_agent_harness_approvals WHERE run_id = ? AND tool_call_id = ?", request.runId, request.toolCallId).toArray());
|
|
426
|
+
if (existing !== undefined) {
|
|
427
|
+
if (existing.tool_name !== request.toolName || existing.arguments !== serializedArguments || existing.required_action !== request.requiredAction || existing.expires_at !== request.expiresAt)
|
|
428
|
+
throw new HarnessApprovalError("conflicting");
|
|
429
|
+
if (existing.status !== "pending")
|
|
430
|
+
throw new HarnessApprovalError(existing.status === "consumed" ? "consumed" : "replayed");
|
|
431
|
+
return existing;
|
|
432
|
+
}
|
|
433
|
+
const now = ports.clock.now();
|
|
434
|
+
const id = request.id ?? `approval-${ports.identity.id}-${now}-${crypto.randomUUID()}`;
|
|
435
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_approvals(id,run_id,tool_name,tool_call_id,arguments,required_action,service_account_id,organization_id,status,expires_at,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,'pending',?,?,?)", id, request.runId, request.toolName, request.toolCallId, serializedArguments, request.requiredAction, identity.serviceAccountId, identity.organizationId, request.expiresAt, now, now);
|
|
436
|
+
recordAudit(id, "requested");
|
|
437
|
+
afterCreate?.();
|
|
438
|
+
return read(id);
|
|
439
|
+
});
|
|
440
|
+
return Object.freeze({ id: created.id, status: "pending" });
|
|
441
|
+
}
|
|
442
|
+
function runReadyToResume(runId) {
|
|
443
|
+
nonblank(runId, "run id");
|
|
444
|
+
// COMPUTE-906: Centralize continuation readiness validation. This must
|
|
445
|
+
// match the exhaustive outcome contract in durable.ts approvalRunReady,
|
|
446
|
+
// including the invalidLinkage check that was previously omitted here.
|
|
447
|
+
const approvals = ports.sql.exec("SELECT approvals.id FROM __telnyx_agent_harness_approvals AS approvals JOIN __telnyx_agent_harness_approval_messages AS messages ON messages.approval_id = approvals.id WHERE approvals.run_id = ? AND messages.run_id = ? AND messages.approval_epoch = (SELECT MAX(approval_epoch) FROM __telnyx_agent_harness_approval_messages WHERE run_id = ?)", runId, runId, runId).toArray();
|
|
448
|
+
if (approvals.length === 0)
|
|
449
|
+
return false;
|
|
450
|
+
const messages = ports.sql.exec("SELECT approval_id FROM __telnyx_agent_harness_approval_messages WHERE run_id = ? AND approval_epoch = (SELECT MAX(approval_epoch) FROM __telnyx_agent_harness_approval_messages WHERE run_id = ?)", runId, runId).toArray();
|
|
451
|
+
if (messages.length !== approvals.length)
|
|
452
|
+
return false;
|
|
453
|
+
const incompleteApproval = first(ports.sql.exec("SELECT approvals.id FROM __telnyx_agent_harness_approvals AS approvals JOIN __telnyx_agent_harness_approval_messages AS messages ON messages.approval_id = approvals.id WHERE approvals.run_id = ? AND messages.run_id = ? AND messages.approval_epoch = (SELECT MAX(approval_epoch) FROM __telnyx_agent_harness_approval_messages WHERE run_id = ?) AND approvals.status NOT IN ('granted','denied') LIMIT 1", runId, runId, runId).toArray());
|
|
454
|
+
const incompleteResponse = first(ports.sql.exec("SELECT approval_id FROM __telnyx_agent_harness_approval_messages WHERE run_id = ? AND approval_epoch = (SELECT MAX(approval_epoch) FROM __telnyx_agent_harness_approval_messages WHERE run_id = ?) AND approved IS NULL LIMIT 1", runId, runId).toArray());
|
|
455
|
+
const invalidLinkage = first(ports.sql.exec("SELECT messages.approval_id FROM __telnyx_agent_harness_approval_messages AS messages LEFT JOIN __telnyx_agent_harness_approval_linkage AS linkage ON linkage.approval_id = messages.approval_id WHERE messages.run_id = ? AND messages.approval_epoch = (SELECT MAX(approval_epoch) FROM __telnyx_agent_harness_approval_messages WHERE run_id = ?) AND (linkage.approval_id IS NULL OR linkage.run_id <> messages.run_id OR linkage.approval_epoch <> messages.approval_epoch OR linkage.tool_name <> messages.tool_name OR linkage.provider_call_id <> messages.tool_call_id OR linkage.private_sequence <> messages.journal_seq OR linkage.ordinal <> messages.journal_seq OR linkage.journal_marker <> ('approval:' || messages.approval_id || ':' || messages.journal_seq) OR linkage.canonical_tool_call <> messages.canonical_tool_call) LIMIT 1", runId, runId).toArray());
|
|
456
|
+
return incompleteApproval === undefined
|
|
457
|
+
&& incompleteResponse === undefined
|
|
458
|
+
&& invalidLinkage === undefined;
|
|
459
|
+
}
|
|
460
|
+
async function grantApproval(id, afterGrant) {
|
|
461
|
+
ensure();
|
|
462
|
+
authIdentity(ports);
|
|
463
|
+
const row = read(id);
|
|
464
|
+
if (!row)
|
|
465
|
+
throw new Error("approval not found");
|
|
466
|
+
try {
|
|
467
|
+
await authorize(ports, APPROVAL_ACTIONS.grant, row);
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
ports.sql.transactionSync(() => recordAudit(id, `grant_${error instanceof HarnessAuthorizationError ? error.code : "policy_unavailable"}`));
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
const current = expiry(read(id));
|
|
474
|
+
if (current.status !== "pending") {
|
|
475
|
+
const error = rejectedDecision(current.status, "granted");
|
|
476
|
+
ports.sql.transactionSync(() => recordAudit(id, `grant_${error.code}`));
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
let decided;
|
|
480
|
+
try {
|
|
481
|
+
decided = ports.sql.transactionSync(() => {
|
|
482
|
+
const current = read(id);
|
|
483
|
+
if (current.status !== "pending")
|
|
484
|
+
throw rejectedDecision(current.status, "granted");
|
|
485
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_approvals SET status = 'granted', updated_at = ? WHERE id = ? AND status = 'pending'", ports.clock.now(), id);
|
|
486
|
+
recordAudit(id, "granted");
|
|
487
|
+
const granted = read(id);
|
|
488
|
+
afterGrant?.(publicApproval(granted));
|
|
489
|
+
return granted;
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
if (error instanceof HarnessApprovalError)
|
|
494
|
+
ports.sql.transactionSync(() => recordAudit(id, `grant_${error.code}`));
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
return publicApproval(decided);
|
|
498
|
+
}
|
|
499
|
+
async function denyApproval(id, afterDeny) {
|
|
500
|
+
ensure();
|
|
501
|
+
authIdentity(ports);
|
|
502
|
+
const row = read(id);
|
|
503
|
+
if (!row)
|
|
504
|
+
throw new Error("approval not found");
|
|
505
|
+
try {
|
|
506
|
+
await authorize(ports, APPROVAL_ACTIONS.deny, row);
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
ports.sql.transactionSync(() => recordAudit(id, `deny_${error instanceof HarnessAuthorizationError ? error.code : "policy_unavailable"}`));
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
const current = expiry(read(id));
|
|
513
|
+
if (current.status !== "pending") {
|
|
514
|
+
const error = rejectedDecision(current.status, "denied");
|
|
515
|
+
ports.sql.transactionSync(() => recordAudit(id, `deny_${error.code}`));
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
let decided;
|
|
519
|
+
try {
|
|
520
|
+
decided = ports.sql.transactionSync(() => {
|
|
521
|
+
const current = read(id);
|
|
522
|
+
if (current.status !== "pending")
|
|
523
|
+
throw rejectedDecision(current.status, "denied");
|
|
524
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_approvals SET status = 'denied', updated_at = ? WHERE id = ? AND status = 'pending'", ports.clock.now(), id);
|
|
525
|
+
recordAudit(id, "denied");
|
|
526
|
+
const denied = read(id);
|
|
527
|
+
afterDeny?.(publicApproval(denied));
|
|
528
|
+
return denied;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
if (error instanceof HarnessApprovalError)
|
|
533
|
+
ports.sql.transactionSync(() => recordAudit(id, `deny_${error.code}`));
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
return publicApproval(decided);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
const APPROVAL_MESSAGE_CODEC_VERSION = 1;
|
|
540
|
+
const MAX_APPROVAL_REASON_BYTES = 4 * 1024;
|
|
541
|
+
function canonicalToolCall(value) {
|
|
542
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
543
|
+
throw new Error("canonical approval tool call is invalid");
|
|
544
|
+
}
|
|
545
|
+
const input = value;
|
|
546
|
+
if (typeof input.toolCallId !== "string" || typeof input.toolName !== "string") {
|
|
547
|
+
throw new Error("canonical approval tool call is invalid");
|
|
548
|
+
}
|
|
549
|
+
nonblank(input.toolCallId, "canonical tool call id");
|
|
550
|
+
nonblank(input.toolName, "canonical tool name");
|
|
551
|
+
return Object.freeze({ toolCallId: input.toolCallId, toolName: input.toolName, input: canonicalJson(input.input) });
|
|
552
|
+
}
|
|
553
|
+
function serializedCanonicalToolCall(value) {
|
|
554
|
+
const serialized = JSON.stringify(canonicalToolCall(value));
|
|
555
|
+
if (Buffer.byteLength(serialized, "utf8") > MAX_ARGUMENT_BYTES) {
|
|
556
|
+
throw new Error(`canonical approval tool call exceeds ${MAX_ARGUMENT_BYTES} UTF-8 bytes`);
|
|
557
|
+
}
|
|
558
|
+
return serialized;
|
|
559
|
+
}
|
|
560
|
+
function approvalCanonicalToolCall(row) {
|
|
561
|
+
if (row.canonical_tool_call === null)
|
|
562
|
+
throw new Error("approval canonical tool call is unavailable");
|
|
563
|
+
let call;
|
|
564
|
+
try {
|
|
565
|
+
call = canonicalToolCall(JSON.parse(row.canonical_tool_call));
|
|
566
|
+
}
|
|
567
|
+
catch {
|
|
568
|
+
throw new Error("approval canonical tool call is malformed");
|
|
569
|
+
}
|
|
570
|
+
if (call.toolCallId !== row.tool_call_id || call.toolName !== row.tool_name) {
|
|
571
|
+
throw new Error("approval canonical tool call linkage is invalid");
|
|
572
|
+
}
|
|
573
|
+
return call;
|
|
574
|
+
}
|
|
575
|
+
function approvalMessage(row) {
|
|
576
|
+
if (row.codec_version !== APPROVAL_MESSAGE_CODEC_VERSION)
|
|
577
|
+
throw new Error("approval message codec is unsupported");
|
|
578
|
+
if (row.approved === null || row.response_at === null)
|
|
579
|
+
throw new Error("approval response is unavailable");
|
|
580
|
+
if (row.approved !== 0 && row.approved !== 1)
|
|
581
|
+
throw new Error("approval response is malformed");
|
|
582
|
+
if (row.reason !== null && Buffer.byteLength(row.reason, "utf8") > MAX_APPROVAL_REASON_BYTES)
|
|
583
|
+
throw new Error("approval response is malformed");
|
|
584
|
+
const call = approvalCanonicalToolCall(row);
|
|
585
|
+
return Object.freeze([
|
|
586
|
+
Object.freeze({ role: "assistant", content: Object.freeze([
|
|
587
|
+
Object.freeze({ type: "tool-call", toolCallId: call.toolCallId, toolName: call.toolName, input: call.input }),
|
|
588
|
+
Object.freeze({ type: "tool-approval-request", approvalId: row.approval_id, toolCallId: row.tool_call_id, ...(row.signature === null ? {} : { signature: row.signature }) }),
|
|
589
|
+
]) }),
|
|
590
|
+
Object.freeze({ role: "tool", content: Object.freeze([Object.freeze({ type: "tool-approval-response", approvalId: row.approval_id, approved: row.approved === 1, ...(row.reason === null ? {} : { reason: row.reason }) })]) }),
|
|
591
|
+
]);
|
|
592
|
+
}
|
|
593
|
+
/** Private SQL codec for the pinned AI SDK approval-message subset only. */
|
|
594
|
+
export function createHarnessApprovalMessageJournal(ports) {
|
|
595
|
+
let initialized = false;
|
|
596
|
+
const ensure = () => { if (!initialized) {
|
|
597
|
+
ensureApprovalSchema(ports);
|
|
598
|
+
initialized = true;
|
|
599
|
+
} };
|
|
600
|
+
const read = (approvalId) => first(ports.sql.exec("SELECT approval_id,codec_version,run_id,tool_call_id,tool_name,journal_seq,after_message_seq,approval_epoch,canonical_tool_call,signature,approved,reason,response_at FROM __telnyx_agent_harness_approval_messages WHERE approval_id = ?", approvalId).toArray());
|
|
601
|
+
return Object.freeze({
|
|
602
|
+
nextRunSequence(runId) {
|
|
603
|
+
ensure();
|
|
604
|
+
nonblank(runId, "run id");
|
|
605
|
+
return ports.sql.transactionSync(() => {
|
|
606
|
+
const row = first(ports.sql.exec("SELECT COALESCE(MAX(journal_seq), 0) + 1 AS next FROM __telnyx_agent_harness_approval_messages WHERE run_id = ?", runId).toArray());
|
|
607
|
+
if (row === undefined || !Number.isSafeInteger(row.next) || row.next < 1) {
|
|
608
|
+
throw new Error("approval journal sequence is invalid");
|
|
609
|
+
}
|
|
610
|
+
return row.next;
|
|
611
|
+
});
|
|
612
|
+
},
|
|
613
|
+
nextRunApprovalEpoch(runId) {
|
|
614
|
+
ensure();
|
|
615
|
+
nonblank(runId, "run id");
|
|
616
|
+
const row = first(ports.sql.exec("SELECT COALESCE(MAX(approval_epoch), 0) + 1 AS next FROM __telnyx_agent_harness_approval_messages WHERE run_id = ?", runId).toArray());
|
|
617
|
+
if (row === undefined || !Number.isSafeInteger(row.next) || row.next < 1)
|
|
618
|
+
throw new Error("approval epoch is invalid");
|
|
619
|
+
return row.next;
|
|
620
|
+
},
|
|
621
|
+
async request(request) {
|
|
622
|
+
recordRequest(request);
|
|
623
|
+
},
|
|
624
|
+
requestInTransaction(request) {
|
|
625
|
+
recordRequest(request);
|
|
626
|
+
},
|
|
627
|
+
async decide(decision) {
|
|
628
|
+
recordDecision(decision);
|
|
629
|
+
},
|
|
630
|
+
decideInTransaction(decision) {
|
|
631
|
+
recordDecision(decision);
|
|
632
|
+
},
|
|
633
|
+
async restore(runId, toolCallId, journalSeq) {
|
|
634
|
+
ensure();
|
|
635
|
+
const row = first(ports.sql.exec("SELECT approval_id,codec_version,run_id,tool_call_id,tool_name,journal_seq,after_message_seq,approval_epoch,canonical_tool_call,signature,approved,reason,response_at FROM __telnyx_agent_harness_approval_messages WHERE run_id = ? AND tool_call_id = ? AND journal_seq = ?", runId, toolCallId, journalSeq).toArray());
|
|
636
|
+
if (row === undefined)
|
|
637
|
+
throw new Error("approval message linkage is missing");
|
|
638
|
+
return approvalMessage(row);
|
|
639
|
+
},
|
|
640
|
+
async restoreRun(runId) {
|
|
641
|
+
ensure();
|
|
642
|
+
nonblank(runId, "run id");
|
|
643
|
+
const rows = ports.sql.exec("SELECT approval_id,codec_version,run_id,tool_call_id,tool_name,journal_seq,after_message_seq,approval_epoch,canonical_tool_call,signature,approved,reason,response_at FROM __telnyx_agent_harness_approval_messages WHERE run_id = ? AND approved IS NOT NULL ORDER BY after_message_seq,journal_seq,approval_id", runId).toArray();
|
|
644
|
+
return Object.freeze(rows.map((row) => Object.freeze({ afterMessageSeq: row.after_message_seq, journalSeq: row.journal_seq, approvalId: row.approval_id, toolCall: approvalCanonicalToolCall(row), messages: approvalMessage(row) })));
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
function recordRequest(request) {
|
|
648
|
+
ensure();
|
|
649
|
+
nonblank(request.runId, "run id");
|
|
650
|
+
nonblank(request.approvalId, "approval id");
|
|
651
|
+
nonblank(request.toolCallId, "tool call id");
|
|
652
|
+
nonblank(request.toolName, "tool name");
|
|
653
|
+
const approvalEpoch = request.approvalEpoch ?? 0;
|
|
654
|
+
if (!Number.isSafeInteger(request.journalSeq) || request.journalSeq < 0 || !Number.isSafeInteger(request.afterMessageSeq) || request.afterMessageSeq < 0 || !Number.isSafeInteger(approvalEpoch) || approvalEpoch < 0)
|
|
655
|
+
throw new Error("approval journal sequence is invalid");
|
|
656
|
+
if (request.signature !== undefined)
|
|
657
|
+
nonblank(request.signature, "approval signature");
|
|
658
|
+
const canonical = serializedCanonicalToolCall(request.canonicalToolCall);
|
|
659
|
+
const call = canonicalToolCall(JSON.parse(canonical));
|
|
660
|
+
if (call.toolCallId !== request.toolCallId || call.toolName !== request.toolName)
|
|
661
|
+
throw new Error("canonical approval tool call linkage is invalid");
|
|
662
|
+
ports.sql.transactionSync(() => {
|
|
663
|
+
const existing = read(request.approvalId);
|
|
664
|
+
if (existing !== undefined) {
|
|
665
|
+
if (existing.run_id !== request.runId || existing.tool_call_id !== request.toolCallId || existing.tool_name !== request.toolName || existing.journal_seq !== request.journalSeq || existing.after_message_seq !== request.afterMessageSeq || existing.approval_epoch !== approvalEpoch || existing.canonical_tool_call !== canonical || existing.signature !== (request.signature ?? null))
|
|
666
|
+
throw new HarnessApprovalError("conflicting");
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_approval_messages(approval_id,codec_version,run_id,tool_call_id,tool_name,journal_seq,after_message_seq,approval_epoch,canonical_tool_call,signature,approved,reason,response_at) VALUES (?,?,?,?,?,?,?,?, ?,?,NULL,NULL,NULL)", request.approvalId, APPROVAL_MESSAGE_CODEC_VERSION, request.runId, request.toolCallId, request.toolName, request.journalSeq, request.afterMessageSeq, approvalEpoch, canonical, request.signature ?? null);
|
|
670
|
+
// The message journal is independently testable and can record an
|
|
671
|
+
// envelope before a durable run exists. Linkage is required only when
|
|
672
|
+
// the approval/run pair exists and can therefore satisfy both FKs.
|
|
673
|
+
const approvalExists = ports.sql.exec("SELECT 1 AS present FROM __telnyx_agent_harness_approvals WHERE id = ? AND run_id = ?", request.approvalId, request.runId).toArray().length > 0;
|
|
674
|
+
const runExists = ports.sql.exec("SELECT 1 AS present FROM __telnyx_agent_harness_runs WHERE id = ?", request.runId).toArray().length > 0;
|
|
675
|
+
if (approvalExists && runExists) {
|
|
676
|
+
ports.sql.exec("INSERT INTO __telnyx_agent_harness_approval_linkage(approval_id,run_id,approval_epoch,tool_name,ordinal,journal_marker,provider_call_id,private_sequence,canonical_tool_call) VALUES (?,?,?,?,?,?,?,?,?)", request.approvalId, request.runId, approvalEpoch, request.toolName, request.journalSeq, `approval:${request.approvalId}:${request.journalSeq}`, request.toolCallId, request.journalSeq, canonical);
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function recordDecision(decision) {
|
|
681
|
+
ensure();
|
|
682
|
+
nonblank(decision.approvalId, "approval id");
|
|
683
|
+
if (typeof decision.approved !== "boolean")
|
|
684
|
+
throw new Error("approval decision is invalid");
|
|
685
|
+
if (decision.reason !== undefined && Buffer.byteLength(decision.reason, "utf8") > MAX_APPROVAL_REASON_BYTES)
|
|
686
|
+
throw new Error(`approval reason exceeds ${MAX_APPROVAL_REASON_BYTES} UTF-8 bytes`);
|
|
687
|
+
ports.sql.transactionSync(() => {
|
|
688
|
+
const current = read(decision.approvalId);
|
|
689
|
+
if (current === undefined)
|
|
690
|
+
throw new Error("approval message not found");
|
|
691
|
+
if (current.approved !== null)
|
|
692
|
+
throw new HarnessApprovalError("replayed");
|
|
693
|
+
ports.sql.exec("UPDATE __telnyx_agent_harness_approval_messages SET approved = ?, reason = ?, response_at = ? WHERE approval_id = ? AND approved IS NULL", decision.approved ? 1 : 0, decision.reason ?? null, ports.clock.now(), decision.approvalId);
|
|
694
|
+
if (read(decision.approvalId)?.approved === null)
|
|
695
|
+
throw new HarnessApprovalError("replayed");
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
//# sourceMappingURL=approvals.js.map
|