@agentguard-run/spend 0.4.4 → 0.5.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +23 -0
  3. package/dist/index.d.ts +9 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +19 -3
  6. package/dist/index.js.map +1 -1
  7. package/dist/workflow/chain-validator.d.ts +4 -0
  8. package/dist/workflow/chain-validator.d.ts.map +1 -0
  9. package/dist/workflow/chain-validator.js +37 -0
  10. package/dist/workflow/chain-validator.js.map +1 -0
  11. package/dist/workflow/context.d.ts +46 -0
  12. package/dist/workflow/context.d.ts.map +1 -0
  13. package/dist/workflow/context.js +360 -0
  14. package/dist/workflow/context.js.map +1 -0
  15. package/dist/workflow/errors.d.ts +43 -0
  16. package/dist/workflow/errors.d.ts.map +1 -0
  17. package/dist/workflow/errors.js +40 -0
  18. package/dist/workflow/errors.js.map +1 -0
  19. package/dist/workflow/index.d.ts +6 -0
  20. package/dist/workflow/index.d.ts.map +1 -0
  21. package/dist/workflow/index.js +20 -0
  22. package/dist/workflow/index.js.map +1 -0
  23. package/dist/workflow/receipt.d.ts +23 -0
  24. package/dist/workflow/receipt.d.ts.map +1 -0
  25. package/dist/workflow/receipt.js +60 -0
  26. package/dist/workflow/receipt.js.map +1 -0
  27. package/dist/workflow/types.d.ts +74 -0
  28. package/dist/workflow/types.d.ts.map +1 -0
  29. package/dist/workflow/types.js +3 -0
  30. package/dist/workflow/types.js.map +1 -0
  31. package/package.json +10 -3
  32. package/src/workflow/chain-validator.ts +35 -0
  33. package/src/workflow/context.ts +418 -0
  34. package/src/workflow/errors.ts +27 -0
  35. package/src/workflow/index.ts +18 -0
  36. package/src/workflow/receipt.ts +73 -0
  37. package/src/workflow/types.ts +88 -0
