@relayflows/sdk 2.0.45 → 2.0.46
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/dist/authored-flow-error.d.ts +1 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-helper-effect.d.ts.map +1 -1
- package/dist/authored-helper-effect.js +9 -0
- package/dist/authored-helper-effect.js.map +1 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +17 -0
- package/dist/authored-root.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +7 -0
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +31 -2
- package/dist/cli/run.js.map +1 -1
- package/dist/cloud-mirror-step.d.ts.map +1 -1
- package/dist/cloud-mirror-step.js +6 -1
- package/dist/cloud-mirror-step.js.map +1 -1
- package/dist/compile.d.ts +1 -0
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +2 -1
- package/dist/compile.js.map +1 -1
- package/dist/failure-kinds.d.ts +1 -1
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +1 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/flow-tool-adapters.d.ts +56 -0
- package/dist/flow-tool-adapters.d.ts.map +1 -0
- package/dist/flow-tool-adapters.js +42 -0
- package/dist/flow-tool-adapters.js.map +1 -0
- package/dist/flow-tool-client.d.ts +38 -0
- package/dist/flow-tool-client.d.ts.map +1 -0
- package/dist/flow-tool-client.js +105 -0
- package/dist/flow-tool-client.js.map +1 -0
- package/dist/flow-tool-contract.d.ts +103 -0
- package/dist/flow-tool-contract.d.ts.map +1 -0
- package/dist/flow-tool-contract.js +75 -0
- package/dist/flow-tool-contract.js.map +1 -0
- package/dist/flow-tool-definitions.d.ts +19 -0
- package/dist/flow-tool-definitions.d.ts.map +1 -0
- package/dist/flow-tool-definitions.js +12 -0
- package/dist/flow-tool-definitions.js.map +1 -0
- package/dist/flow-tool-http.d.ts +14 -0
- package/dist/flow-tool-http.d.ts.map +1 -0
- package/dist/flow-tool-http.js +175 -0
- package/dist/flow-tool-http.js.map +1 -0
- package/dist/flow-tool-kernel.d.ts +27 -0
- package/dist/flow-tool-kernel.d.ts.map +1 -0
- package/dist/flow-tool-kernel.js +310 -0
- package/dist/flow-tool-kernel.js.map +1 -0
- package/dist/flow-tool-manifest.d.ts +30 -0
- package/dist/flow-tool-manifest.d.ts.map +1 -0
- package/dist/flow-tool-manifest.js +79 -0
- package/dist/flow-tool-manifest.js.map +1 -0
- package/dist/flow-tool-schema.d.ts +15 -0
- package/dist/flow-tool-schema.d.ts.map +1 -0
- package/dist/flow-tool-schema.js +70 -0
- package/dist/flow-tool-schema.js.map +1 -0
- package/dist/flow-tool-wire.d.ts +26 -0
- package/dist/flow-tool-wire.d.ts.map +1 -0
- package/dist/flow-tool-wire.js +195 -0
- package/dist/flow-tool-wire.js.map +1 -0
- package/dist/helper-park.d.ts +14 -0
- package/dist/helper-park.d.ts.map +1 -0
- package/dist/helper-park.js +40 -0
- package/dist/helper-park.js.map +1 -0
- package/dist/helper-receipt.d.ts +11 -0
- package/dist/helper-receipt.d.ts.map +1 -0
- package/dist/helper-receipt.js +100 -0
- package/dist/helper-receipt.js.map +1 -0
- package/dist/helper-writeback.d.ts.map +1 -1
- package/dist/helper-writeback.js +7 -2
- package/dist/helper-writeback.js.map +1 -1
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/yaml-helper-effect.d.ts.map +1 -1
- package/dist/yaml-helper-effect.js +32 -23
- package/dist/yaml-helper-effect.js.map +1 -1
- package/package.json +2 -2
- package/src/authored-flow-error.ts +1 -0
- package/src/authored-helper-effect.ts +9 -0
- package/src/authored-root.ts +17 -0
- package/src/cli/direct-run.ts +7 -0
- package/src/cli/run.ts +30 -2
- package/src/cloud-mirror-step.ts +6 -1
- package/src/compile.ts +2 -1
- package/src/failure-kinds.ts +1 -0
- package/src/flow-tool-adapters.ts +43 -0
- package/src/flow-tool-client.ts +115 -0
- package/src/flow-tool-contract.ts +153 -0
- package/src/flow-tool-definitions.ts +27 -0
- package/src/flow-tool-http.ts +134 -0
- package/src/flow-tool-kernel.ts +319 -0
- package/src/flow-tool-manifest.ts +104 -0
- package/src/flow-tool-schema.ts +66 -0
- package/src/flow-tool-wire.ts +152 -0
- package/src/helper-park.ts +45 -0
- package/src/helper-receipt.ts +90 -0
- package/src/helper-writeback.ts +8 -3
- package/src/index.ts +21 -0
- package/src/yaml-helper-effect.ts +12 -5
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { canonicalize } from './canonical.js';
|
|
4
|
+
import { sha256, verifyBundle } from './bundle.js';
|
|
5
|
+
import { FlowToolError, type FlowToolCatalogEntryV1, type FlowToolEventV1,
|
|
6
|
+
type FlowToolRunState, type FlowToolRunV1, type FlowToolTerminalReason } from './flow-tool-contract.js';
|
|
7
|
+
import type { FlowToolRequest, FlowToolTransport } from './flow-tool-client.js';
|
|
8
|
+
import { flowToolOperationKey } from './flow-tool-client.js';
|
|
9
|
+
import { JournalClient, JournalProtocolError } from './journal-client.js';
|
|
10
|
+
import { parseFlowToolEntry, parseFlowToolInvocation, flowToolRunLinks, toolId } from './flow-tool-wire.js';
|
|
11
|
+
import { validateFlowToolResult } from './flow-tool-manifest.js';
|
|
12
|
+
import type { FlowToolJson } from './flow-tool-schema.js';
|
|
13
|
+
import type { KernelDeterministicStep, KernelRunSpec } from './spec.js';
|
|
14
|
+
|
|
15
|
+
/** The only executable accepted by the v1 embedded runtime. It receives JSON as argv data. */
|
|
16
|
+
export const FLOW_TOOL_INPUT_PLACEHOLDER = '__RELAYFLOWS_FLOW_TOOL_INPUT_V1__';
|
|
17
|
+
const RESULT_STEP = 'flow-tool-result';
|
|
18
|
+
const MAX_RESULT_BYTES = 60 * 1024; // below relayflowd's 64 KiB stdout tail
|
|
19
|
+
|
|
20
|
+
export interface KernelFlowToolDeployment {
|
|
21
|
+
readonly entry: FlowToolCatalogEntryV1;
|
|
22
|
+
/** A bundle whose content digest is entry.manifest.flow.digest. */
|
|
23
|
+
readonly bundlePath: string;
|
|
24
|
+
/** Operator-owned outcome label, never selected by invocation input. */
|
|
25
|
+
readonly businessVerdict: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface KernelFlowToolControlPlaneOptions {
|
|
29
|
+
readonly journal: JournalClient;
|
|
30
|
+
/** Authenticated server-side identity. This value is hashed before journaling. */
|
|
31
|
+
readonly principal: string;
|
|
32
|
+
readonly deployments: readonly KernelFlowToolDeployment[];
|
|
33
|
+
/** Server-side grants for this principal. Omission grants nothing. */
|
|
34
|
+
readonly authorizedDeploymentIds?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface LoadedDeployment {
|
|
38
|
+
entry: FlowToolCatalogEntryV1;
|
|
39
|
+
template: KernelRunSpec;
|
|
40
|
+
businessVerdict: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// relayflowd's Rust boundary accepts an untagged shell-string | argv command.
|
|
44
|
+
// The public authoring compiler deliberately remains shell-string-only; this
|
|
45
|
+
// closed runtime constructs the narrower argv variant internally.
|
|
46
|
+
type KernelArgvStep = Omit<KernelDeterministicStep, 'command'> & { command: string[] };
|
|
47
|
+
|
|
48
|
+
interface RunBinding {
|
|
49
|
+
kind: 'relayflows.flow-tool-run.v1';
|
|
50
|
+
deployment_id: string;
|
|
51
|
+
manifest_digest: string;
|
|
52
|
+
flow_digest: string;
|
|
53
|
+
input_digest: string;
|
|
54
|
+
principal_digest: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface JournalEntry {
|
|
58
|
+
seq: number;
|
|
59
|
+
entry_type: string;
|
|
60
|
+
step_id?: string | null;
|
|
61
|
+
payload: Record<string, unknown>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function runtimeFailure(code: 'unsupported' | 'invalid_contract' = 'unsupported'): never {
|
|
69
|
+
throw new FlowToolError(code);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function exactKeys(value: Record<string, unknown>, allowed: readonly string[]): boolean {
|
|
73
|
+
return Object.keys(value).every(key => allowed.includes(key));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function preflightTemplate(value: unknown, entry: FlowToolCatalogEntryV1): KernelRunSpec {
|
|
77
|
+
if (!record(value) || !exactKeys(value, ['version', 'name', 'description', 'steps'])
|
|
78
|
+
|| typeof value['version'] !== 'string' || value['name'] !== entry.manifest.flow.name
|
|
79
|
+
|| !Array.isArray(value['steps']) || value['steps'].length !== 1) runtimeFailure();
|
|
80
|
+
const step = value['steps'][0];
|
|
81
|
+
if (!record(step) || !exactKeys(step, [
|
|
82
|
+
'id', 'type', 'command', 'depends_on', 'max_iterations', 'retry', 'verification', 'timeout_ms',
|
|
83
|
+
]) || step['id'] !== RESULT_STEP || step['type'] !== 'deterministic'
|
|
84
|
+
|| !Array.isArray(step['command']) || step['command'].length !== 3
|
|
85
|
+
|| !['/usr/bin/printf', '/bin/printf'].includes(step['command'][0] as string)
|
|
86
|
+
|| step['command'][1] !== '%s' || step['command'][2] !== FLOW_TOOL_INPUT_PLACEHOLDER
|
|
87
|
+
|| !Array.isArray(step['depends_on']) || step['depends_on'].length !== 0
|
|
88
|
+
|| step['max_iterations'] !== 1 || !record(step['retry']) || !record(step['verification'])
|
|
89
|
+
|| Object.keys(step['verification']).length !== 0
|
|
90
|
+
|| !Number.isSafeInteger(step['timeout_ms']) || (step['timeout_ms'] as number) < 1
|
|
91
|
+
|| (step['timeout_ms'] as number) > entry.budget.max_wallclock_ms) runtimeFailure();
|
|
92
|
+
const retry = step['retry'];
|
|
93
|
+
if (!exactKeys(retry, ['initial_backoff_ms', 'max_backoff_ms', 'multiplier', 'jitter_percent'])
|
|
94
|
+
|| retry['initial_backoff_ms'] !== 0 || retry['max_backoff_ms'] !== 0
|
|
95
|
+
|| retry['multiplier'] !== 1 || retry['jitter_percent'] !== 0) runtimeFailure();
|
|
96
|
+
return value as unknown as KernelRunSpec;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function loadDeployment(deployment: KernelFlowToolDeployment): Promise<LoadedDeployment> {
|
|
100
|
+
let entry: FlowToolCatalogEntryV1;
|
|
101
|
+
try { entry = parseFlowToolEntry(deployment.entry); } catch { runtimeFailure('invalid_contract'); }
|
|
102
|
+
if (entry.effects.length !== 0 || entry.requires_human.length !== 0
|
|
103
|
+
|| !entry.business_verdicts.includes(deployment.businessVerdict)) runtimeFailure();
|
|
104
|
+
// This runtime prints the invocation input and nothing else. A result schema
|
|
105
|
+
// that is not that same document would journal success and then have to
|
|
106
|
+
// invent a different public reason. Refuse the deployment instead.
|
|
107
|
+
if (canonicalize(entry.manifest.inputSchema) !== canonicalize(entry.manifest.resultSchema)) {
|
|
108
|
+
runtimeFailure('unsupported');
|
|
109
|
+
}
|
|
110
|
+
const expected = entry.manifest.flow.digest.slice('sha256:'.length);
|
|
111
|
+
try {
|
|
112
|
+
const before = await readFile(join(deployment.bundlePath, 'spec.canonical.json'));
|
|
113
|
+
const preflightBefore = await readFile(join(deployment.bundlePath, 'preflight.json'));
|
|
114
|
+
await verifyBundle(deployment.bundlePath, expected);
|
|
115
|
+
const after = await readFile(join(deployment.bundlePath, 'spec.canonical.json'));
|
|
116
|
+
const preflightAfter = await readFile(join(deployment.bundlePath, 'preflight.json'));
|
|
117
|
+
if (!before.equals(after) || !preflightBefore.equals(preflightAfter)) runtimeFailure();
|
|
118
|
+
const report: unknown = JSON.parse(preflightAfter.toString('utf8'));
|
|
119
|
+
if (!record(report) || report['ok'] !== true) runtimeFailure();
|
|
120
|
+
return { entry, template: preflightTemplate(JSON.parse(after.toString('utf8')), entry),
|
|
121
|
+
businessVerdict: deployment.businessVerdict };
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof FlowToolError) throw error;
|
|
124
|
+
runtimeFailure();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function bindingDescription(binding: RunBinding): string { return canonicalize(binding); }
|
|
129
|
+
|
|
130
|
+
function parseBinding(entries: readonly JournalEntry[]): RunBinding {
|
|
131
|
+
const spawned = entries.find(entry => entry.entry_type === 'run.spawned');
|
|
132
|
+
const spec = spawned?.payload['spec'];
|
|
133
|
+
const description = record(spec) ? spec['description'] : undefined;
|
|
134
|
+
try {
|
|
135
|
+
const value: unknown = JSON.parse(typeof description === 'string' ? description : 'null');
|
|
136
|
+
if (!record(value) || value['kind'] !== 'relayflows.flow-tool-run.v1'
|
|
137
|
+
|| typeof value['deployment_id'] !== 'string' || typeof value['manifest_digest'] !== 'string'
|
|
138
|
+
|| typeof value['flow_digest'] !== 'string' || typeof value['input_digest'] !== 'string'
|
|
139
|
+
|| typeof value['principal_digest'] !== 'string'
|
|
140
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(value['manifest_digest'])
|
|
141
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(value['flow_digest'])
|
|
142
|
+
|| !/^sha256:[a-f0-9]{64}$/.test(value['input_digest'])
|
|
143
|
+
|| !/^[a-f0-9]{64}$/.test(value['principal_digest'])) runtimeFailure('invalid_contract');
|
|
144
|
+
return value as unknown as RunBinding;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (error instanceof FlowToolError) throw error;
|
|
147
|
+
runtimeFailure('invalid_contract');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function journalEntries(value: unknown[]): JournalEntry[] {
|
|
152
|
+
if (!value.every(item => record(item) && Number.isSafeInteger(item['seq'])
|
|
153
|
+
&& typeof item['entry_type'] === 'string' && record(item['payload']))) runtimeFailure('invalid_contract');
|
|
154
|
+
return value as unknown as JournalEntry[];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function terminalReason(reason: unknown): FlowToolTerminalReason {
|
|
158
|
+
if (reason === 'success') return 'success';
|
|
159
|
+
if (reason === 'canceled') return 'canceled';
|
|
160
|
+
if (reason === 'budget_exceeded') return 'budget_exceeded';
|
|
161
|
+
return 'execution_failed';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function runState(status: string, reason: FlowToolTerminalReason | null): FlowToolRunState {
|
|
165
|
+
if (reason === 'success') return 'completed';
|
|
166
|
+
if (reason === 'canceled') return 'cancelled';
|
|
167
|
+
if (reason !== null) return 'failed';
|
|
168
|
+
if (status === 'parked') return 'parked';
|
|
169
|
+
return 'running';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function mapProtocolError(error: unknown, admission = false): never {
|
|
173
|
+
if (error instanceof JournalProtocolError) {
|
|
174
|
+
if (error.code === 'run_admission_conflict') throw new FlowToolError('idempotency_conflict');
|
|
175
|
+
if (error.code === 'run_not_found') throw new FlowToolError('not_authorized');
|
|
176
|
+
throw new FlowToolError('unavailable');
|
|
177
|
+
}
|
|
178
|
+
throw new FlowToolError(admission ? 'transport_error' : 'unavailable');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Build an authenticated embedded control plane over relayflowd's durable journal.
|
|
183
|
+
* This intentionally supports only the effect-free JSON echo/conformance program;
|
|
184
|
+
* arbitrary declarative, authored, LLM, and agent flows remain unsupported.
|
|
185
|
+
*/
|
|
186
|
+
export async function createKernelFlowToolControlPlane(
|
|
187
|
+
options: KernelFlowToolControlPlaneOptions,
|
|
188
|
+
): Promise<FlowToolTransport> {
|
|
189
|
+
if (typeof options.principal !== 'string' || options.principal.length < 1 || options.principal.length > 1024) {
|
|
190
|
+
runtimeFailure('invalid_contract');
|
|
191
|
+
}
|
|
192
|
+
const principalDigest = sha256(options.principal);
|
|
193
|
+
const grants = new Set(options.authorizedDeploymentIds ?? []);
|
|
194
|
+
const deployments = new Map<string, LoadedDeployment>();
|
|
195
|
+
const names = new Map<string, LoadedDeployment>();
|
|
196
|
+
for (const candidate of options.deployments) {
|
|
197
|
+
const loaded = await loadDeployment(candidate);
|
|
198
|
+
if (deployments.has(loaded.entry.deployment_id) || names.has(loaded.entry.manifest.name)) runtimeFailure('invalid_contract');
|
|
199
|
+
deployments.set(loaded.entry.deployment_id, loaded);
|
|
200
|
+
names.set(loaded.entry.manifest.name, loaded);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const authorized = (deployment: LoadedDeployment): boolean => grants.has(deployment.entry.deployment_id);
|
|
204
|
+
|
|
205
|
+
async function readAuthorizedRun(runId: string) {
|
|
206
|
+
toolId(runId);
|
|
207
|
+
let entries: JournalEntry[];
|
|
208
|
+
try { entries = journalEntries((await options.journal.journalRead(runId, 1, 1000)).entries); }
|
|
209
|
+
catch (error) { mapProtocolError(error); }
|
|
210
|
+
const binding = parseBinding(entries!);
|
|
211
|
+
const deployment = deployments.get(binding.deployment_id);
|
|
212
|
+
if (deployment === undefined || !authorized(deployment) || binding.principal_digest !== principalDigest
|
|
213
|
+
|| binding.manifest_digest !== deployment.entry.manifest.digest
|
|
214
|
+
|| binding.flow_digest !== deployment.entry.manifest.flow.digest) throw new FlowToolError('not_authorized');
|
|
215
|
+
return { deployment, binding, entries: entries! };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function projectRun(runId: string): Promise<FlowToolRunV1> {
|
|
219
|
+
const { deployment, binding, entries } = await readAuthorizedRun(runId);
|
|
220
|
+
let snapshot: Awaited<ReturnType<JournalClient['runGet']>>;
|
|
221
|
+
try { snapshot = await options.journal.runGet(runId); } catch (error) { mapProtocolError(error); }
|
|
222
|
+
const completed = entries.find(entry => entry.entry_type === 'step.completed' && entry.step_id === RESULT_STEP);
|
|
223
|
+
const ended = entries.find(entry => entry.entry_type === 'run.completed');
|
|
224
|
+
let reason = ended === undefined ? null : terminalReason(ended.payload['completionReason']);
|
|
225
|
+
if (reason === null && ['failed', 'interrupted'].includes(snapshot!.status)) reason = 'execution_failed';
|
|
226
|
+
let result: Readonly<Record<string, FlowToolJson>> | null = null;
|
|
227
|
+
if (reason === 'success') {
|
|
228
|
+
// The journal already recorded success. Do not publish a different
|
|
229
|
+
// terminal reason from a projection-time check the journal never stored.
|
|
230
|
+
const output = completed?.payload['output'];
|
|
231
|
+
if (!record(output) || typeof output['stdout_tail'] !== 'string') throw new FlowToolError('unavailable');
|
|
232
|
+
try { result = validateFlowToolResult(deployment.entry.manifest, JSON.parse(output['stdout_tail'])); }
|
|
233
|
+
catch { throw new FlowToolError('unavailable'); }
|
|
234
|
+
}
|
|
235
|
+
const state = runState(snapshot!.status, reason);
|
|
236
|
+
const sequence = Math.max(0, ...entries.map(entry => entry.seq));
|
|
237
|
+
const evidence = { journal_digest: `sha256:${sha256(canonicalize(entries))}` as const,
|
|
238
|
+
flow_digest: deployment.entry.manifest.flow.digest, artifacts: [], redacted_transcript_refs: [] };
|
|
239
|
+
const terminal = reason === null ? null : {
|
|
240
|
+
terminal_reason: reason,
|
|
241
|
+
business_verdict: reason === 'success' ? deployment.businessVerdict : null,
|
|
242
|
+
result: reason === 'success' ? result : null,
|
|
243
|
+
gates: [], evidence,
|
|
244
|
+
spend: { tokens_in: snapshot!.budget.tokens_in, tokens_out: snapshot!.budget.tokens_out,
|
|
245
|
+
dollars: snapshot!.budget.dollars, dollars_unmetered: snapshot!.budget.dollars_unmetered === true },
|
|
246
|
+
};
|
|
247
|
+
return { api_version: 1, accepted: true, run_id: runId, tool_name: deployment.entry.manifest.name,
|
|
248
|
+
deployment_id: deployment.entry.deployment_id, manifest_digest: deployment.entry.manifest.digest,
|
|
249
|
+
flow_digest: deployment.entry.manifest.flow.digest, input_digest: binding.input_digest as `sha256:${string}`,
|
|
250
|
+
state, sequence, ...flowToolRunLinks(runId), terminal };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function eventsFor(run: FlowToolRunV1, entries: readonly JournalEntry[], after: number): FlowToolEventV1[] {
|
|
254
|
+
const projected: FlowToolEventV1[] = [];
|
|
255
|
+
for (const entry of entries) {
|
|
256
|
+
if (entry.seq <= after) continue;
|
|
257
|
+
if (entry.entry_type === 'run.spawned') projected.push({ api_version: 1, run_id: run.run_id,
|
|
258
|
+
flow_digest: run.flow_digest, sequence: entry.seq, type: 'run.accepted', state: 'accepted' });
|
|
259
|
+
else if (entry.entry_type === 'step.completed') projected.push({ api_version: 1, run_id: run.run_id,
|
|
260
|
+
flow_digest: run.flow_digest, sequence: entry.seq, type: 'step.completed', state: 'running',
|
|
261
|
+
...(entry.step_id === null || entry.step_id === undefined ? {} : { step_id: entry.step_id }) });
|
|
262
|
+
else if (entry.entry_type === 'run.completed') projected.push({ api_version: 1, run_id: run.run_id,
|
|
263
|
+
flow_digest: run.flow_digest, sequence: entry.seq, type: 'run.terminal', state: run.state,
|
|
264
|
+
terminal_reason: run.terminal!.terminal_reason });
|
|
265
|
+
}
|
|
266
|
+
return projected;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
async request(request: FlowToolRequest): Promise<unknown> {
|
|
271
|
+
if (request.method === 'GET' && request.path === '/api/v1/flow-tools') {
|
|
272
|
+
return { api_version: 1, tools: [...deployments.values()].filter(authorized).map(value => value.entry) };
|
|
273
|
+
}
|
|
274
|
+
const invoke = request.path.match(/^\/api\/v1\/flow-tools\/([^/]+)\/invoke$/);
|
|
275
|
+
if (request.method === 'POST' && invoke !== null) {
|
|
276
|
+
const deployment = names.get(invoke[1]!);
|
|
277
|
+
if (deployment === undefined || !authorized(deployment)) throw new FlowToolError('not_authorized');
|
|
278
|
+
flowToolOperationKey(request.idempotencyKey);
|
|
279
|
+
const body = parseFlowToolInvocation(request.body, deployment.entry);
|
|
280
|
+
const encoded = canonicalize(body.input);
|
|
281
|
+
if (Buffer.byteLength(encoded) > MAX_RESULT_BYTES) throw new FlowToolError('unsupported');
|
|
282
|
+
// Catalog bytes and the operator verdict label are not part of the
|
|
283
|
+
// admitted program. Putting them in the spec description changes the
|
|
284
|
+
// kernel spec hash, so a budget or verdict reload conflicts with the
|
|
285
|
+
// same operation key and the original run can no longer be read.
|
|
286
|
+
const binding: RunBinding = { kind: 'relayflows.flow-tool-run.v1', deployment_id: deployment.entry.deployment_id,
|
|
287
|
+
manifest_digest: deployment.entry.manifest.digest, flow_digest: deployment.entry.manifest.flow.digest,
|
|
288
|
+
input_digest: body.input_digest, principal_digest: principalDigest };
|
|
289
|
+
const step = deployment.template.steps[0]! as unknown as KernelArgvStep;
|
|
290
|
+
const spec: KernelRunSpec = { ...deployment.template, description: bindingDescription(binding), steps: [{
|
|
291
|
+
...step, command: [step.command[0]!, '%s', encoded],
|
|
292
|
+
} as unknown as KernelDeterministicStep] };
|
|
293
|
+
const admissionKey = `flow-tool:${sha256(canonicalize([principalDigest, deployment.entry.deployment_id,
|
|
294
|
+
deployment.entry.manifest.flow.digest, deployment.entry.manifest.digest, request.idempotencyKey]))}`;
|
|
295
|
+
let started: Awaited<ReturnType<JournalClient['runStart']>>;
|
|
296
|
+
try { started = await options.journal.runStart(spec, undefined, admissionKey); }
|
|
297
|
+
catch (error) { mapProtocolError(error, true); }
|
|
298
|
+
return projectRun(started!.run_id);
|
|
299
|
+
}
|
|
300
|
+
const runPath = request.path.match(/^\/api\/v1\/flow-runs\/([^/]+)(.*)$/);
|
|
301
|
+
if (runPath === null) throw new FlowToolError('not_found');
|
|
302
|
+
const run = await projectRun(runPath[1]!);
|
|
303
|
+
if (request.method === 'GET' && runPath[2] === '') return run;
|
|
304
|
+
if (request.method === 'GET' && runPath[2] === '/evidence') {
|
|
305
|
+
if (run.terminal === null) throw new FlowToolError('unavailable');
|
|
306
|
+
return { api_version: 1, run_id: run.run_id, evidence: run.terminal.evidence };
|
|
307
|
+
}
|
|
308
|
+
// The conformance program cannot park, so commands are deliberately absent.
|
|
309
|
+
throw new FlowToolError('unsupported');
|
|
310
|
+
},
|
|
311
|
+
async *events(path: string, after: number): AsyncIterable<unknown> {
|
|
312
|
+
const match = path.match(/^\/api\/v1\/flow-runs\/([^/]+)\/events$/);
|
|
313
|
+
if (match === null || !Number.isSafeInteger(after) || after < 0) throw new FlowToolError('invalid_contract');
|
|
314
|
+
const { entries } = await readAuthorizedRun(match[1]!);
|
|
315
|
+
const run = await projectRun(match[1]!);
|
|
316
|
+
for (const event of eventsFor(run, entries, after)) yield event;
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { canonicalize } from './canonical.js';
|
|
2
|
+
import { sha256 } from './bundle.js';
|
|
3
|
+
import { snapshotJsonValue, type JsonValue } from './json-value.js';
|
|
4
|
+
import { FLOW_TOOL_LIMITS, FLOW_TOOL_SCHEMA_DIALECT, flowToolSchema, compileFlowToolSchema } from './flow-tool-schema.js';
|
|
5
|
+
import type { FlowToolJson, FlowToolObjectSchema } from './flow-tool-schema.js';
|
|
6
|
+
|
|
7
|
+
export type FlowToolDigest = `sha256:${string}`;
|
|
8
|
+
export interface FlowToolDeclarationV1 {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly description: string;
|
|
11
|
+
/** Author-supplied immutable bundle reference; not proof of bundle verification. */
|
|
12
|
+
readonly flow: Readonly<{ name: string; version: string; digest: FlowToolDigest }>;
|
|
13
|
+
readonly inputSchema: FlowToolObjectSchema;
|
|
14
|
+
readonly resultSchema: FlowToolObjectSchema;
|
|
15
|
+
}
|
|
16
|
+
export interface FlowToolManifestV1 extends FlowToolDeclarationV1 {
|
|
17
|
+
readonly manifestVersion: 1;
|
|
18
|
+
readonly schemaDialect: typeof FLOW_TOOL_SCHEMA_DIALECT;
|
|
19
|
+
/** SHA-256 of canonical manifest content excluding this field. Not a signature. */
|
|
20
|
+
readonly digest: FlowToolDigest;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const FIELDS = ['name', 'description', 'flow', 'inputSchema', 'resultSchema'];
|
|
24
|
+
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
25
|
+
const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
26
|
+
|
|
27
|
+
function record(value: JsonValue, fields: readonly string[], at: string): Record<string, JsonValue> {
|
|
28
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new TypeError(`${at}: expected an object`);
|
|
29
|
+
if (Object.keys(value).some(key => !fields.includes(key)) || fields.some(key => !Object.hasOwn(value, key))) {
|
|
30
|
+
// Unknown field names may themselves be sensitive caller data; do not echo them.
|
|
31
|
+
throw new TypeError(`${at}: missing or unknown fields`);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function declaration(value: unknown): FlowToolDeclarationV1 {
|
|
37
|
+
const data = record(snapshotJsonValue(value, 'flow tool', FLOW_TOOL_LIMITS), FIELDS, 'flow tool');
|
|
38
|
+
if (typeof data.name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(data.name)) {
|
|
39
|
+
throw new TypeError('flow tool.name: expected a portable tool name (1–64 ASCII characters)');
|
|
40
|
+
}
|
|
41
|
+
if (typeof data.description !== 'string' || !data.description.trim() || data.description.length > 4096) {
|
|
42
|
+
throw new TypeError('flow tool.description: expected 1–4096 characters of operator-authored description');
|
|
43
|
+
}
|
|
44
|
+
const flow = record(data.flow!, ['name', 'version', 'digest'], 'flow tool.flow');
|
|
45
|
+
if (typeof flow.name !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(flow.name)) {
|
|
46
|
+
throw new TypeError('flow tool.flow.name: expected a safe flow name');
|
|
47
|
+
}
|
|
48
|
+
if (typeof flow.version !== 'string' || flow.version.length > 128 || !VERSION.test(flow.version)
|
|
49
|
+
|| flow.version.split('+')[0]!.split('-').slice(1).join('-').split('.').some(id => /^0\d+$/.test(id))) {
|
|
50
|
+
throw new TypeError('flow tool.flow.version: expected a semantic version');
|
|
51
|
+
}
|
|
52
|
+
if (typeof flow.digest !== 'string' || !DIGEST.test(flow.digest)) throw new TypeError('flow tool.flow.digest: expected sha256 and 64 lowercase hex digits');
|
|
53
|
+
return Object.freeze({
|
|
54
|
+
name: data.name, description: data.description,
|
|
55
|
+
flow: Object.freeze({ name: flow.name, version: flow.version, digest: flow.digest as FlowToolDigest }),
|
|
56
|
+
inputSchema: flowToolSchema(data.inputSchema, 'flow tool.inputSchema'),
|
|
57
|
+
resultSchema: flowToolSchema(data.resultSchema, 'flow tool.resultSchema'),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Explicit runtime contract; TypeScript generics are never treated as schemas. */
|
|
62
|
+
export function createFlowToolManifest(value: FlowToolDeclarationV1): FlowToolManifestV1 {
|
|
63
|
+
const content = Object.freeze({ manifestVersion: 1 as const, schemaDialect: FLOW_TOOL_SCHEMA_DIALECT, ...declaration(value) });
|
|
64
|
+
return snapshotJsonValue({ ...content, digest: `sha256:${sha256(canonicalize(content))}` },
|
|
65
|
+
'flow tool manifest', FLOW_TOOL_LIMITS) as unknown as FlowToolManifestV1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Verify untrusted JSON and optionally bind it to a caller-trusted catalog digest. */
|
|
69
|
+
export function parseFlowToolManifest(value: unknown, expectedDigest?: FlowToolDigest): FlowToolManifestV1 {
|
|
70
|
+
const data = record(snapshotJsonValue(value, 'flow tool manifest', FLOW_TOOL_LIMITS),
|
|
71
|
+
[...FIELDS, 'manifestVersion', 'schemaDialect', 'digest'], 'flow tool manifest');
|
|
72
|
+
if (data.manifestVersion !== 1 || data.schemaDialect !== FLOW_TOOL_SCHEMA_DIALECT) {
|
|
73
|
+
throw new TypeError('flow tool manifest: unsupported manifest version or schema dialect');
|
|
74
|
+
}
|
|
75
|
+
// Identity verification must never trim, case-fold or otherwise normalize metadata.
|
|
76
|
+
const parsed = createFlowToolManifest(Object.fromEntries(FIELDS.map(key => [key, data[key]])) as unknown as FlowToolDeclarationV1);
|
|
77
|
+
if (data.digest !== parsed.digest || (expectedDigest !== undefined && parsed.digest !== expectedDigest)) {
|
|
78
|
+
throw new TypeError('flow tool manifest: digest mismatch');
|
|
79
|
+
}
|
|
80
|
+
return parsed;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function canonicalFlowToolManifest(value: FlowToolManifestV1): string {
|
|
84
|
+
return canonicalize(parseFlowToolManifest(value));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validate(value: unknown, schema: FlowToolObjectSchema, at: string): Readonly<Record<string, FlowToolJson>> {
|
|
88
|
+
const snapshot = snapshotJsonValue(value, at, FLOW_TOOL_LIMITS);
|
|
89
|
+
const validator = compileFlowToolSchema(schema);
|
|
90
|
+
if (!validator(snapshot)) {
|
|
91
|
+
// Report only the keyword: instance paths can contain sensitive caller keys.
|
|
92
|
+
throw new TypeError(`${at}: schema validation failed (${validator.errors?.[0]?.keyword ?? 'invalid'})`);
|
|
93
|
+
}
|
|
94
|
+
return snapshot as Readonly<Record<string, FlowToolJson>>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function validateFlowToolInput(manifest: FlowToolManifestV1, input: unknown): Readonly<Record<string, FlowToolJson>> {
|
|
98
|
+
return validate(input, parseFlowToolManifest(manifest).inputSchema, 'flow tool input');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Validates structure only, not business truth or successful execution. */
|
|
102
|
+
export function validateFlowToolResult(manifest: FlowToolManifestV1, result: unknown): Readonly<Record<string, FlowToolJson>> {
|
|
103
|
+
return validate(result, parseFlowToolManifest(manifest).resultSchema, 'flow tool result');
|
|
104
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
2
|
+
import { jsonSchemaBoundError } from './json-schema-bound.js';
|
|
3
|
+
import { snapshotJsonValue, type JsonValue } from './json-value.js';
|
|
4
|
+
|
|
5
|
+
export const FLOW_TOOL_SCHEMA_DIALECT = 'https://json-schema.org/draft/2020-12/schema' as const;
|
|
6
|
+
export type FlowToolJson = null | boolean | number | string | readonly FlowToolJson[]
|
|
7
|
+
| { readonly [key: string]: FlowToolJson };
|
|
8
|
+
export type FlowToolObjectSchema = Readonly<Record<string, FlowToolJson>> & { readonly type: 'object' };
|
|
9
|
+
export const FLOW_TOOL_LIMITS = Object.freeze({ maxDepth: 64, maxNodes: 4096, maxBytes: 262144 });
|
|
10
|
+
|
|
11
|
+
// A deliberately explicit portable v1 profile, not silently ignored JSON Schema.
|
|
12
|
+
const KEYWORDS = new Set([
|
|
13
|
+
'$schema', '$defs', '$ref', 'title', 'description', 'type', 'enum', 'const',
|
|
14
|
+
'properties', 'required', 'additionalProperties', 'items', 'minItems', 'maxItems',
|
|
15
|
+
'uniqueItems', 'minLength', 'maxLength', 'minimum', 'maximum', 'exclusiveMinimum',
|
|
16
|
+
'exclusiveMaximum', 'multipleOf', 'minProperties', 'maxProperties', 'anyOf', 'oneOf', 'allOf', 'not',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function inspect(schema: JsonValue, at: string): void {
|
|
20
|
+
if (typeof schema === 'boolean') return;
|
|
21
|
+
if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) {
|
|
22
|
+
throw new TypeError(`${at}: expected a JSON Schema object or boolean`);
|
|
23
|
+
}
|
|
24
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
25
|
+
if (!KEYWORDS.has(key)) throw new TypeError(`${at}: unsupported v1 schema keyword ${key}`);
|
|
26
|
+
if (key === '$schema' && value !== FLOW_TOOL_SCHEMA_DIALECT) {
|
|
27
|
+
throw new TypeError(`${at}: expected JSON Schema 2020-12`);
|
|
28
|
+
}
|
|
29
|
+
if (key === '$ref' && (typeof value !== 'string' || !value.startsWith('#/'))) {
|
|
30
|
+
throw new TypeError(`${at}: only document-local JSON Pointer references are supported`);
|
|
31
|
+
}
|
|
32
|
+
if (key === 'properties' || key === '$defs') {
|
|
33
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
34
|
+
throw new TypeError(`${at}.${key}: expected an object`);
|
|
35
|
+
}
|
|
36
|
+
for (const [name, child] of Object.entries(value)) inspect(child, `${at}.${key}.${name}`);
|
|
37
|
+
} else if (key === 'items' || key === 'additionalProperties' || key === 'not') {
|
|
38
|
+
inspect(value, `${at}.${key}`);
|
|
39
|
+
} else if (key === 'anyOf' || key === 'oneOf' || key === 'allOf') {
|
|
40
|
+
if (!Array.isArray(value)) throw new TypeError(`${at}.${key}: expected an array`);
|
|
41
|
+
value.forEach((child, i) => inspect(child, `${at}.${key}[${i}]`));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function compileFlowToolSchema(schema: FlowToolObjectSchema) {
|
|
47
|
+
// No coercion, defaults, removal of extras, network retrieval, or ignored formats.
|
|
48
|
+
// Deliberately revalidate each supplied manifest; no unbounded global schema cache.
|
|
49
|
+
// A future admitted catalog may own a bounded compiled-validator lifecycle.
|
|
50
|
+
return new Ajv2020({ strict: true, strictRequired: true, allowUnionTypes: true, allErrors: false })
|
|
51
|
+
.compile(schema);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function flowToolSchema(value: unknown, at: string): FlowToolObjectSchema {
|
|
55
|
+
const snapshot = snapshotJsonValue(value, at, FLOW_TOOL_LIMITS);
|
|
56
|
+
inspect(snapshot, at);
|
|
57
|
+
if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot) || snapshot.type !== 'object') {
|
|
58
|
+
throw new TypeError(`${at}: tool schemas must explicitly have type "object"`);
|
|
59
|
+
}
|
|
60
|
+
const bounded = jsonSchemaBoundError(snapshot);
|
|
61
|
+
if (bounded !== undefined) throw new TypeError(`${at}: ${bounded}`);
|
|
62
|
+
const schema = snapshot as FlowToolObjectSchema;
|
|
63
|
+
try { compileFlowToolSchema(schema); }
|
|
64
|
+
catch { throw new TypeError(`${at}: invalid or unsupported JSON Schema`); }
|
|
65
|
+
return schema;
|
|
66
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { canonicalize } from './canonical.js';
|
|
2
|
+
import { sha256 } from './bundle.js';
|
|
3
|
+
import { snapshotJsonValue } from './json-value.js';
|
|
4
|
+
import { compileFlowToolSchema, FLOW_TOOL_LIMITS, type FlowToolObjectSchema } from './flow-tool-schema.js';
|
|
5
|
+
import { parseFlowToolManifest, validateFlowToolInput, validateFlowToolResult, type FlowToolDigest } from './flow-tool-manifest.js';
|
|
6
|
+
import {
|
|
7
|
+
FlowToolError, FLOW_TOOL_CATALOG_SCHEMA, FLOW_TOOL_RUN_SCHEMA, FLOW_TOOL_EVIDENCE_SCHEMA, FLOW_TOOL_EVENT_SCHEMA, FLOW_TOOL_INVOKE_SCHEMA,
|
|
8
|
+
type FlowToolCatalogV1, type FlowToolCatalogEntryV1, type FlowToolRunV1, type FlowToolEventV1, type FlowToolEvidenceV1, type FlowToolInvokeRequestV1,
|
|
9
|
+
} from './flow-tool-contract.js';
|
|
10
|
+
|
|
11
|
+
// Compile only our fixed protocol schemas; never cache caller-controlled schemas globally.
|
|
12
|
+
const validators = new Map([FLOW_TOOL_CATALOG_SCHEMA, FLOW_TOOL_RUN_SCHEMA, FLOW_TOOL_EVIDENCE_SCHEMA, FLOW_TOOL_EVENT_SCHEMA, FLOW_TOOL_INVOKE_SCHEMA]
|
|
13
|
+
.map(schema => [schema, compileFlowToolSchema(schema)]));
|
|
14
|
+
// FLOW_TOOL_LIMITS bounds one manifest, input, or result. A catalog holds up
|
|
15
|
+
// to 64 of those documents, and a run or invoke envelope wraps one of them.
|
|
16
|
+
// Snapshotting the envelope with the single-document budget rejects a valid
|
|
17
|
+
// catalog or a near-limit result before that document is checked on its own.
|
|
18
|
+
const CATALOG_TOOL_CAP = 64;
|
|
19
|
+
export const FLOW_TOOL_ENVELOPE_LIMITS = Object.freeze({
|
|
20
|
+
maxDepth: FLOW_TOOL_LIMITS.maxDepth,
|
|
21
|
+
maxNodes: FLOW_TOOL_LIMITS.maxNodes * CATALOG_TOOL_CAP + FLOW_TOOL_LIMITS.maxNodes,
|
|
22
|
+
maxBytes: FLOW_TOOL_LIMITS.maxBytes * CATALOG_TOOL_CAP + FLOW_TOOL_LIMITS.maxBytes,
|
|
23
|
+
});
|
|
24
|
+
const ENVELOPE_SCHEMAS = new Set<FlowToolObjectSchema>([FLOW_TOOL_CATALOG_SCHEMA, FLOW_TOOL_RUN_SCHEMA, FLOW_TOOL_INVOKE_SCHEMA]);
|
|
25
|
+
function wire<T>(value: unknown, schema: FlowToolObjectSchema): T {
|
|
26
|
+
try {
|
|
27
|
+
const snapshot = snapshotJsonValue(value, 'flow tool protocol', ENVELOPE_SCHEMAS.has(schema) ? FLOW_TOOL_ENVELOPE_LIMITS : FLOW_TOOL_LIMITS);
|
|
28
|
+
if (!validators.get(schema)!(snapshot)) throw new FlowToolError('invalid_contract');
|
|
29
|
+
return snapshot as T;
|
|
30
|
+
} catch { throw new FlowToolError('invalid_contract'); }
|
|
31
|
+
}
|
|
32
|
+
export function toolId(value: unknown): asserts value is string {
|
|
33
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/.test(value)) throw new FlowToolError('invalid_contract');
|
|
34
|
+
}
|
|
35
|
+
function digest(value: unknown): asserts value is FlowToolDigest {
|
|
36
|
+
if (typeof value !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(value)) throw new FlowToolError('invalid_contract');
|
|
37
|
+
}
|
|
38
|
+
function decimal(value: string): void {
|
|
39
|
+
if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(value)) throw new FlowToolError('invalid_contract');
|
|
40
|
+
}
|
|
41
|
+
export function flowToolInputDigest(value: unknown): FlowToolDigest {
|
|
42
|
+
return `sha256:${sha256(canonicalize(snapshotJsonValue(value, 'flow tool input', FLOW_TOOL_LIMITS)))}`;
|
|
43
|
+
}
|
|
44
|
+
export function parseFlowToolCatalog(value: unknown): FlowToolCatalogV1 {
|
|
45
|
+
const catalog = wire<FlowToolCatalogV1>(value, FLOW_TOOL_CATALOG_SCHEMA);
|
|
46
|
+
const names = new Set<string>();
|
|
47
|
+
for (const entry of catalog.tools) {
|
|
48
|
+
try { parseFlowToolManifest(entry.manifest); } catch { throw new FlowToolError('invalid_contract'); }
|
|
49
|
+
toolId(entry.deployment_id);
|
|
50
|
+
if (names.has(entry.manifest.name)) throw new FlowToolError('invalid_contract');
|
|
51
|
+
names.add(entry.manifest.name);
|
|
52
|
+
for (const name of [...entry.business_verdicts, ...entry.requires_human]) toolId(name);
|
|
53
|
+
// Policy labels are not enforcement. The server must also bind read_only to
|
|
54
|
+
// effective capabilities; this v1 client will not accept a write-enabled entry.
|
|
55
|
+
if (entry.effects.some(effect => !/^[a-z][a-z0-9_-]*:read$/.test(effect))) throw new FlowToolError('invalid_contract');
|
|
56
|
+
if (entry.budget.max_dollars !== null) {
|
|
57
|
+
if (entry.budget.max_dollars.length > 128) throw new FlowToolError('invalid_contract');
|
|
58
|
+
decimal(entry.budget.max_dollars);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return catalog;
|
|
62
|
+
}
|
|
63
|
+
export function parseFlowToolEntry(value: unknown): FlowToolCatalogEntryV1 {
|
|
64
|
+
return parseFlowToolCatalog({ api_version: 1, tools: [value] }).tools[0]!;
|
|
65
|
+
}
|
|
66
|
+
/** Stable discovery serialization independent of input key or catalog ordering. */
|
|
67
|
+
export function canonicalFlowToolCatalog(value: unknown): string {
|
|
68
|
+
const catalog = parseFlowToolCatalog(value);
|
|
69
|
+
return canonicalize({ api_version: 1, tools: [...catalog.tools].sort((a, b) =>
|
|
70
|
+
a.manifest.name < b.manifest.name ? -1 : a.manifest.name > b.manifest.name ? 1 : 0) });
|
|
71
|
+
}
|
|
72
|
+
/** Shared admission validation; this proves a contract binding, not authorization. */
|
|
73
|
+
export function parseFlowToolInvocation(value: unknown, selected: FlowToolCatalogEntryV1): FlowToolInvokeRequestV1 {
|
|
74
|
+
const entry = parseFlowToolEntry(selected);
|
|
75
|
+
const request = wire<FlowToolInvokeRequestV1>(value, FLOW_TOOL_INVOKE_SCHEMA);
|
|
76
|
+
if (request.flow !== `${entry.manifest.flow.name}@${entry.manifest.flow.digest}`
|
|
77
|
+
|| request.deployment_id !== entry.deployment_id || request.manifest_digest !== entry.manifest.digest
|
|
78
|
+
|| (request.mode === 'async' && request.wait_ms !== 0)) throw new FlowToolError('invalid_contract');
|
|
79
|
+
let input: unknown;
|
|
80
|
+
try { input = validateFlowToolInput(entry.manifest, request.input); } catch { throw new FlowToolError('invalid_contract'); }
|
|
81
|
+
if (flowToolInputDigest(input) !== request.input_digest) throw new FlowToolError('invalid_contract');
|
|
82
|
+
return request;
|
|
83
|
+
}
|
|
84
|
+
export function parseFlowToolEvidence(value: unknown, flowDigest: FlowToolDigest): FlowToolEvidenceV1 {
|
|
85
|
+
const evidence = wire<FlowToolEvidenceV1>(value, FLOW_TOOL_EVIDENCE_SCHEMA);
|
|
86
|
+
digest(evidence.journal_digest);
|
|
87
|
+
if (evidence.flow_digest !== flowDigest) throw new FlowToolError('invalid_contract');
|
|
88
|
+
const refs = new Set<string>();
|
|
89
|
+
for (const artifact of evidence.artifacts) {
|
|
90
|
+
toolId(artifact.name); toolId(artifact.ref);
|
|
91
|
+
if (refs.has(artifact.ref) || !/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/.test(artifact.media_type)) throw new FlowToolError('invalid_contract');
|
|
92
|
+
refs.add(artifact.ref);
|
|
93
|
+
}
|
|
94
|
+
evidence.redacted_transcript_refs.forEach(toolId);
|
|
95
|
+
return evidence;
|
|
96
|
+
}
|
|
97
|
+
export function flowToolRunLinks(runId: string) {
|
|
98
|
+
toolId(runId);
|
|
99
|
+
const path = `/api/v1/flow-runs/${runId}`;
|
|
100
|
+
return { status_url: path, events_url: `${path}/events`, evidence_url: `${path}/evidence`, cancel_url: `${path}/cancel`, resume_url: `${path}/resume` };
|
|
101
|
+
}
|
|
102
|
+
export function parseFlowToolRun(value: unknown, selected: FlowToolCatalogEntryV1, previous?: FlowToolRunV1): FlowToolRunV1 {
|
|
103
|
+
const run = wire<FlowToolRunV1>(value, FLOW_TOOL_RUN_SCHEMA);
|
|
104
|
+
toolId(run.run_id); digest(run.input_digest);
|
|
105
|
+
if (run.tool_name !== selected.manifest.name || run.deployment_id !== selected.deployment_id
|
|
106
|
+
|| run.manifest_digest !== selected.manifest.digest || run.flow_digest !== selected.manifest.flow.digest) throw new FlowToolError('invalid_contract');
|
|
107
|
+
for (const [key, url] of Object.entries(flowToolRunLinks(run.run_id))) {
|
|
108
|
+
if (run[key as keyof FlowToolRunV1] !== url) throw new FlowToolError('invalid_contract');
|
|
109
|
+
}
|
|
110
|
+
const terminal = ['completed', 'failed', 'cancelled'].includes(run.state);
|
|
111
|
+
if (terminal !== (run.terminal !== null)) throw new FlowToolError('invalid_contract');
|
|
112
|
+
if (run.terminal !== null) {
|
|
113
|
+
const result = run.terminal;
|
|
114
|
+
const success = result.terminal_reason === 'success';
|
|
115
|
+
if (success !== (run.state === 'completed') || (run.state === 'cancelled') !== (result.terminal_reason === 'canceled')) throw new FlowToolError('invalid_contract');
|
|
116
|
+
if (success) {
|
|
117
|
+
if (result.business_verdict === null || !selected.business_verdicts.includes(result.business_verdict) || result.result === null) throw new FlowToolError('invalid_contract');
|
|
118
|
+
try { validateFlowToolResult(selected.manifest, result.result); } catch { throw new FlowToolError('invalid_contract'); }
|
|
119
|
+
} else if (result.business_verdict !== null || result.result !== null) throw new FlowToolError('invalid_contract');
|
|
120
|
+
const evidence = parseFlowToolEvidence(result.evidence, run.flow_digest);
|
|
121
|
+
const refs = new Set(evidence.artifacts.map(artifact => artifact.ref));
|
|
122
|
+
const gateNames = new Set<string>();
|
|
123
|
+
for (const gate of result.gates) {
|
|
124
|
+
toolId(gate.name); toolId(gate.evidence_ref);
|
|
125
|
+
if (!refs.has(gate.evidence_ref) || gateNames.has(gate.name)) throw new FlowToolError('invalid_contract');
|
|
126
|
+
gateNames.add(gate.name);
|
|
127
|
+
}
|
|
128
|
+
decimal(result.spend.dollars);
|
|
129
|
+
if (selected.budget.max_dollars !== null && result.spend.dollars_unmetered) throw new FlowToolError('invalid_contract');
|
|
130
|
+
}
|
|
131
|
+
if (previous !== undefined) {
|
|
132
|
+
if (run.run_id !== previous.run_id || run.input_digest !== previous.input_digest || run.sequence < previous.sequence
|
|
133
|
+
|| (run.sequence === previous.sequence && canonicalize(run) !== canonicalize(previous))
|
|
134
|
+
|| (previous.terminal !== null && canonicalize(run) !== canonicalize(previous))) throw new FlowToolError('invalid_contract');
|
|
135
|
+
}
|
|
136
|
+
return run;
|
|
137
|
+
}
|
|
138
|
+
export function parseFlowToolEvent(value: unknown, run: FlowToolRunV1, cursor: number): FlowToolEventV1 {
|
|
139
|
+
const event = wire<FlowToolEventV1>(value, FLOW_TOOL_EVENT_SCHEMA);
|
|
140
|
+
if (event.run_id !== run.run_id || event.flow_digest !== run.flow_digest || event.sequence <= cursor) throw new FlowToolError('invalid_contract');
|
|
141
|
+
if (event.step_id !== undefined) toolId(event.step_id);
|
|
142
|
+
if (event.wait_id !== undefined) toolId(event.wait_id);
|
|
143
|
+
const terminal = ['completed', 'failed', 'cancelled'].includes(event.state);
|
|
144
|
+
if ((event.type === 'run.terminal') !== terminal || terminal !== (event.terminal_reason !== undefined)
|
|
145
|
+
|| (event.type === 'run.accepted' && event.state !== 'accepted')
|
|
146
|
+
|| (event.type !== 'human.required' && event.wait_id !== undefined)
|
|
147
|
+
|| (event.type === 'human.required' && (event.wait_id === undefined || event.state !== 'parked'))
|
|
148
|
+
|| (event.type === 'step.completed' && event.step_id === undefined)
|
|
149
|
+
|| (terminal && ((event.state === 'completed') !== (event.terminal_reason === 'success')
|
|
150
|
+
|| (event.state === 'cancelled') !== (event.terminal_reason === 'canceled')))) throw new FlowToolError('invalid_contract');
|
|
151
|
+
return event;
|
|
152
|
+
}
|