@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.
Files changed (103) hide show
  1. package/dist/authored-flow-error.d.ts +1 -1
  2. package/dist/authored-flow-error.d.ts.map +1 -1
  3. package/dist/authored-flow-error.js.map +1 -1
  4. package/dist/authored-helper-effect.d.ts.map +1 -1
  5. package/dist/authored-helper-effect.js +9 -0
  6. package/dist/authored-helper-effect.js.map +1 -1
  7. package/dist/authored-root.d.ts.map +1 -1
  8. package/dist/authored-root.js +17 -0
  9. package/dist/authored-root.js.map +1 -1
  10. package/dist/cli/direct-run.d.ts.map +1 -1
  11. package/dist/cli/direct-run.js +7 -0
  12. package/dist/cli/direct-run.js.map +1 -1
  13. package/dist/cli/run.d.ts.map +1 -1
  14. package/dist/cli/run.js +31 -2
  15. package/dist/cli/run.js.map +1 -1
  16. package/dist/cloud-mirror-step.d.ts.map +1 -1
  17. package/dist/cloud-mirror-step.js +6 -1
  18. package/dist/cloud-mirror-step.js.map +1 -1
  19. package/dist/compile.d.ts +1 -0
  20. package/dist/compile.d.ts.map +1 -1
  21. package/dist/compile.js +2 -1
  22. package/dist/compile.js.map +1 -1
  23. package/dist/failure-kinds.d.ts +1 -1
  24. package/dist/failure-kinds.d.ts.map +1 -1
  25. package/dist/failure-kinds.js +1 -0
  26. package/dist/failure-kinds.js.map +1 -1
  27. package/dist/flow-tool-adapters.d.ts +56 -0
  28. package/dist/flow-tool-adapters.d.ts.map +1 -0
  29. package/dist/flow-tool-adapters.js +42 -0
  30. package/dist/flow-tool-adapters.js.map +1 -0
  31. package/dist/flow-tool-client.d.ts +38 -0
  32. package/dist/flow-tool-client.d.ts.map +1 -0
  33. package/dist/flow-tool-client.js +105 -0
  34. package/dist/flow-tool-client.js.map +1 -0
  35. package/dist/flow-tool-contract.d.ts +103 -0
  36. package/dist/flow-tool-contract.d.ts.map +1 -0
  37. package/dist/flow-tool-contract.js +75 -0
  38. package/dist/flow-tool-contract.js.map +1 -0
  39. package/dist/flow-tool-definitions.d.ts +19 -0
  40. package/dist/flow-tool-definitions.d.ts.map +1 -0
  41. package/dist/flow-tool-definitions.js +12 -0
  42. package/dist/flow-tool-definitions.js.map +1 -0
  43. package/dist/flow-tool-http.d.ts +14 -0
  44. package/dist/flow-tool-http.d.ts.map +1 -0
  45. package/dist/flow-tool-http.js +175 -0
  46. package/dist/flow-tool-http.js.map +1 -0
  47. package/dist/flow-tool-kernel.d.ts +27 -0
  48. package/dist/flow-tool-kernel.d.ts.map +1 -0
  49. package/dist/flow-tool-kernel.js +310 -0
  50. package/dist/flow-tool-kernel.js.map +1 -0
  51. package/dist/flow-tool-manifest.d.ts +30 -0
  52. package/dist/flow-tool-manifest.d.ts.map +1 -0
  53. package/dist/flow-tool-manifest.js +79 -0
  54. package/dist/flow-tool-manifest.js.map +1 -0
  55. package/dist/flow-tool-schema.d.ts +15 -0
  56. package/dist/flow-tool-schema.d.ts.map +1 -0
  57. package/dist/flow-tool-schema.js +70 -0
  58. package/dist/flow-tool-schema.js.map +1 -0
  59. package/dist/flow-tool-wire.d.ts +26 -0
  60. package/dist/flow-tool-wire.d.ts.map +1 -0
  61. package/dist/flow-tool-wire.js +195 -0
  62. package/dist/flow-tool-wire.js.map +1 -0
  63. package/dist/helper-park.d.ts +14 -0
  64. package/dist/helper-park.d.ts.map +1 -0
  65. package/dist/helper-park.js +40 -0
  66. package/dist/helper-park.js.map +1 -0
  67. package/dist/helper-receipt.d.ts +11 -0
  68. package/dist/helper-receipt.d.ts.map +1 -0
  69. package/dist/helper-receipt.js +100 -0
  70. package/dist/helper-receipt.js.map +1 -0
  71. package/dist/helper-writeback.d.ts.map +1 -1
  72. package/dist/helper-writeback.js +7 -2
  73. package/dist/helper-writeback.js.map +1 -1
  74. package/dist/index.d.ts +9 -0
  75. package/dist/index.d.ts.map +1 -1
  76. package/dist/index.js +9 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/yaml-helper-effect.d.ts.map +1 -1
  79. package/dist/yaml-helper-effect.js +32 -23
  80. package/dist/yaml-helper-effect.js.map +1 -1
  81. package/package.json +2 -2
  82. package/src/authored-flow-error.ts +1 -0
  83. package/src/authored-helper-effect.ts +9 -0
  84. package/src/authored-root.ts +17 -0
  85. package/src/cli/direct-run.ts +7 -0
  86. package/src/cli/run.ts +30 -2
  87. package/src/cloud-mirror-step.ts +6 -1
  88. package/src/compile.ts +2 -1
  89. package/src/failure-kinds.ts +1 -0
  90. package/src/flow-tool-adapters.ts +43 -0
  91. package/src/flow-tool-client.ts +115 -0
  92. package/src/flow-tool-contract.ts +153 -0
  93. package/src/flow-tool-definitions.ts +27 -0
  94. package/src/flow-tool-http.ts +134 -0
  95. package/src/flow-tool-kernel.ts +319 -0
  96. package/src/flow-tool-manifest.ts +104 -0
  97. package/src/flow-tool-schema.ts +66 -0
  98. package/src/flow-tool-wire.ts +152 -0
  99. package/src/helper-park.ts +45 -0
  100. package/src/helper-receipt.ts +90 -0
  101. package/src/helper-writeback.ts +8 -3
  102. package/src/index.ts +21 -0
  103. package/src/yaml-helper-effect.ts +12 -5