@@ -0,0 +1,418 @@
1
+ import { randomUUID } from 'crypto';
2
+
3
+ declare const fetch: (input: string, init?: { method?: string; headers?: Record<string, string>; body?: string }) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
4
+ import { computeChainHash, validateReceiptChain } from './chain-validator';
5
+ import { AgentGuardBudgetCapError, AgentGuardChainCorruptError, AgentGuardDurationCapError, AgentGuardWorkflowStateError } from './errors';
6
+ import { buildReceipt, roundUsd } from './receipt';
7
+ import type { CheckpointReceipt, ReceiptV2, WorkflowConfig, WorkflowState, WorkflowStatus } from './types';
8
+
9
+ interface WorkflowCreateResponse {
10
+ workflow_id: string;
11
+ created_at: string;
12
+ state?: WorkflowState;
13
+ total_spend_usd?: number;
14
+ outcome_count?: number;
15
+ last_receipt_id?: string | null;
16
+ last_checkpoint_id?: string | null;
17
+ last_chain_hash?: string | null;
18
+ receipts?: ReceiptV2[];
19
+ }
20
+
21
+ export class WorkflowContext {
22
+ public readonly workflow_id: string;
23
+ public readonly user_id: string;
24
+ public readonly config: WorkflowConfig;
25
+ public readonly created_at: Date;
26
+
27
+ private _total_spend_usd = 0;
28
+ private _outcome_count = 0;
29
+ private _last_receipt_id: string | null = null;
30
+ private _last_checkpoint_id: string | null = null;
31
+ private _last_chain_hash: string | null = null;
32
+ private _state: WorkflowState = 'active';
33
+ private readonly _receipts: ReceiptV2[] = [];
34
+
35
+ private constructor(args: {
36
+ workflow_id: string;
37
+ user_id: string;
38
+ config: WorkflowConfig;
39
+ created_at: Date;
40
+ state?: WorkflowState;
41
+ total_spend_usd?: number;
42
+ outcome_count?: number;
43
+ last_receipt_id?: string | null;
44
+ last_checkpoint_id?: string | null;
45
+ last_chain_hash?: string | null;
46
+ receipts?: ReceiptV2[];
47
+ }) {
48
+ this.workflow_id = args.workflow_id;
49
+ this.user_id = args.user_id;
50
+ this.config = args.config;
51
+ this.created_at = args.created_at;
52
+ this._state = args.state ?? 'active';
53
+ this._total_spend_usd = args.total_spend_usd ?? 0;
54
+ this._outcome_count = args.outcome_count ?? 0;
55
+ this._last_receipt_id = args.last_receipt_id ?? null;
56
+ this._last_checkpoint_id = args.last_checkpoint_id ?? null;
57
+ this._last_chain_hash = args.last_chain_hash ?? null;
58
+ this._receipts.push(...(args.receipts ?? []));
59
+ }
60
+
61
+ static async create(config: WorkflowConfig): Promise<WorkflowContext> {
62
+ validateConfig(config);
63
+ const userId = String(config.metadata?.user_id || config.user_id || 'local-user');
64
+
65
+ if (config.resume_if_exists) {
66
+ const existing = await tryResume(config, userId);
67
+ if (existing) return existing;
68
+ }
69
+
70
+ const apiBase = resolveApiBase(config);
71
+ if (apiBase) {
72
+ const body = {
73
+ name: config.name,
74
+ budget_cap_usd: config.budget_cap_usd,
75
+ duration_cap_hours: config.duration_cap_hours,
76
+ checkpoint_every_outcomes: config.checkpoint_every_outcomes ?? 25,
77
+ parent_outcomes: config.parent_outcomes,
78
+ metadata: config.metadata,
79
+ };
80
+ const created = await postJson<WorkflowCreateResponse>(config, `${apiBase}/api/workflows/create`, body);
81
+ return new WorkflowContext({
82
+ workflow_id: created.workflow_id,
83
+ user_id: userId,
84
+ config,
85
+ created_at: new Date(created.created_at),
86
+ state: created.state,
87
+ total_spend_usd: created.total_spend_usd,
88
+ outcome_count: created.outcome_count,
89
+ last_receipt_id: created.last_receipt_id,
90
+ last_checkpoint_id: created.last_checkpoint_id,
91
+ last_chain_hash: created.last_chain_hash,
92
+ receipts: created.receipts,
93
+ });
94
+ }
95
+
96
+ return new WorkflowContext({
97
+ workflow_id: `ag_w_${randomUUID().replace(/-/g, '').slice(0, 16)}`,
98
+ user_id: userId,
99
+ config,
100
+ created_at: new Date(),
101
+ });
102
+ }
103
+
104
+ static fromCheckpoint(config: WorkflowConfig, existing: WorkflowCreateResponse, userId = 'local-user'): WorkflowContext {
105
+ return new WorkflowContext({
106
+ workflow_id: existing.workflow_id,
107
+ user_id: userId,
108
+ config,
109
+ created_at: new Date(existing.created_at),
110
+ state: existing.state ?? 'active',
111
+ total_spend_usd: existing.total_spend_usd,
112
+ outcome_count: existing.outcome_count,
113
+ last_receipt_id: existing.last_receipt_id,
114
+ last_checkpoint_id: existing.last_checkpoint_id,
115
+ last_chain_hash: existing.last_chain_hash,
116
+ receipts: existing.receipts,
117
+ });
118
+ }
119
+
120
+ async outcome<T>(name: string, fn: () => Promise<T>): Promise<T> {
121
+ this.assertActive();
122
+ this.assertOutcomeAllowed(name);
123
+ this.assertBudgetRemaining();
124
+ this.assertDurationRemaining();
125
+
126
+ const result = await fn();
127
+ const cost = this.extractOutcomeCost(result);
128
+ const parentReceiptId = this._last_receipt_id;
129
+ const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
130
+ const receipt = buildReceipt({
131
+ workflow_id: this.workflow_id,
132
+ outcome_name: name,
133
+ user_id: this.user_id,
134
+ cost_usd: cost,
135
+ parent_receipt_id: parentReceiptId,
136
+ chain_validation_hash: chainHash,
137
+ workflow_checkpoint_idx: null,
138
+ workflow_total_spend_usd_to_date: this._total_spend_usd + cost,
139
+ reviewer_cascade: extractReviewerCascade(result),
140
+ });
141
+
142
+ this._receipts.push(receipt);
143
+ this._total_spend_usd = roundUsd(this._total_spend_usd + cost);
144
+ this._outcome_count += 1;
145
+ this._last_receipt_id = receipt.receipt_id;
146
+ this._last_chain_hash = await computeChainHash(receipt);
147
+ await this.postReceipt(receipt);
148
+
149
+ if (this._outcome_count % (this.config.checkpoint_every_outcomes ?? 25) === 0) {
150
+ await this.writeCheckpoint();
151
+ }
152
+
153
+ await this.checkPostCallCaps();
154
+ return result;
155
+ }
156
+
157
+ async checkpoint(label?: string): Promise<CheckpointReceipt> {
158
+ return this.writeCheckpoint(label);
159
+ }
160
+
161
+ async cancel(reason: string): Promise<void> {
162
+ this._state = 'cancelled';
163
+ await this.writeFinalReceipt('cancelled', reason);
164
+ }
165
+
166
+ async finalize(): Promise<void> {
167
+ if (this._state === 'active') {
168
+ this._state = 'completed';
169
+ await this.writeFinalReceipt('completed', 'workflow completed');
170
+ }
171
+ }
172
+
173
+ status(): WorkflowStatus {
174
+ const elapsed = Date.now() - this.created_at.getTime();
175
+ const capMs = this.config.duration_cap_hours * 3_600_000;
176
+ return {
177
+ workflow_id: this.workflow_id,
178
+ state: this._state,
179
+ total_spend_usd: this._total_spend_usd,
180
+ budget_cap_usd: this.config.budget_cap_usd,
181
+ budget_remaining_usd: roundUsd(Math.max(0, this.config.budget_cap_usd - this._total_spend_usd)),
182
+ budget_pct_used: this.config.budget_cap_usd === 0 ? 1 : this._total_spend_usd / this.config.budget_cap_usd,
183
+ outcome_count: this._outcome_count,
184
+ last_receipt_id: this._last_receipt_id,
185
+ last_checkpoint_id: this._last_checkpoint_id,
186
+ last_chain_hash: this._last_chain_hash,
187
+ duration_elapsed_ms: elapsed,
188
+ duration_cap_ms: capMs,
189
+ duration_remaining_ms: Math.max(0, capMs - elapsed),
190
+ };
191
+ }
192
+
193
+ receipts(): ReceiptV2[] {
194
+ return [...this._receipts];
195
+ }
196
+
197
+ private async writeCheckpoint(label?: string): Promise<CheckpointReceipt> {
198
+ const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
199
+ const checkpoint = buildReceipt({
200
+ workflow_id: this.workflow_id,
201
+ outcome_name: 'CHECKPOINT',
202
+ user_id: this.user_id,
203
+ cost_usd: 0,
204
+ parent_receipt_id: this._last_receipt_id,
205
+ chain_validation_hash: chainHash,
206
+ workflow_checkpoint_idx: this._outcome_count,
207
+ workflow_total_spend_usd_to_date: this._total_spend_usd,
208
+ is_checkpoint: true,
209
+ checkpoint_label: label ?? null,
210
+ receipt_type: 'checkpoint',
211
+ }) as CheckpointReceipt;
212
+ this._receipts.push(checkpoint);
213
+ this._last_checkpoint_id = checkpoint.receipt_id;
214
+ this._last_receipt_id = checkpoint.receipt_id;
215
+ this._last_chain_hash = await computeChainHash(checkpoint);
216
+ await this.postReceipt(checkpoint);
217
+ return checkpoint;
218
+ }
219
+
220
+ private async writeFinalReceipt(type: 'cancelled' | 'completed' | 'cap_hit', reason: string): Promise<void> {
221
+ const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
222
+ const receipt = buildReceipt({
223
+ workflow_id: this.workflow_id,
224
+ outcome_name: type === 'completed' ? 'WORKFLOW_COMPLETED' : 'WORKFLOW_STOPPED',
225
+ user_id: this.user_id,
226
+ cost_usd: 0,
227
+ parent_receipt_id: this._last_receipt_id,
228
+ chain_validation_hash: chainHash,
229
+ workflow_checkpoint_idx: this._outcome_count,
230
+ workflow_total_spend_usd_to_date: this._total_spend_usd,
231
+ receipt_type: type === 'cancelled' ? 'cancel' : type,
232
+ cancelled_reason: reason,
233
+ });
234
+ this._receipts.push(receipt);
235
+ this._last_receipt_id = receipt.receipt_id;
236
+ this._last_chain_hash = await computeChainHash(receipt);
237
+ await this.postReceipt(receipt);
238
+ }
239
+
240
+ private assertActive(): void {
241
+ if (this._state === 'budget_capped') {
242
+ throw new AgentGuardBudgetCapError(
243
+ `Workflow ${this.workflow_id} exceeded budget cap of $${this.config.budget_cap_usd}. Total spend to date: $${this._total_spend_usd}.`,
244
+ { workflow_id: this.workflow_id, total_spend_usd: this._total_spend_usd },
245
+ );
246
+ }
247
+ if (this._state === 'duration_capped') {
248
+ throw new AgentGuardDurationCapError(`Workflow ${this.workflow_id} exceeded duration cap.`, {
249
+ workflow_id: this.workflow_id,
250
+ elapsed_ms: Date.now() - this.created_at.getTime(),
251
+ });
252
+ }
253
+ if (this._state !== 'active') {
254
+ throw new AgentGuardWorkflowStateError(`Workflow ${this.workflow_id} is ${this._state}.`, {
255
+ workflow_id: this.workflow_id,
256
+ state: this._state,
257
+ });
258
+ }
259
+ }
260
+
261
+ private assertOutcomeAllowed(name: string): void {
262
+ if (this.config.parent_outcomes && !this.config.parent_outcomes.includes(name)) {
263
+ throw new AgentGuardWorkflowStateError(`Outcome ${name} is not allowed in workflow ${this.workflow_id}.`, {
264
+ workflow_id: this.workflow_id,
265
+ state: this._state,
266
+ });
267
+ }
268
+ }
269
+
270
+ private assertBudgetRemaining(): void {
271
+ if (this._total_spend_usd >= this.config.budget_cap_usd) {
272
+ this._state = 'budget_capped';
273
+ throw new AgentGuardBudgetCapError(
274
+ `Workflow ${this.workflow_id} exceeded budget cap of $${this.config.budget_cap_usd}. Total spend to date: $${this._total_spend_usd}.`,
275
+ { workflow_id: this.workflow_id, total_spend_usd: this._total_spend_usd },
276
+ );
277
+ }
278
+ }
279
+
280
+ private assertDurationRemaining(): void {
281
+ const elapsed = Date.now() - this.created_at.getTime();
282
+ const cap = this.config.duration_cap_hours * 3_600_000;
283
+ if (elapsed >= cap) {
284
+ this._state = 'duration_capped';
285
+ throw new AgentGuardDurationCapError(`Workflow ${this.workflow_id} exceeded duration cap.`, {
286
+ workflow_id: this.workflow_id,
287
+ elapsed_ms: elapsed,
288
+ });
289
+ }
290
+ }
291
+
292
+ private async checkPostCallCaps(): Promise<void> {
293
+ if (this._total_spend_usd >= this.config.budget_cap_usd) {
294
+ this._state = 'budget_capped';
295
+ await this.writeFinalReceipt('cap_hit', 'budget cap reached');
296
+ }
297
+ const elapsed = Date.now() - this.created_at.getTime();
298
+ if (elapsed >= this.config.duration_cap_hours * 3_600_000) {
299
+ this._state = 'duration_capped';
300
+ await this.writeFinalReceipt('cap_hit', 'duration cap reached');
301
+ }
302
+ }
303
+
304
+ private extractOutcomeCost(result: unknown): number {
305
+ if (isRecord(result)) {
306
+ if (typeof result.cost_usd === 'number') return result.cost_usd;
307
+ const receipt = result.receipt;
308
+ if (isRecord(receipt) && typeof receipt.cost_usd === 'number') return receipt.cost_usd;
309
+ }
310
+ const configured = this.config.metadata?.default_outcome_cost_usd;
311
+ return typeof configured === 'number' ? configured : 0;
312
+ }
313
+
314
+ private async postReceipt(receipt: ReceiptV2): Promise<void> {
315
+ const apiBase = resolveApiBase(this.config);
316
+ if (!apiBase) return;
317
+ await postJson(this.config, `${apiBase}/api/workflows/${this.workflow_id}/receipt`, { receipt });
318
+ }
319
+ }
320
+
321
+ export async function workflow<T>(config: WorkflowConfig, fn: (ctx: WorkflowContext) => Promise<T>): Promise<T> {
322
+ const ctx = await WorkflowContext.create(config);
323
+ try {
324
+ return await fn(ctx);
325
+ } finally {
326
+ await ctx.finalize();
327
+ }
328
+ }
329
+
330
+ async function tryResume(config: WorkflowConfig, userId: string): Promise<WorkflowContext | null> {
331
+ const apiBase = resolveApiBase(config);
332
+ if (!apiBase) return null;
333
+ const headers = authHeaders(config);
334
+ const existing = await getJson<WorkflowCreateResponse | null>(
335
+ config,
336
+ `${apiBase}/api/workflows/lookup?name=${encodeURIComponent(config.name)}`,
337
+ headers,
338
+ );
339
+ if (!existing || ['cancelled', 'completed', 'budget_capped', 'duration_capped'].includes(existing.state ?? '')) {
340
+ return null;
341
+ }
342
+ const chain = await getJson<{ receipts: ReceiptV2[] }>(
343
+ config,
344
+ `${apiBase}/api/workflows/${existing.workflow_id}/chain`,
345
+ headers,
346
+ );
347
+ const validation = await validateReceiptChain(chain.receipts);
348
+ if (!validation.ok) {
349
+ throw new AgentGuardChainCorruptError(`Cannot resume workflow ${existing.workflow_id}: chain validation failed.`, {
350
+ workflow_id: existing.workflow_id,
351
+ broken_at: validation.broken_at,
352
+ reason: validation.reason,
353
+ });
354
+ }
355
+ return WorkflowContext.fromCheckpoint(config, { ...existing, receipts: chain.receipts }, userId);
356
+ }
357
+
358
+ function resolveApiBase(config: WorkflowConfig): string | null {
359
+ const value = config.api_base_url || process.env.AGENTGUARD_WORKFLOW_API_BASE || '';
360
+ return value ? value.replace(/\/$/, '') : null;
361
+ }
362
+
363
+ function authHeaders(config: WorkflowConfig): Record<string, string> {
364
+ const license = config.license_key || process.env.AGENTGUARD_LICENSE_KEY || '';
365
+ return {
366
+ 'content-type': 'application/json',
367
+ ...(license ? { authorization: `Bearer ${license}` } : {}),
368
+ };
369
+ }
370
+
371
+ async function postJson<T>(config: WorkflowConfig, url: string, body: unknown): Promise<T> {
372
+ const headers = authHeaders(config);
373
+ if (config.post_json) return config.post_json(url, body, headers) as Promise<T>;
374
+ const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
375
+ if (!response.ok) throw new Error(`Workflow API request failed: ${response.status}`);
376
+ return response.json() as Promise<T>;
377
+ }
378
+
379
+ async function getJson<T>(config: WorkflowConfig, url: string, headers: Record<string, string>): Promise<T> {
380
+ if (config.get_json) return config.get_json(url, headers) as Promise<T>;
381
+ const response = await fetch(url, { headers });
382
+ if (response.status === 404) return null as T;
383
+ if (!response.ok) throw new Error(`Workflow API request failed: ${response.status}`);
384
+ return response.json() as Promise<T>;
385
+ }
386
+
387
+ function extractReviewerCascade(result: unknown): ReceiptV2['reviewer_cascade'] {
388
+ if (!isRecord(result)) return null;
389
+ const value = result.reviewer_cascade;
390
+ if (!isRecord(value)) return null;
391
+ return {
392
+ triggered: value.triggered === true,
393
+ drafter_model: typeof value.drafter_model === 'string' ? value.drafter_model : null,
394
+ reviewer_model: typeof value.reviewer_model === 'string' ? value.reviewer_model : null,
395
+ drafter_output_hash: typeof value.drafter_output_hash === 'string' ? value.drafter_output_hash : null,
396
+ reviewer_verdict:
397
+ value.reviewer_verdict === 'approve' || value.reviewer_verdict === 'block' || value.reviewer_verdict === 'revise'
398
+ ? value.reviewer_verdict
399
+ : null,
400
+ reviewer_reasoning_hash: typeof value.reviewer_reasoning_hash === 'string' ? value.reviewer_reasoning_hash : null,
401
+ review_started_at: typeof value.review_started_at === 'string' ? value.review_started_at : null,
402
+ review_completed_at: typeof value.review_completed_at === 'string' ? value.review_completed_at : null,
403
+ };
404
+ }
405
+
406
+ function validateConfig(config: WorkflowConfig): void {
407
+ if (!config.name || !config.name.trim()) throw new Error('Workflow name is required');
408
+ if (!Number.isFinite(config.budget_cap_usd) || config.budget_cap_usd <= 0) {
409
+ throw new Error('Workflow budget_cap_usd must be greater than zero');
410
+ }
411
+ if (!Number.isFinite(config.duration_cap_hours) || config.duration_cap_hours <= 0) {
412
+ throw new Error('Workflow duration_cap_hours must be greater than zero');
413
+ }
414
+ }
415
+
416
+ function isRecord(value: unknown): value is Record<string, unknown> {
417
+ return typeof value === 'object' && value !== null;
418
+ }
@@ -0,0 +1,27 @@
1
+ export class AgentGuardBudgetCapError extends Error {
2
+ constructor(message: string, public context: { workflow_id: string; total_spend_usd: number }) {
3
+ super(message);
4
+ this.name = 'AgentGuardBudgetCapError';
5
+ }
6
+ }
7
+
8
+ export class AgentGuardDurationCapError extends Error {
9
+ constructor(message: string, public context: { workflow_id: string; elapsed_ms: number }) {
10
+ super(message);
11
+ this.name = 'AgentGuardDurationCapError';
12
+ }
13
+ }
14
+
15
+ export class AgentGuardChainCorruptError extends Error {
16
+ constructor(message: string, public context: { workflow_id?: string; broken_at?: number; reason?: string } = {}) {
17
+ super(message);
18
+ this.name = 'AgentGuardChainCorruptError';
19
+ }
20
+ }
21
+
22
+ export class AgentGuardWorkflowStateError extends Error {
23
+ constructor(message: string, public context: { workflow_id: string; state: string }) {
24
+ super(message);
25
+ this.name = 'AgentGuardWorkflowStateError';
26
+ }
27
+ }
@@ -0,0 +1,18 @@
1
+ export { WorkflowContext, workflow } from './context';
2
+ export { validateReceiptChain, computeChainHash } from './chain-validator';
3
+ export { buildReceipt, verifyReceipt, canonicalJSONStringify, sha256 } from './receipt';
4
+ export {
5
+ AgentGuardBudgetCapError,
6
+ AgentGuardDurationCapError,
7
+ AgentGuardChainCorruptError,
8
+ AgentGuardWorkflowStateError,
9
+ } from './errors';
10
+ export type {
11
+ ChainValidationResult,
12
+ CheckpointReceipt,
13
+ ReceiptV2,
14
+ ReviewerCascadeEvidence,
15
+ WorkflowConfig,
16
+ WorkflowState,
17
+ WorkflowStatus,
18
+ } from './types';
@@ -0,0 +1,73 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import { canonicalJson, sha256Hex } from '../decision-log';
3
+ import type { ReceiptV2, ReviewerCascadeEvidence } from './types';
4
+
5
+ export function canonicalJSONStringify(value: unknown): string {
6
+ return canonicalJson(value);
7
+ }
8
+
9
+ export function sha256(input: string): string {
10
+ return sha256Hex(input);
11
+ }
12
+
13
+ export function workflowReceiptId(prefix = 'ag_r'): string {
14
+ return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
15
+ }
16
+
17
+ export function signReceiptPayload(payload: Omit<ReceiptV2, 'signature'>): string {
18
+ return sha256Hex(canonicalJson(payload));
19
+ }
20
+
21
+ export function buildReceipt(args: {
22
+ workflow_id: string | null;
23
+ outcome_name: string;
24
+ user_id: string;
25
+ cost_usd: number;
26
+ parent_receipt_id: string | null;
27
+ chain_validation_hash: string | null;
28
+ workflow_checkpoint_idx: number | null;
29
+ workflow_total_spend_usd_to_date: number | null;
30
+ is_checkpoint?: boolean;
31
+ checkpoint_label?: string | null;
32
+ reviewer_cascade?: ReviewerCascadeEvidence | null;
33
+ receipt_type?: ReceiptV2['receipt_type'];
34
+ cancelled_reason?: string | null;
35
+ }): ReceiptV2 {
36
+ if (!Number.isFinite(args.cost_usd) || args.cost_usd < 0) {
37
+ throw new Error('Workflow receipt cost must be a finite non-negative number');
38
+ }
39
+ const publicKeyFingerprint = createHash('sha256')
40
+ .update(args.workflow_id || args.user_id || 'agentguard-workflow')
41
+ .digest('hex')
42
+ .slice(0, 16);
43
+ const unsigned: Omit<ReceiptV2, 'signature'> = {
44
+ receipt_id: workflowReceiptId(args.is_checkpoint ? 'ag_cp' : 'ag_r'),
45
+ schema_version: 2,
46
+ outcome_name: args.outcome_name,
47
+ user_id: args.user_id,
48
+ cost_usd: roundUsd(args.cost_usd),
49
+ signed_at: new Date().toISOString(),
50
+ public_key_fingerprint: publicKeyFingerprint,
51
+ workflow_id: args.workflow_id,
52
+ parent_receipt_id: args.parent_receipt_id,
53
+ chain_validation_hash: args.chain_validation_hash,
54
+ workflow_checkpoint_idx: args.workflow_checkpoint_idx,
55
+ workflow_total_spend_usd_to_date:
56
+ args.workflow_total_spend_usd_to_date === null ? null : roundUsd(args.workflow_total_spend_usd_to_date),
57
+ is_checkpoint: args.is_checkpoint === true,
58
+ checkpoint_label: args.checkpoint_label ?? null,
59
+ reviewer_cascade: args.reviewer_cascade ?? null,
60
+ receipt_type: args.receipt_type ?? (args.is_checkpoint ? 'checkpoint' : 'outcome'),
61
+ cancelled_reason: args.cancelled_reason ?? null,
62
+ };
63
+ return { ...unsigned, signature: signReceiptPayload(unsigned) };
64
+ }
65
+
66
+ export function verifyReceipt(receipt: ReceiptV2): boolean {
67
+ const { signature: _signature, ...unsigned } = receipt;
68
+ return signReceiptPayload(unsigned) === receipt.signature;
69
+ }
70
+
71
+ export function roundUsd(value: number): number {
72
+ return Math.round(value * 1_000_000) / 1_000_000;
73
+ }
@@ -0,0 +1,88 @@
1
+ export type WorkflowState =
2
+ | 'active'
3
+ | 'paused'
4
+ | 'cancelled'
5
+ | 'completed'
6
+ | 'budget_capped'
7
+ | 'duration_capped';
8
+
9
+ export type ReviewerVerdict = 'approve' | 'block' | 'revise' | null;
10
+
11
+ export interface WorkflowConfig {
12
+ name: string;
13
+ budget_cap_usd: number;
14
+ duration_cap_hours: number;
15
+ checkpoint_every_outcomes?: number;
16
+ parent_outcomes?: string[];
17
+ resume_if_exists?: boolean;
18
+ metadata?: Record<string, string | number | boolean>;
19
+ user_id?: string;
20
+ api_base_url?: string;
21
+ license_key?: string;
22
+ post_json?: (url: string, body: unknown, headers: Record<string, string>) => Promise<unknown>;
23
+ get_json?: (url: string, headers: Record<string, string>) => Promise<unknown>;
24
+ }
25
+
26
+ export interface ReviewerCascadeEvidence {
27
+ triggered: boolean;
28
+ drafter_model: string | null;
29
+ reviewer_model: string | null;
30
+ drafter_output_hash: string | null;
31
+ reviewer_verdict: ReviewerVerdict;
32
+ reviewer_reasoning_hash: string | null;
33
+ review_started_at: string | null;
34
+ review_completed_at: string | null;
35
+ }
36
+
37
+ export interface ReceiptV2 {
38
+ receipt_id: string;
39
+ schema_version: 2;
40
+ outcome_name: string;
41
+ user_id: string;
42
+ cost_usd: number;
43
+ signed_at: string;
44
+ signature: string;
45
+ public_key_fingerprint: string;
46
+ workflow_id: string | null;
47
+ parent_receipt_id: string | null;
48
+ chain_validation_hash: string | null;
49
+ workflow_checkpoint_idx: number | null;
50
+ workflow_total_spend_usd_to_date: number | null;
51
+ is_checkpoint: boolean;
52
+ checkpoint_label: string | null;
53
+ reviewer_cascade: ReviewerCascadeEvidence | null;
54
+ receipt_type?: 'outcome' | 'checkpoint' | 'cancel' | 'cap_hit' | 'completed';
55
+ cancelled_reason?: string | null;
56
+ }
57
+
58
+ export interface CheckpointReceipt extends ReceiptV2 {
59
+ is_checkpoint: true;
60
+ }
61
+
62
+ export interface WorkflowStatus {
63
+ workflow_id: string;
64
+ state: WorkflowState;
65
+ total_spend_usd: number;
66
+ budget_cap_usd: number;
67
+ budget_remaining_usd: number;
68
+ budget_pct_used: number;
69
+ outcome_count: number;
70
+ last_receipt_id: string | null;
71
+ last_checkpoint_id: string | null;
72
+ last_chain_hash: string | null;
73
+ duration_elapsed_ms: number;
74
+ duration_cap_ms: number;
75
+ duration_remaining_ms: number;
76
+ }
77
+
78
+ export interface ValidationResult {
79
+ ok: true;
80
+ }
81
+
82
+ export interface ValidationFailure {
83
+ ok: false;
84
+ broken_at: number;
85
+ reason: 'signature_invalid' | 'chain_hash_mismatch' | 'parent_reference_mismatch';
86
+ }
87
+
88
+ export type ChainValidationResult = ValidationResult | ValidationFailure;