@relayflows/sdk 2.0.44 → 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/journal-client.d.ts +7 -0
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +22 -1
- package/dist/journal-client.js.map +1 -1
- package/dist/protocol.d.ts +7 -0
- package/dist/protocol.d.ts.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/journal-client.ts +21 -1
- package/src/protocol.ts +7 -0
- package/src/yaml-helper-effect.ts +12 -5
package/src/cli/direct-run.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resumeCommand } from '../authored-human.js';
|
|
1
2
|
import { completedResultUnreadableReport, daemonUnresponsiveReport, isReadInterruption } from './journal-timeout.js';
|
|
2
3
|
import { FLOW_READ_BUDGET_MS } from '../journal-read-policy.js';
|
|
3
4
|
import { onWorkerFailure } from '../worker-lease.js';
|
|
@@ -179,6 +180,12 @@ export async function runDirectFlow(
|
|
|
179
180
|
},
|
|
180
181
|
};
|
|
181
182
|
}
|
|
183
|
+
if (error instanceof AuthoredFlowExecutionError && error.code === 'helper_writeback_pending') {
|
|
184
|
+
return { exitCode: 3, report: { ...base, ok: false,
|
|
185
|
+
runId: error.rootRunId ?? error.runId, rootRunId: error.rootRunId, socketPath, status: 'parked',
|
|
186
|
+
diagnostics: [...base.diagnostics, { severity: 'parked', kind: error.code,
|
|
187
|
+
message: error.message + ` Continue with: ${resumeCommand(error.rootRunId ?? error.runId!, dataDir, options.localAgent === true)}.` }] } };
|
|
188
|
+
}
|
|
182
189
|
if (error instanceof AuthoredFlowExecutionError && (error.code === 'agent_parked' || error.code === 'llm_parked')) {
|
|
183
190
|
return {
|
|
184
191
|
exitCode: 3,
|
package/src/cli/run.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { attachReuseSummary } from './reuse.js';
|
|
|
12
12
|
import { resumeHelperEffect } from '../authored-helper-effect.js';
|
|
13
13
|
import { AuthoredFlowExecutionError, AuthoredHumanParked, type AuthoredHumanWait } from '../authored-flow-error.js';
|
|
14
14
|
import { answerCommand, resumeCommand } from '../authored-human.js';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
15
16
|
import { join, resolve } from 'node:path';
|
|
16
17
|
import type { ProgressEvent } from '../progress.js';
|
|
17
18
|
import type { JournalEvent } from '../journal-reader.js';
|
|
@@ -255,7 +256,14 @@ async function executeCheckedFlow(
|
|
|
255
256
|
diagnostics: [...base.diagnostics, { severity: failed ? 'failure' : 'refusal',
|
|
256
257
|
kind: error.code, message: error.message }] } };
|
|
257
258
|
}
|
|
258
|
-
|
|
259
|
+
const cause = communicationWorkers?.failure ?? localAgent?.failure ?? error;
|
|
260
|
+
if (cause instanceof AuthoredFlowExecutionError && cause.code === 'helper_writeback_pending') {
|
|
261
|
+
return { exitCode: 3, report: { ...base, ok: false, runId: cause.rootRunId ?? cause.runId ?? base.runId,
|
|
262
|
+
rootRunId: cause.rootRunId, socketPath, status: 'parked',
|
|
263
|
+
diagnostics: [...base.diagnostics, { severity: 'parked', kind: cause.code,
|
|
264
|
+
message: cause.message + ` Continue with: ${resumeCommand(cause.rootRunId ?? cause.runId ?? base.runId ?? '', dataDir, options.localAgent === true)}.` }] } };
|
|
265
|
+
}
|
|
266
|
+
return protocolFailure('run', base, socketPath, cause);
|
|
259
267
|
} finally {
|
|
260
268
|
if (options.onJournalEntry !== undefined) client.off('entry', options.onJournalEntry);
|
|
261
269
|
try { await communicationWorkers?.close(); } finally { try { await localAgent?.close(); } finally { client.close(); } }
|
|
@@ -364,7 +372,11 @@ export async function resumeFlow(
|
|
|
364
372
|
// slack effect resume plus every other provider from N's codegen. The
|
|
365
373
|
// second call the earlier rebase left is a stale reference from before
|
|
366
374
|
// the helper fanout renamed the API.
|
|
367
|
-
|
|
375
|
+
// A helper child brings its own worker in resumeHelperEffect. A local agent
|
|
376
|
+
// pinned to that stream first receives the park's redispatch and does not
|
|
377
|
+
// understand the authored helper instruction.
|
|
378
|
+
const helperChild = existsSync(join(dataDir, 'helper-runs', `${runId}.json`));
|
|
379
|
+
if (options.localAgent && !helperChild) {
|
|
368
380
|
const entries = (await client.journalRead(runId, 1)).entries as Array<{ entry_type: string; payload?: { spec?: import('../spec.js').KernelRunSpec } }>;
|
|
369
381
|
const spec = entries.find(entry => entry.entry_type === 'run.spawned')?.payload?.spec;
|
|
370
382
|
if (!spec) throw new Error('Cannot attach a local worker: the journaled run spec is missing');
|
|
@@ -384,6 +396,12 @@ export async function resumeFlow(
|
|
|
384
396
|
// the run unprojected, and the resume below reports the run's own fate.
|
|
385
397
|
await client.runWatch(runId).catch(() => client.off('entry', onEntry));
|
|
386
398
|
}
|
|
399
|
+
// A helper child answers its own park after its worker attaches.
|
|
400
|
+
// Emitting here, with no worker, journals the next attempt as a crash.
|
|
401
|
+
if (options.localAgent && !helperChild) {
|
|
402
|
+
const { releaseHelperReceiptWaits } = await import('../helper-park.js');
|
|
403
|
+
await releaseHelperReceiptWaits(client, runId);
|
|
404
|
+
}
|
|
387
405
|
let outcome = await client.runResume(runId, options.allowHumanInfluenced);
|
|
388
406
|
options.onRunReceipt?.({ runId, flow: 'flow' });
|
|
389
407
|
if (await resumeHelperEffect(client, runId, dataDir)) {
|
|
@@ -432,6 +450,16 @@ export async function resumeFlow(
|
|
|
432
450
|
// only honest instruction is a new run carrying the same `--input`.
|
|
433
451
|
// `runId` here is the CHILD run holding the evidence; the root this resume
|
|
434
452
|
// named stays separate, so `flows` can still collect it.
|
|
453
|
+
const pending = error instanceof AuthoredFlowExecutionError && error.code === 'helper_writeback_pending'
|
|
454
|
+
? error
|
|
455
|
+
: authoredAgent?.failure instanceof AuthoredFlowExecutionError && authoredAgent.failure.code === 'helper_writeback_pending'
|
|
456
|
+
? authoredAgent.failure : undefined;
|
|
457
|
+
if (pending !== undefined) {
|
|
458
|
+
return { exitCode: 3, report: { ...base, ok: false,
|
|
459
|
+
runId: pending.rootRunId ?? pending.runId, rootRunId: pending.rootRunId, socketPath, status: 'parked',
|
|
460
|
+
diagnostics: [...base.diagnostics, { severity: 'parked', kind: 'helper_writeback_pending',
|
|
461
|
+
message: pending.message + ` Continue with: ${resumeCommand(pending.rootRunId ?? pending.runId ?? runId, dataDir, options.localAgent === true)}.` }] } };
|
|
462
|
+
}
|
|
435
463
|
if (error instanceof AuthoredFlowExecutionError && (error.code === 'agent_parked' || error.code === 'llm_parked')) {
|
|
436
464
|
return {
|
|
437
465
|
exitCode: 3,
|
package/src/cloud-mirror-step.ts
CHANGED
|
@@ -416,7 +416,12 @@ function finalStep(
|
|
|
416
416
|
const summary = text(verification?.detail, env, OUTPUT_SUMMARY_MAX_CHARS)
|
|
417
417
|
?? `step ${succeeded ? 'completed' : 'ended'}: ${identifier(completionReason, env) ?? 'unknown'}`;
|
|
418
418
|
const model = identifier(transcript?.model, env);
|
|
419
|
-
|
|
419
|
+
// Agent attempts carry a transcript failure excerpt; deterministic timeouts
|
|
420
|
+
// do not. Their execution failure is journaled in verification.detail, so
|
|
421
|
+
// fall back to that same record for a timeout instead of publishing a failed
|
|
422
|
+
// dashboard row with an empty error column.
|
|
423
|
+
const error = succeeded ? undefined : text(failure?.excerpt, env, ERROR_MAX_CHARS)
|
|
424
|
+
?? (completionReason === 'timeout' ? text(verification?.detail, env, ERROR_MAX_CHARS) : undefined);
|
|
420
425
|
return {
|
|
421
426
|
stepName,
|
|
422
427
|
stepType,
|
package/src/compile.ts
CHANGED
|
@@ -75,6 +75,7 @@ export class CompileError extends Error {
|
|
|
75
75
|
* being rejected by `isSafeInteger`.
|
|
76
76
|
*/
|
|
77
77
|
export const AGENT_STEP_TIMEOUT_MAX_MS = 60 * 60_000;
|
|
78
|
+
export const RUN_STEP_TIMEOUT_MAX_MS = 15 * 60_000;
|
|
78
79
|
export function parseAgentStepTimeout(timeout: unknown): number {
|
|
79
80
|
return parseStepTimeout(timeout, true);
|
|
80
81
|
}
|
|
@@ -103,7 +104,7 @@ export function parseStepTimeout(timeout: unknown, agent = false): number {
|
|
|
103
104
|
if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
|
|
104
105
|
throw new CompileError([`${agent ? 'f.agent' : 'f.run'} timeout must be a positive whole number of milliseconds or a duration such as "10s" or "5m".`], 'timeout_invalid');
|
|
105
106
|
}
|
|
106
|
-
if (milliseconds > (agent ? AGENT_STEP_TIMEOUT_MAX_MS :
|
|
107
|
+
if (milliseconds > (agent ? AGENT_STEP_TIMEOUT_MAX_MS : RUN_STEP_TIMEOUT_MAX_MS)) {
|
|
107
108
|
throw new CompileError([`${agent ? 'f.agent' : 'f.run'} timeout exceeds the maximum of ${agent ? 60 : 15} minutes declared in SURFACE.md.`], 'lease_exceeded');
|
|
108
109
|
}
|
|
109
110
|
return milliseconds;
|
package/src/failure-kinds.ts
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { canonicalize } from './canonical.js';
|
|
2
|
+
import { FlowToolClient, type FlowToolInvocationOptions } from './flow-tool-client.js';
|
|
3
|
+
import { flowToolFunctionDefinition } from './flow-tool-definitions.js';
|
|
4
|
+
import { FLOW_TOOL_RUN_SCHEMA, type FlowToolCatalogEntryV1 } from './flow-tool-contract.js';
|
|
5
|
+
import { parseFlowToolEntry } from './flow-tool-wire.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One selected, authorized revision; no alternate execution path. Host/session
|
|
9
|
+
* code supplies stable operation metadata separately from model tool arguments.
|
|
10
|
+
* Register only after discovery with this same authenticated client. The server
|
|
11
|
+
* MUST reauthorize; possession of this object is not an authorization token.
|
|
12
|
+
*/
|
|
13
|
+
export function createFlowToolAdapters(client: FlowToolClient, selected: FlowToolCatalogEntryV1) {
|
|
14
|
+
const entry = parseFlowToolEntry(selected);
|
|
15
|
+
const invoke = (input: unknown, operation: FlowToolInvocationOptions) => client.invoke(entry, input, operation);
|
|
16
|
+
return Object.freeze({
|
|
17
|
+
native: Object.freeze({ definition: flowToolFunctionDefinition(entry.manifest), call: invoke }),
|
|
18
|
+
mcp: Object.freeze({
|
|
19
|
+
// Do not reuse the legacy business-result-only metadata descriptor here:
|
|
20
|
+
// tools/call returns an async run envelope, not the eventual result object.
|
|
21
|
+
definition: Object.freeze({
|
|
22
|
+
name: entry.manifest.name, description: entry.manifest.description,
|
|
23
|
+
inputSchema: entry.manifest.inputSchema, outputSchema: FLOW_TOOL_RUN_SCHEMA,
|
|
24
|
+
}),
|
|
25
|
+
async call(input: unknown, operation: FlowToolInvocationOptions) {
|
|
26
|
+
const run = await invoke(input, operation);
|
|
27
|
+
return {
|
|
28
|
+
structuredContent: Object.freeze({ ...run }),
|
|
29
|
+
content: [{ type: 'text' as const, text: canonicalize(run) }],
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
// Relay action registration can use this schema/handler. An action dispatch
|
|
34
|
+
// acknowledgment is not this return value and must never become completion.
|
|
35
|
+
action: Object.freeze({
|
|
36
|
+
definition: Object.freeze({
|
|
37
|
+
name: entry.manifest.name, description: entry.manifest.description,
|
|
38
|
+
inputSchema: entry.manifest.inputSchema, outputSchema: FLOW_TOOL_RUN_SCHEMA,
|
|
39
|
+
}),
|
|
40
|
+
invoke,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -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
|
+
}
|