@@ -0,0 +1,115 @@
1
+ import { canonicalize } from './canonical.js';
2
+ import { snapshotJsonValue } from './json-value.js';
3
+ import { FLOW_TOOL_LIMITS } from './flow-tool-schema.js';
4
+ import { validateFlowToolInput } from './flow-tool-manifest.js';
5
+ import { FlowToolError, type FlowToolCatalogEntryV1, type FlowToolRunV1, type FlowToolEventV1 } from './flow-tool-contract.js';
6
+ import { parseFlowToolCatalog, parseFlowToolEntry, parseFlowToolRun, parseFlowToolEvent, parseFlowToolEvidence, flowToolInputDigest, toolId } from './flow-tool-wire.js';
7
+
8
+ export interface FlowToolRequest {
9
+ readonly method: 'GET' | 'POST';
10
+ readonly path: string;
11
+ readonly body?: unknown;
12
+ readonly idempotencyKey?: string;
13
+ }
14
+ /**
15
+ * Authenticated control-plane boundary. Implementations must authorize every
16
+ * operation and durably admit/deduplicate runs; the client owns no run ledger.
17
+ * A test transport is not a hosted implementation. No source-submission fallback.
18
+ */
19
+ export interface FlowToolTransport {
20
+ request(request: FlowToolRequest): Promise<unknown>;
21
+ events(path: string, after: number): AsyncIterable<unknown>;
22
+ }
23
+ export interface FlowToolInvocationOptions {
24
+ /** Host-owned operation identity; preserve across reconnect/retry, never generate per retry. */
25
+ readonly idempotencyKey: string;
26
+ readonly mode?: 'async' | 'sync';
27
+ /** Server-side observation bound only, not an execution timeout or cancellation. */
28
+ readonly waitMs?: number;
29
+ }
30
+ export function flowToolOperationKey(value: unknown): asserts value is string {
31
+ if (typeof value !== 'string' || !/^[A-Za-z0-9_-][A-Za-z0-9_.:-]{0,127}$/.test(value)) throw new FlowToolError('invalid_contract');
32
+ }
33
+
34
+ export class FlowToolClient {
35
+ constructor(private readonly transport: FlowToolTransport) {}
36
+
37
+ async discover() {
38
+ return parseFlowToolCatalog(await this.transport.request({ method: 'GET', path: '/api/v1/flow-tools' }));
39
+ }
40
+
41
+ async invoke(selected: FlowToolCatalogEntryV1, input: unknown, options: FlowToolInvocationOptions): Promise<FlowToolRunV1> {
42
+ const entry = parseFlowToolEntry(selected);
43
+ // Schema validation precedes ALL transport calls, including credential access.
44
+ let validated: ReturnType<typeof validateFlowToolInput>;
45
+ try { validated = validateFlowToolInput(entry.manifest, input); }
46
+ catch { throw new FlowToolError('invalid_contract'); }
47
+ flowToolOperationKey(options.idempotencyKey);
48
+ const mode = options.mode ?? 'async';
49
+ const waitMs = options.waitMs ?? 0;
50
+ if (!['async', 'sync'].includes(mode) || !Number.isSafeInteger(waitMs) || waitMs < 0 || waitMs > 25_000
51
+ || (mode === 'async' && waitMs !== 0)) throw new FlowToolError('invalid_contract');
52
+ const inputDigest = flowToolInputDigest(validated);
53
+ const body = {
54
+ api_version: 1, flow: `${entry.manifest.flow.name}@${entry.manifest.flow.digest}`,
55
+ deployment_id: entry.deployment_id, manifest_digest: entry.manifest.digest,
56
+ input: validated, input_digest: inputDigest, mode, wait_ms: waitMs,
57
+ };
58
+ const run = parseFlowToolRun(await this.transport.request({
59
+ method: 'POST', path: `/api/v1/flow-tools/${entry.manifest.name}/invoke`, body, idempotencyKey: options.idempotencyKey,
60
+ }), entry);
61
+ if (run.input_digest !== inputDigest) throw new FlowToolError('invalid_contract');
62
+ return run;
63
+ }
64
+
65
+ async status(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1): Promise<FlowToolRunV1> {
66
+ const entry = parseFlowToolEntry(selected), run = parseFlowToolRun(receipt, entry);
67
+ return parseFlowToolRun(await this.transport.request({ method: 'GET', path: run.status_url }), entry, run);
68
+ }
69
+
70
+ async *events(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, after = 0): AsyncIterable<FlowToolEventV1> {
71
+ const run = parseFlowToolRun(receipt, parseFlowToolEntry(selected));
72
+ if (!Number.isSafeInteger(after) || after < 0) throw new FlowToolError('invalid_contract');
73
+ let cursor = after;
74
+ for await (const raw of this.transport.events(run.events_url, after)) {
75
+ const event = parseFlowToolEvent(raw, run, cursor);
76
+ cursor = event.sequence;
77
+ yield event;
78
+ if (event.type === 'run.terminal') return;
79
+ }
80
+ }
81
+
82
+ async evidence(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1) {
83
+ const run = parseFlowToolRun(receipt, parseFlowToolEntry(selected));
84
+ const response = await this.transport.request({ method: 'GET', path: run.evidence_url });
85
+ let raw: ReturnType<typeof snapshotJsonValue>;
86
+ try { raw = snapshotJsonValue(response, 'flow tool evidence', FLOW_TOOL_LIMITS); }
87
+ catch { throw new FlowToolError('invalid_contract'); }
88
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)
89
+ || Object.keys(raw).sort().join(',') !== 'api_version,evidence,run_id'
90
+ || raw.api_version !== 1 || raw.run_id !== run.run_id) throw new FlowToolError('invalid_contract');
91
+ const evidence = parseFlowToolEvidence(raw.evidence, run.flow_digest);
92
+ if (run.terminal !== null && canonicalize(evidence) !== canonicalize(run.terminal.evidence)) throw new FlowToolError('invalid_contract');
93
+ return evidence;
94
+ }
95
+
96
+ cancel(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, key: string) {
97
+ return this.command(selected, receipt, '/cancel', key, { api_version: 1 });
98
+ }
99
+ resume(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, key: string) {
100
+ return this.command(selected, receipt, '/resume', key, { api_version: 1 });
101
+ }
102
+ answer(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, waitId: string, approved: boolean, key: string) {
103
+ toolId(waitId);
104
+ if (typeof approved !== 'boolean') throw new FlowToolError('invalid_contract');
105
+ // No answered_by claim: the server derives/audits the authenticated human actor.
106
+ return this.command(selected, receipt, `/human/${waitId}/answer`, key, { api_version: 1, input: { approved } });
107
+ }
108
+ private async command(selected: FlowToolCatalogEntryV1, receipt: FlowToolRunV1, suffix: string, key: string, body: unknown) {
109
+ const entry = parseFlowToolEntry(selected), run = parseFlowToolRun(receipt, entry);
110
+ flowToolOperationKey(key);
111
+ return parseFlowToolRun(await this.transport.request({
112
+ method: 'POST', path: `${run.status_url}${suffix}`, idempotencyKey: key, body,
113
+ }), entry, run);
114
+ }
115
+ }
@@ -0,0 +1,153 @@
1
+ import type { FlowToolDigest, FlowToolManifestV1 } from './flow-tool-manifest.js';
2
+ import type { FlowToolJson, FlowToolObjectSchema } from './flow-tool-schema.js';
3
+ import type { Spend } from './run-state.js';
4
+ import { snapshotJsonValue } from './json-value.js';
5
+ import { FLOW_TOOL_LIMITS } from './flow-tool-schema.js';
6
+
7
+ export const FLOW_TOOL_API_VERSION = 1 as const;
8
+ export const FLOW_TOOL_TERMINAL_REASONS = [
9
+ 'success', 'gate_failed', 'model_failed', 'agent_failed', 'budget_exceeded',
10
+ 'human_rejected', 'human_timeout', 'canceled', 'execution_failed',
11
+ ] as const;
12
+ export type FlowToolTerminalReason = typeof FLOW_TOOL_TERMINAL_REASONS[number];
13
+ export type FlowToolRunState = 'accepted' | 'running' | 'parked' | 'cancelling' | 'completed' | 'failed' | 'cancelled';
14
+
15
+ /** Operator-owned, server-authorized read-only revision; never generated from run input. */
16
+ export interface FlowToolCatalogEntryV1 {
17
+ readonly manifest: FlowToolManifestV1;
18
+ readonly deployment_id: string;
19
+ readonly read_only: true;
20
+ readonly effects: readonly string[];
21
+ readonly requires_human: readonly string[];
22
+ readonly business_verdicts: readonly string[];
23
+ readonly budget: Readonly<{ max_tokens: number; max_dollars: string | null; max_wallclock_ms: number }>;
24
+ }
25
+ export interface FlowToolCatalogV1 {
26
+ readonly api_version: 1;
27
+ readonly tools: readonly FlowToolCatalogEntryV1[];
28
+ }
29
+ export interface FlowToolEvidenceV1 {
30
+ readonly journal_digest: FlowToolDigest;
31
+ readonly flow_digest: FlowToolDigest;
32
+ readonly artifacts: readonly Readonly<{ name: string; ref: string; media_type: string }>[];
33
+ readonly redacted_transcript_refs: readonly string[];
34
+ }
35
+ export interface FlowToolTerminalV1 {
36
+ readonly terminal_reason: FlowToolTerminalReason;
37
+ readonly business_verdict: string | null;
38
+ readonly result: Readonly<Record<string, FlowToolJson>> | null;
39
+ readonly gates: readonly Readonly<{ name: string; status: 'pass' | 'fail' | 'skipped'; evidence_ref: string }>[];
40
+ readonly evidence: FlowToolEvidenceV1;
41
+ readonly spend: Readonly<Spend>;
42
+ }
43
+ /** Accepted is durable admission, never successful execution; terminal is null until finished. */
44
+ export interface FlowToolRunV1 {
45
+ readonly api_version: 1;
46
+ readonly accepted: true;
47
+ readonly run_id: string;
48
+ readonly tool_name: string;
49
+ readonly deployment_id: string;
50
+ readonly manifest_digest: FlowToolDigest;
51
+ readonly flow_digest: FlowToolDigest;
52
+ readonly input_digest: FlowToolDigest;
53
+ readonly state: FlowToolRunState;
54
+ readonly sequence: number;
55
+ readonly status_url: string;
56
+ readonly events_url: string;
57
+ readonly evidence_url: string;
58
+ readonly cancel_url: string;
59
+ readonly resume_url: string;
60
+ readonly terminal: FlowToolTerminalV1 | null;
61
+ }
62
+ export interface FlowToolEventV1 {
63
+ readonly api_version: 1;
64
+ readonly run_id: string;
65
+ readonly flow_digest: FlowToolDigest;
66
+ readonly sequence: number;
67
+ readonly type: 'run.accepted' | 'run.state_changed' | 'step.completed' | 'human.required' | 'run.terminal';
68
+ readonly state: FlowToolRunState;
69
+ readonly step_id?: string;
70
+ readonly wait_id?: string;
71
+ readonly terminal_reason?: FlowToolTerminalReason;
72
+ }
73
+ export interface FlowToolInvokeRequestV1 {
74
+ readonly api_version: 1;
75
+ readonly flow: string;
76
+ readonly deployment_id: string;
77
+ readonly manifest_digest: FlowToolDigest;
78
+ readonly input: Readonly<Record<string, FlowToolJson>>;
79
+ /** Assertion only: admission MUST recompute this from schema-validated canonical input. */
80
+ readonly input_digest: FlowToolDigest;
81
+ readonly mode: 'async' | 'sync';
82
+ readonly wait_ms: number;
83
+ }
84
+ export type FlowToolFailureCode = 'invalid_contract' | 'not_authorized' | 'not_found'
85
+ | 'idempotency_conflict' | 'unsupported' | 'unavailable' | 'transport_error';
86
+ export class FlowToolError extends Error {
87
+ constructor(readonly code: FlowToolFailureCode) {
88
+ // Never interpolate remote bodies, model input, bearer tokens or transport errors.
89
+ super(`Flow Tool ${code}${code === 'transport_error' ? ': admission or command outcome may be unknown; observation failure does not cancel a run' : ''}`);
90
+ this.name = 'FlowToolError';
91
+ }
92
+ }
93
+
94
+ const text = { type: 'string', minLength: 1, maxLength: 128 };
95
+ const digest = { type: 'string', minLength: 71, maxLength: 71 };
96
+ const integer = { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER };
97
+ const positive = { ...integer, minimum: 1 };
98
+ const strings = { type: 'array', items: text, maxItems: 64, uniqueItems: true };
99
+ const state = { enum: ['accepted', 'running', 'parked', 'cancelling', 'completed', 'failed', 'cancelled'] };
100
+ function object(properties: Record<string, unknown>, required = Object.keys(properties)) {
101
+ return snapshotJsonValue({ type: 'object', properties, required, additionalProperties: false },
102
+ 'flow tool protocol schema', FLOW_TOOL_LIMITS) as FlowToolObjectSchema;
103
+ }
104
+ export const FLOW_TOOL_EVIDENCE_SCHEMA = object({
105
+ journal_digest: digest, flow_digest: digest,
106
+ artifacts: { type: 'array', maxItems: 128, items: object({ name: text, ref: text, media_type: text }) },
107
+ redacted_transcript_refs: strings,
108
+ }) as FlowToolObjectSchema;
109
+ const terminal = object({
110
+ terminal_reason: { enum: FLOW_TOOL_TERMINAL_REASONS },
111
+ business_verdict: { type: ['string', 'null'], minLength: 1, maxLength: 128 },
112
+ result: { type: ['object', 'null'] },
113
+ gates: { type: 'array', maxItems: 128, items: object({ name: text, status: { enum: ['pass', 'fail', 'skipped'] }, evidence_ref: text }) },
114
+ evidence: FLOW_TOOL_EVIDENCE_SCHEMA,
115
+ spend: object({ tokens_in: integer, tokens_out: integer, dollars: text, dollars_unmetered: { type: 'boolean' } }),
116
+ });
117
+ /** Async adapters return this envelope, NOT the flow's eventual business-result schema. */
118
+ export const FLOW_TOOL_RUN_SCHEMA = object({
119
+ api_version: { const: 1 }, accepted: { const: true }, run_id: text, tool_name: text,
120
+ deployment_id: text, manifest_digest: digest, flow_digest: digest, input_digest: digest,
121
+ state, sequence: integer,
122
+ status_url: { type: 'string' }, events_url: { type: 'string' }, evidence_url: { type: 'string' },
123
+ cancel_url: { type: 'string' }, resume_url: { type: 'string' },
124
+ terminal: { anyOf: [{ type: 'null' }, terminal] },
125
+ }) as FlowToolObjectSchema;
126
+ export const FLOW_TOOL_CATALOG_SCHEMA = object({
127
+ api_version: { const: 1 },
128
+ tools: { type: 'array', maxItems: 64, items: object({
129
+ manifest: { type: 'object' }, deployment_id: text, read_only: { const: true },
130
+ effects: strings, requires_human: strings,
131
+ business_verdicts: { ...strings, minItems: 1 },
132
+ budget: object({ max_tokens: positive, max_dollars: { type: ['string', 'null'] }, max_wallclock_ms: positive }),
133
+ }) },
134
+ }) as FlowToolObjectSchema;
135
+ export const FLOW_TOOL_EVENT_SCHEMA = object({
136
+ api_version: { const: 1 }, run_id: text, flow_digest: digest, sequence: positive,
137
+ type: { enum: ['run.accepted', 'run.state_changed', 'step.completed', 'human.required', 'run.terminal'] },
138
+ state, step_id: text, wait_id: text, terminal_reason: { enum: FLOW_TOOL_TERMINAL_REASONS },
139
+ }, ['api_version', 'run_id', 'flow_digest', 'sequence', 'type', 'state']) as FlowToolObjectSchema;
140
+
141
+ export const FLOW_TOOL_INVOKE_SCHEMA = object({
142
+ api_version: { const: 1 }, flow: { type: 'string', minLength: 73, maxLength: 200 },
143
+ deployment_id: text, manifest_digest: digest, input_digest: digest,
144
+ input: { type: 'object' }, mode: { enum: ['async', 'sync'] },
145
+ wait_ms: { type: 'integer', minimum: 0, maximum: 25000 },
146
+ }) as FlowToolObjectSchema;
147
+ export const FLOW_TOOL_COMMAND_SCHEMA = object({ api_version: { const: 1 } }) as FlowToolObjectSchema;
148
+ export const FLOW_TOOL_ANSWER_SCHEMA = object({
149
+ api_version: { const: 1 }, input: object({ approved: { type: 'boolean' } }),
150
+ }) as FlowToolObjectSchema;
151
+ export const FLOW_TOOL_ERROR_SCHEMA = object({
152
+ api_version: { const: 1 }, code: { enum: ['invalid_contract', 'not_authorized', 'not_found', 'idempotency_conflict', 'unsupported', 'unavailable'] },
153
+ }) as FlowToolObjectSchema;
@@ -0,0 +1,27 @@
1
+ import { parseFlowToolManifest, type FlowToolManifestV1 } from './flow-tool-manifest.js';
2
+ import type { FlowToolObjectSchema } from './flow-tool-schema.js';
3
+
4
+ export interface FlowToolFunctionDefinition {
5
+ readonly type: 'function';
6
+ readonly name: string;
7
+ readonly description: string;
8
+ readonly parameters: FlowToolObjectSchema;
9
+ }
10
+ export interface FlowToolMcpDefinition {
11
+ readonly name: string;
12
+ readonly description: string;
13
+ readonly inputSchema: FlowToolObjectSchema;
14
+ readonly outputSchema: FlowToolObjectSchema;
15
+ }
16
+
17
+ /** Plain function-call descriptor. Provider-specific strict-mode translation is not implied. */
18
+ export function flowToolFunctionDefinition(value: FlowToolManifestV1): FlowToolFunctionDefinition {
19
+ const manifest = parseFlowToolManifest(value);
20
+ return Object.freeze({ type: 'function', name: manifest.name, description: manifest.description, parameters: manifest.inputSchema });
21
+ }
22
+
23
+ /** MCP tools/list metadata only: no server, call handler, authority, or idempotency claim. */
24
+ export function flowToolMcpDefinition(value: FlowToolManifestV1): FlowToolMcpDefinition {
25
+ const manifest = parseFlowToolManifest(value);
26
+ return Object.freeze({ name: manifest.name, description: manifest.description, inputSchema: manifest.inputSchema, outputSchema: manifest.resultSchema });
27
+ }
@@ -0,0 +1,134 @@
1
+ import { canonicalize } from './canonical.js';
2
+ import { cloudConnection } from './cloud-http.js';
3
+ import { FlowToolError } from './flow-tool-contract.js';
4
+ import { flowToolOperationKey, type FlowToolRequest, type FlowToolTransport } from './flow-tool-client.js';
5
+ import { FLOW_TOOL_ENVELOPE_LIMITS } from './flow-tool-wire.js';
6
+
7
+ export interface FlowToolHttpOptions {
8
+ /** Explicit trusted HTTPS API origin/base path. No implicit production endpoint. */
9
+ readonly apiUrl: string;
10
+ /** Dedicated scoped bearer; never included in definitions, bodies, errors or persisted state. */
11
+ readonly token: string;
12
+ readonly requestTimeoutMs?: number;
13
+ readonly signal?: AbortSignal;
14
+ /** Test/integration seam; the default is the standard fetch implementation. */
15
+ readonly fetch?: typeof fetch;
16
+ }
17
+
18
+ function pathIsSafe(path: string): boolean {
19
+ return /^\/api\/v1\/flow-tools(?:\/[A-Za-z_][A-Za-z0-9_-]{0,63}\/invoke)?$/.test(path)
20
+ || /^\/api\/v1\/flow-runs\/[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}(?:\/(?:events|evidence|cancel|resume)|\/human\/[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\/answer)?$/.test(path);
21
+ }
22
+ async function* chunks(response: Response, maximumBytes: number): AsyncIterable<string> {
23
+ if (!response.body) throw new FlowToolError('invalid_contract');
24
+ const reader = response.body.getReader(), decoder = new TextDecoder('utf-8', { fatal: true });
25
+ let size = 0;
26
+ try {
27
+ while (true) {
28
+ const chunk = await reader.read();
29
+ if (chunk.done) break;
30
+ size += chunk.value.byteLength;
31
+ if (size > maximumBytes) throw new FlowToolError('invalid_contract');
32
+ yield decoder.decode(chunk.value, { stream: true });
33
+ }
34
+ yield decoder.decode();
35
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
36
+ }
37
+ function refuse(status: number): never {
38
+ const code = status === 400 || status === 422 ? 'invalid_contract' : status === 401 || status === 403 ? 'not_authorized' : status === 404 ? 'not_found'
39
+ : status === 409 ? 'idempotency_conflict' : status === 501 ? 'unsupported' : 'unavailable';
40
+ throw new FlowToolError(code);
41
+ }
42
+ function safeError(error: unknown): never {
43
+ if (error instanceof FlowToolError) throw error;
44
+ throw new FlowToolError('transport_error');
45
+ }
46
+
47
+ /** No automatic retry; the owner can repeat the same operation key after an ambiguous failure. */
48
+ export function createFlowToolHttpTransport(options: FlowToolHttpOptions): FlowToolTransport {
49
+ // Reuse existing HTTPS/token validation, but never fall back to its ambient login store.
50
+ if (typeof options.apiUrl !== 'string' || typeof options.token !== 'string') throw new FlowToolError('invalid_contract');
51
+ let connection: { baseUrl: string; token: string };
52
+ try { connection = cloudConnection({ apiUrl: options.apiUrl, token: options.token }); }
53
+ catch { throw new FlowToolError('not_authorized'); }
54
+ const timeout = options.requestTimeoutMs ?? 30_000;
55
+ if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 120_000) throw new FlowToolError('invalid_contract');
56
+ const fetcher = options.fetch ?? globalThis.fetch;
57
+ async function response(path: string, init: RequestInit, accept: string): Promise<Response> {
58
+ if (!pathIsSafe(path)) throw new FlowToolError('invalid_contract');
59
+ const signal = AbortSignal.any([AbortSignal.timeout(timeout), ...(options.signal ? [options.signal] : [])]);
60
+ const result = await fetcher(`${connection.baseUrl}${path}`, {
61
+ ...init, redirect: 'error', signal,
62
+ headers: { ...init.headers, Authorization: `Bearer ${connection.token}`, Accept: accept },
63
+ });
64
+ if (!result.ok) { await result.body?.cancel(); refuse(result.status); }
65
+ if (result.headers.get('content-type')?.toLowerCase().split(';')[0]?.trim() !== accept) {
66
+ await result.body?.cancel(); throw new FlowToolError('invalid_contract');
67
+ }
68
+ return result;
69
+ }
70
+ return {
71
+ async request(request: FlowToolRequest): Promise<unknown> {
72
+ try {
73
+ if (request.idempotencyKey !== undefined) flowToolOperationKey(request.idempotencyKey);
74
+ if (request.method === 'POST' && request.idempotencyKey === undefined) throw new FlowToolError('invalid_contract');
75
+ const result = await response(request.path, {
76
+ method: request.method,
77
+ headers: { ...(request.idempotencyKey === undefined ? {} : { 'Idempotency-Key': request.idempotencyKey }), 'Content-Type': 'application/json' },
78
+ ...(request.body === undefined ? {} : { body: canonicalize(request.body) }),
79
+ }, 'application/json');
80
+ let body = '';
81
+ for await (const chunk of chunks(result, FLOW_TOOL_ENVELOPE_LIMITS.maxBytes)) body += chunk;
82
+ try { return JSON.parse(body); } catch { throw new FlowToolError('invalid_contract'); }
83
+ } catch (error) { return safeError(error); }
84
+ },
85
+ async *events(path: string, after: number): AsyncIterable<unknown> {
86
+ try {
87
+ if (!path.endsWith('/events') || !Number.isSafeInteger(after) || after < 0) throw new FlowToolError('invalid_contract');
88
+ const result = await response(path, { method: 'GET', headers: { 'Last-Event-ID': String(after) } }, 'text/event-stream');
89
+ let buffer = '', frame: string[] = [], frameSize = 0;
90
+ // Each bounded subscription may reconnect with its last validated cursor.
91
+ for await (const chunk of chunks(result, 4 * 1024 * 1024)) {
92
+ buffer += chunk;
93
+ let newline: number;
94
+ while ((newline = buffer.indexOf('\n')) >= 0) {
95
+ const line = buffer.slice(0, newline).replace(/\r$/, '');
96
+ buffer = buffer.slice(newline + 1);
97
+ frameSize += line.length;
98
+ if (frameSize > 65536) throw new FlowToolError('invalid_contract');
99
+ if (line === '') {
100
+ const event = parseFrame(frame);
101
+ frame = []; frameSize = 0;
102
+ if (event !== undefined) yield event;
103
+ } else frame.push(line);
104
+ }
105
+ if (buffer.length > 65536) throw new FlowToolError('invalid_contract');
106
+ }
107
+ if (buffer.length || frame.some(line => !line.startsWith(':'))) throw new FlowToolError('invalid_contract');
108
+ } catch (error) { safeError(error); }
109
+ },
110
+ };
111
+ }
112
+
113
+ function parseFrame(lines: string[]): unknown {
114
+ const data: string[] = [];
115
+ let id: string | undefined, name: string | undefined;
116
+ for (const line of lines) {
117
+ if (line.startsWith(':')) continue;
118
+ const separator = line.indexOf(':');
119
+ const field = separator < 0 ? line : line.slice(0, separator);
120
+ const value = separator < 0 ? '' : line.slice(separator + 1).replace(/^ /, '');
121
+ if (field === 'data') data.push(value);
122
+ else if (field === 'id' && id === undefined) id = value;
123
+ else if (field === 'event' && name === undefined) name = value;
124
+ else throw new FlowToolError('invalid_contract');
125
+ }
126
+ if (data.length === 0 && id === undefined && name === undefined) return undefined;
127
+ if (id === undefined || !/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id))) throw new FlowToolError('invalid_contract');
128
+ let parsed: unknown;
129
+ try { parsed = JSON.parse(data.join('\n')); } catch { throw new FlowToolError('invalid_contract'); }
130
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
131
+ || (parsed as Record<string, unknown>).sequence !== Number(id)
132
+ || (parsed as Record<string, unknown>).type !== name) throw new FlowToolError('invalid_contract');
133
+ return parsed;
134
+ }