@relayflows/sdk 2.0.34 → 2.0.36
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/adapters/base.d.ts +16 -0
- package/dist/adapters/base.d.ts.map +1 -1
- package/dist/adapters/claude.d.ts.map +1 -1
- package/dist/adapters/claude.js +15 -0
- package/dist/adapters/claude.js.map +1 -1
- package/dist/adapters/codex.d.ts.map +1 -1
- package/dist/adapters/codex.js +4 -0
- package/dist/adapters/codex.js.map +1 -1
- package/dist/adapters/usage-limit.d.ts +2 -0
- package/dist/adapters/usage-limit.d.ts.map +1 -0
- package/dist/adapters/usage-limit.js +17 -0
- package/dist/adapters/usage-limit.js.map +1 -0
- package/dist/authored-activity.d.ts +17 -0
- package/dist/authored-activity.d.ts.map +1 -0
- package/dist/authored-activity.js +218 -0
- package/dist/authored-activity.js.map +1 -0
- package/dist/authored-child-run.d.ts +34 -0
- package/dist/authored-child-run.d.ts.map +1 -0
- package/dist/authored-child-run.js +128 -0
- package/dist/authored-child-run.js.map +1 -0
- package/dist/authored-dispatch.d.ts +24 -0
- package/dist/authored-dispatch.d.ts.map +1 -0
- package/dist/authored-dispatch.js +81 -0
- package/dist/authored-dispatch.js.map +1 -0
- package/dist/authored-flow-error.d.ts +32 -3
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js +3 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts +26 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +122 -16
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-loader.d.ts.map +1 -1
- package/dist/authored-flow-loader.js +9 -0
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-mcp.js +2 -2
- package/dist/authored-mcp.js.map +1 -1
- package/dist/authored-node-entry.js +4 -1
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +40 -8
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-plugin-effect.js +2 -2
- package/dist/authored-plugin-effect.js.map +1 -1
- package/dist/authored-root.d.ts +10 -7
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +20 -0
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-step-output.d.ts +24 -8
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +81 -15
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-subscription-park.d.ts +5 -0
- package/dist/authored-subscription-park.d.ts.map +1 -0
- package/dist/authored-subscription-park.js +47 -0
- package/dist/authored-subscription-park.js.map +1 -0
- package/dist/authored-worker-step.d.ts +3 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +25 -9
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check-activities.d.ts +8 -0
- package/dist/cli/check-activities.d.ts.map +1 -0
- package/dist/cli/check-activities.js +155 -0
- package/dist/cli/check-activities.js.map +1 -0
- package/dist/cli/cli-probe.d.ts.map +1 -1
- package/dist/cli/cli-probe.js +14 -2
- package/dist/cli/cli-probe.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +10 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +23 -5
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +32 -5
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/subscription-report.d.ts +5 -0
- package/dist/cli/subscription-report.d.ts.map +1 -0
- package/dist/cli/subscription-report.js +48 -0
- package/dist/cli/subscription-report.js.map +1 -0
- package/dist/cli-adapter.d.ts +4 -2
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +4 -0
- package/dist/cli-adapter.js.map +1 -1
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +16 -5
- package/dist/cli.js.map +1 -1
- package/dist/cloud-mirror-step.d.ts +2 -0
- package/dist/cloud-mirror-step.d.ts.map +1 -1
- package/dist/cloud-mirror-step.js +9 -1
- package/dist/cloud-mirror-step.js.map +1 -1
- package/dist/cloud-mirror.d.ts.map +1 -1
- package/dist/cloud-mirror.js +17 -2
- package/dist/cloud-mirror.js.map +1 -1
- package/dist/failure-kinds.d.ts +2 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +2 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/journal-client.d.ts +17 -2
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +33 -2
- package/dist/journal-client.js.map +1 -1
- package/dist/journal-reader.d.ts.map +1 -1
- package/dist/journal-reader.js +2 -0
- package/dist/journal-reader.js.map +1 -1
- package/dist/local-agent.d.ts +4 -1
- package/dist/local-agent.d.ts.map +1 -1
- package/dist/local-agent.js +14 -4
- package/dist/local-agent.js.map +1 -1
- package/dist/preflight.d.ts +6 -0
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +36 -10
- package/dist/preflight.js.map +1 -1
- package/dist/protocol.d.ts +166 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/step-settling.d.ts +11 -0
- package/dist/step-settling.d.ts.map +1 -0
- package/dist/step-settling.js +22 -0
- package/dist/step-settling.js.map +1 -0
- package/dist/worker-slots.d.ts +15 -0
- package/dist/worker-slots.d.ts.map +1 -1
- package/dist/worker-slots.js +40 -3
- package/dist/worker-slots.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +19 -0
- package/src/adapters/claude.ts +15 -0
- package/src/adapters/codex.ts +6 -0
- package/src/adapters/usage-limit.ts +17 -0
- package/src/authored-activity.ts +251 -0
- package/src/authored-child-run.ts +138 -0
- package/src/authored-dispatch.ts +113 -0
- package/src/authored-flow-error.ts +36 -0
- package/src/authored-flow-executor.ts +166 -15
- package/src/authored-flow-loader.ts +12 -0
- package/src/authored-mcp.ts +2 -2
- package/src/authored-node-entry.ts +4 -1
- package/src/authored-node-runner.ts +41 -8
- package/src/authored-plugin-effect.ts +2 -2
- package/src/authored-root.ts +28 -4
- package/src/authored-step-output.ts +88 -15
- package/src/authored-subscription-park.ts +46 -0
- package/src/authored-worker-step.ts +24 -8
- package/src/cli/check-activities.ts +144 -0
- package/src/cli/cli-probe.ts +15 -3
- package/src/cli/direct-run.ts +9 -0
- package/src/cli/run.ts +56 -9
- package/src/cli/subscription-report.ts +51 -0
- package/src/cli-adapter.ts +8 -2
- package/src/cli.ts +16 -6
- package/src/cloud-mirror-step.ts +16 -2
- package/src/cloud-mirror.ts +15 -2
- package/src/failure-kinds.ts +2 -0
- package/src/index.ts +6 -0
- package/src/journal-client.ts +47 -3
- package/src/journal-reader.ts +2 -0
- package/src/local-agent.ts +17 -3
- package/src/preflight.ts +46 -10
- package/src/protocol.ts +129 -0
- package/src/step-settling.ts +19 -0
- package/src/worker-slots.ts +63 -4
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Activity,
|
|
3
|
+
ActivityDuration,
|
|
4
|
+
ActivityOptions,
|
|
5
|
+
EventFrame,
|
|
6
|
+
TriggerSource,
|
|
7
|
+
WebhookTriggerSource,
|
|
8
|
+
Wake,
|
|
9
|
+
} from '@relayflows/surface';
|
|
10
|
+
import type { SubscriptionNextResult } from './protocol.js';
|
|
11
|
+
import { AuthoredFlowExecutionError, type AuthoredFlowSuspension } from './authored-flow-error.js';
|
|
12
|
+
import { JournalClient } from './journal-client.js';
|
|
13
|
+
|
|
14
|
+
type CloseReason = 'closed' | 'run_completed' | 'canceled';
|
|
15
|
+
|
|
16
|
+
interface OpenActivity {
|
|
17
|
+
readonly activity: Activity;
|
|
18
|
+
close(reason: CloseReason): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Owns body-local cursors that must close before the authored root completes. */
|
|
22
|
+
export class AuthoredActivities {
|
|
23
|
+
private readonly activities: OpenActivity[] = [];
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly journal: JournalClient,
|
|
27
|
+
private readonly runId: string | undefined,
|
|
28
|
+
) {}
|
|
29
|
+
|
|
30
|
+
open(source: TriggerSource, options: ActivityOptions): Activity {
|
|
31
|
+
if (source?.kind !== 'webhook') {
|
|
32
|
+
throw new AuthoredFlowExecutionError('unsupported_header',
|
|
33
|
+
'f.on() requires an event source; schedule triggers start runs and cannot be awaited inside a body');
|
|
34
|
+
}
|
|
35
|
+
if (this.runId === undefined) {
|
|
36
|
+
throw new AuthoredFlowExecutionError('journal_protocol_violation', 'f.on() requires a durable authored root run');
|
|
37
|
+
}
|
|
38
|
+
const id = `activity-${this.activities.length + 1}`;
|
|
39
|
+
const activity = new JournalActivity(this.journal, this.runId, id, source, normalizeOptions(options));
|
|
40
|
+
this.activities.push(activity);
|
|
41
|
+
return activity.activity;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async closeAll(reason: Exclude<CloseReason, 'closed'>): Promise<void> {
|
|
45
|
+
for (const activity of this.activities) await activity.close(reason);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class JournalActivity implements OpenActivity {
|
|
50
|
+
readonly activity: Activity;
|
|
51
|
+
private readonly openPromise: Promise<void>;
|
|
52
|
+
private closed = false;
|
|
53
|
+
private nextSequence = 0;
|
|
54
|
+
private acknowledgeWaitId: string | undefined;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly journal: JournalClient,
|
|
58
|
+
private readonly runId: string,
|
|
59
|
+
private readonly subscriptionId: string,
|
|
60
|
+
private readonly source: WebhookTriggerSource,
|
|
61
|
+
private readonly options: NormalizedActivityOptions,
|
|
62
|
+
) {
|
|
63
|
+
this.activity = Object.freeze({
|
|
64
|
+
next: () => this.next(),
|
|
65
|
+
close: () => this.close('closed'),
|
|
66
|
+
});
|
|
67
|
+
// Opening starts at f.on(), rather than at the first next(), so frames
|
|
68
|
+
// arriving while the body reads state or runs another step are inside the
|
|
69
|
+
// router's binding window. next()/close() await this same handshake.
|
|
70
|
+
this.openPromise = this.openNow();
|
|
71
|
+
void this.openPromise.catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async close(reason: CloseReason): Promise<void> {
|
|
75
|
+
if (this.closed) return;
|
|
76
|
+
try {
|
|
77
|
+
await this.ensureOpen();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
// Failure cleanup has no active binding to close before activation.
|
|
80
|
+
// Re-throwing this handoff would disguise a real body failure as a
|
|
81
|
+
// normal suspension. Root termination fences any later activation.
|
|
82
|
+
if (reason !== 'closed' && error instanceof AuthoredFlowExecutionError
|
|
83
|
+
&& error.code === 'subscription_suspended' && error.suspension?.kind === 'activation') return;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
await this.journal.subscriptionClose({
|
|
87
|
+
run_id: this.runId,
|
|
88
|
+
subscription_id: this.subscriptionId,
|
|
89
|
+
completion_reason: reason,
|
|
90
|
+
});
|
|
91
|
+
this.closed = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async next(): Promise<Wake> {
|
|
95
|
+
if (this.closed) throw new AuthoredFlowExecutionError('activity_closed', 'activity is already closed');
|
|
96
|
+
await this.ensureOpen();
|
|
97
|
+
const result = await this.journal.subscriptionNext({
|
|
98
|
+
run_id: this.runId,
|
|
99
|
+
subscription_id: this.subscriptionId,
|
|
100
|
+
sequence: this.nextSequence,
|
|
101
|
+
...(this.acknowledgeWaitId === undefined ? {} : { acknowledge_wait_id: this.acknowledgeWaitId }),
|
|
102
|
+
});
|
|
103
|
+
if (result.kind === 'suspended') {
|
|
104
|
+
throw suspended({
|
|
105
|
+
kind: 'event_wait', subscriptionId: result.subscription_id,
|
|
106
|
+
stream: result.stream, deadlineAtMs: result.deadline_at_ms,
|
|
107
|
+
}, this.runId);
|
|
108
|
+
}
|
|
109
|
+
const wake = decodeWake(result);
|
|
110
|
+
if (wake.kind === 'deadline' || wake.kind === 'overflow') {
|
|
111
|
+
this.closed = true;
|
|
112
|
+
} else {
|
|
113
|
+
this.acknowledgeWaitId = receiptId(result) ?? `${this.subscriptionId}/next/${this.nextSequence}`;
|
|
114
|
+
this.nextSequence += 1;
|
|
115
|
+
}
|
|
116
|
+
return wake;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private async ensureOpen(): Promise<void> {
|
|
120
|
+
await this.openPromise;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private async openNow(): Promise<void> {
|
|
124
|
+
const result = await this.journal.subscriptionOpen({
|
|
125
|
+
run_id: this.runId,
|
|
126
|
+
subscription_id: this.subscriptionId,
|
|
127
|
+
event_types: [this.source.name],
|
|
128
|
+
...(this.source.filter === undefined ? {} : { pattern: this.source.filter }),
|
|
129
|
+
settle_ms: this.options.settleMs,
|
|
130
|
+
idle_ms: this.options.idleMs,
|
|
131
|
+
deadline_ms: this.options.deadlineMs,
|
|
132
|
+
include_self: this.options.includeSelf,
|
|
133
|
+
});
|
|
134
|
+
if (result.state === 'prepared') {
|
|
135
|
+
throw suspended({
|
|
136
|
+
kind: 'activation', subscriptionId: result.subscription_id,
|
|
137
|
+
eventTypes: Object.freeze([...result.event_types]),
|
|
138
|
+
...(result.pattern === undefined ? {} : { pattern: Object.freeze({ ...result.pattern }) }),
|
|
139
|
+
stream: result.stream, settleMs: result.settle_ms, idleMs: result.idle_ms,
|
|
140
|
+
deadlineAtMs: result.deadline_at_ms, includeSelf: result.include_self,
|
|
141
|
+
}, this.runId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function suspended(value: AuthoredFlowSuspension, runId: string): AuthoredFlowExecutionError {
|
|
147
|
+
return new AuthoredFlowExecutionError(
|
|
148
|
+
'subscription_suspended',
|
|
149
|
+
`subscription ${value.subscriptionId} is durably suspended for ${value.kind}`,
|
|
150
|
+
undefined,
|
|
151
|
+
runId,
|
|
152
|
+
undefined,
|
|
153
|
+
value,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function receiptId(result: SubscriptionNextResult): string | undefined {
|
|
158
|
+
const receipt = (result as { acknowledge_wait_id?: unknown }).acknowledge_wait_id;
|
|
159
|
+
return typeof receipt === 'string' && receipt.length > 0 ? receipt : undefined;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface NormalizedActivityOptions {
|
|
163
|
+
readonly settleMs: number;
|
|
164
|
+
readonly idleMs: number;
|
|
165
|
+
readonly deadlineMs: number;
|
|
166
|
+
readonly includeSelf: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizeOptions(options: ActivityOptions): NormalizedActivityOptions {
|
|
170
|
+
if (typeof options !== 'object' || options === null || Array.isArray(options)) {
|
|
171
|
+
throw unboundedSubscription('f.on() requires idle and deadline bounds');
|
|
172
|
+
}
|
|
173
|
+
const value = options as unknown as Record<string, unknown>;
|
|
174
|
+
const allowed = new Set(['settle', 'idle', 'deadline', 'includeSelf']);
|
|
175
|
+
for (const key of Object.keys(value)) {
|
|
176
|
+
if (!allowed.has(key)) throw new AuthoredFlowExecutionError('unbounded_subscription', `f.on() has unknown option ${JSON.stringify(key)}`);
|
|
177
|
+
}
|
|
178
|
+
if (!Object.hasOwn(value, 'idle') || !Object.hasOwn(value, 'deadline')) {
|
|
179
|
+
throw unboundedSubscription('f.on() requires both idle and deadline bounds');
|
|
180
|
+
}
|
|
181
|
+
if (value['includeSelf'] !== undefined && typeof value['includeSelf'] !== 'boolean') {
|
|
182
|
+
throw new AuthoredFlowExecutionError('unbounded_subscription', 'f.on() includeSelf must be a boolean');
|
|
183
|
+
}
|
|
184
|
+
return Object.freeze({
|
|
185
|
+
settleMs: parseDuration(value['settle'] as ActivityDuration | undefined, 'settle', true),
|
|
186
|
+
idleMs: parseDuration(value['idle'] as ActivityDuration, 'idle', false),
|
|
187
|
+
deadlineMs: parseDuration(value['deadline'] as ActivityDuration, 'deadline', false),
|
|
188
|
+
includeSelf: value['includeSelf'] === true,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseDuration(value: ActivityDuration | undefined, field: string, zeroAllowed: boolean): number {
|
|
193
|
+
if (value === undefined && zeroAllowed) return 0;
|
|
194
|
+
let milliseconds: number;
|
|
195
|
+
if (typeof value === 'number') milliseconds = value;
|
|
196
|
+
else if (typeof value === 'string') {
|
|
197
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)$/.exec(value);
|
|
198
|
+
const units: Record<string, number> = { ms: 1, s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
199
|
+
const unit = match?.[2];
|
|
200
|
+
milliseconds = match === null || unit === undefined ? NaN : Math.round(Number(match[1]) * units[unit]!);
|
|
201
|
+
} else milliseconds = NaN;
|
|
202
|
+
if (!Number.isSafeInteger(milliseconds) || milliseconds < 0 || (!zeroAllowed && milliseconds === 0)) {
|
|
203
|
+
throw new AuthoredFlowExecutionError(
|
|
204
|
+
'unbounded_subscription',
|
|
205
|
+
`f.on() ${field} must be ${zeroAllowed ? 'a non-negative' : 'a positive'} whole number of milliseconds or a duration such as "72h"`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return milliseconds;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function unboundedSubscription(message: string): AuthoredFlowExecutionError {
|
|
212
|
+
return new AuthoredFlowExecutionError('unbounded_subscription', message);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Decode the wire result at the journal boundary so malformed wakes fail closed. */
|
|
216
|
+
export function decodeWake(value: SubscriptionNextResult): Wake {
|
|
217
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalidWake();
|
|
218
|
+
if (value.kind === 'idle') return Object.freeze({ kind: 'idle' });
|
|
219
|
+
if (value.kind === 'events') {
|
|
220
|
+
if (!Array.isArray(value.events) || !isOffset(value.offset) || !value.events.every(isEventFrame)) return invalidWake();
|
|
221
|
+
return Object.freeze({ kind: 'events', events: Object.freeze([...value.events]) as readonly EventFrame[], offset: value.offset });
|
|
222
|
+
}
|
|
223
|
+
if (value.kind === 'deadline') {
|
|
224
|
+
if (value.pending !== null && !isPending(value.pending)) return invalidWake();
|
|
225
|
+
return Object.freeze({ kind: 'deadline', pending: value.pending === null ? null : Object.freeze({ ...value.pending }) });
|
|
226
|
+
}
|
|
227
|
+
if (value.kind === 'overflow') {
|
|
228
|
+
if (!isOffset(value.retained) || !isOffset(value.bytes) || !isOffset(value.from)) return invalidWake();
|
|
229
|
+
return Object.freeze({ kind: 'overflow', retained: value.retained, bytes: value.bytes, from: value.from });
|
|
230
|
+
}
|
|
231
|
+
return invalidWake();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function isEventFrame(value: unknown): value is EventFrame {
|
|
235
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
236
|
+
&& typeof (value as { type?: unknown }).type === 'string';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isPending(value: unknown): value is { from: number; to: number } {
|
|
240
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
241
|
+
&& isOffset((value as { from?: unknown }).from)
|
|
242
|
+
&& isOffset((value as { to?: unknown }).to);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function isOffset(value: unknown): value is number {
|
|
246
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function invalidWake(): never {
|
|
250
|
+
throw new AuthoredFlowExecutionError('journal_protocol_violation', 'subscription.next returned an invalid wake result');
|
|
251
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { delay, LEASE_SWEEP_GRACE_MS } from './cli/run.js';
|
|
2
|
+
import type { JournalClient } from './journal-client.js';
|
|
3
|
+
import type { RunGetResult, RunStatus } from './protocol.js';
|
|
4
|
+
|
|
5
|
+
/** One `journal.read` page; the kernel caps an unbounded read at 100 entries. */
|
|
6
|
+
const PAGE = 1_000;
|
|
7
|
+
const POLL_MS = 50;
|
|
8
|
+
// A run between two of its own actions can report `running` with nothing in
|
|
9
|
+
// flight for a moment (a wait just completed, the next attempt not yet
|
|
10
|
+
// journaled). The same bound `classifyOutcome` gives that window (#179).
|
|
11
|
+
const MAX_IDLE_POLLS = 40;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every entry of a child run's journal, paged.
|
|
15
|
+
*
|
|
16
|
+
* A single unbounded read returns the first 100 entries only, and a reader
|
|
17
|
+
* that needs the LAST completion of a step cannot stop at a page boundary.
|
|
18
|
+
* A short page is the end of the journal.
|
|
19
|
+
*/
|
|
20
|
+
export async function readChildJournal(journal: JournalClient, runId: string): Promise<unknown[]> {
|
|
21
|
+
const all: unknown[] = [];
|
|
22
|
+
let fromSeq = 1;
|
|
23
|
+
while (true) {
|
|
24
|
+
const { entries } = await journal.journalRead(runId, fromSeq, PAGE);
|
|
25
|
+
all.push(...entries);
|
|
26
|
+
if (entries.length < PAGE) return all;
|
|
27
|
+
const last = entries.at(-1) as { seq?: unknown } | undefined;
|
|
28
|
+
if (typeof last?.seq !== 'number' || !Number.isSafeInteger(last.seq) || last.seq < fromSeq) {
|
|
29
|
+
throw new Error(`invalid journal sequence reading child run "${runId}"`);
|
|
30
|
+
}
|
|
31
|
+
fromSeq = last.seq + 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Wait until an authored operation's child run is terminal, and say whether
|
|
37
|
+
* it got there.
|
|
38
|
+
*
|
|
39
|
+
* A child adopted through its admission key is not necessarily finished: on
|
|
40
|
+
* `flows resume` of a root whose daemon survived, `run.start` returns the
|
|
41
|
+
* existing child's CURRENT outcome without driving it, so the child may still
|
|
42
|
+
* be executing an attempt, or sleeping out a retry backoff, on the request
|
|
43
|
+
* that originally started it. Reading its journal then judges a history that
|
|
44
|
+
* is still being written.
|
|
45
|
+
*
|
|
46
|
+
* The wait is read-only on purpose: it never resumes the child. The kernel
|
|
47
|
+
* drives a deterministic attempt inline on the request that started it, and a
|
|
48
|
+
* second driver would presume that attempt dead and run the command again.
|
|
49
|
+
*
|
|
50
|
+
* Bounded by the child's own journaled deadlines, never a fixed one: a running
|
|
51
|
+
* attempt by its lease (renewed as the lease renews) plus the kernel's
|
|
52
|
+
* lease-sweep grace, a backoff by the `sleep.until` it journaled (once that
|
|
53
|
+
* wake has passed, by the settling window). With nothing in flight, only the
|
|
54
|
+
* short settling window above; a child parked on a worker
|
|
55
|
+
* returns at once. Either way the caller gets the status and decides.
|
|
56
|
+
*/
|
|
57
|
+
export async function waitForTerminalChildRun(
|
|
58
|
+
journal: JournalClient,
|
|
59
|
+
runId: string,
|
|
60
|
+
signal?: AbortSignal,
|
|
61
|
+
): Promise<RunStatus> {
|
|
62
|
+
let idlePolls = 0;
|
|
63
|
+
while (true) {
|
|
64
|
+
if (signal?.aborted === true) throw new Error(`waiting for child run "${runId}" was canceled`);
|
|
65
|
+
const snapshot = await journal.runGet(runId);
|
|
66
|
+
if (snapshot.status === 'completed' || snapshot.status === 'failed') return snapshot.status;
|
|
67
|
+
const inFlight = await inFlightDeadline(journal, runId, snapshot);
|
|
68
|
+
if (inFlight === 'parked') return snapshot.status;
|
|
69
|
+
const remainingMs = inFlight === undefined ? 0 : inFlight.deadlineMs + inFlight.graceMs - Date.now();
|
|
70
|
+
if (inFlight !== undefined && remainingMs > 0) {
|
|
71
|
+
idlePolls = 0;
|
|
72
|
+
await delay(Math.min(POLL_MS, remainingMs), signal);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// Only a lease past its sweep grace is proof nothing is running the
|
|
76
|
+
// attempt. An elapsed retry wake is the kernel's normal transition to the
|
|
77
|
+
// next attempt — its driver may be folding the timer right now — so it is
|
|
78
|
+
// re-polled inside the settling window like any other in-between state.
|
|
79
|
+
if (inFlight?.what === 'lease') {
|
|
80
|
+
throw new Error(`child run "${runId}" step "${inFlight.stepId}" passed its lease`
|
|
81
|
+
+ ` (${inFlight.deadlineMs}) without reaching a terminal state`);
|
|
82
|
+
}
|
|
83
|
+
if (idlePolls >= MAX_IDLE_POLLS) return snapshot.status;
|
|
84
|
+
idlePolls += 1;
|
|
85
|
+
await delay(POLL_MS, signal);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface InFlight {
|
|
90
|
+
stepId: string;
|
|
91
|
+
what: 'lease' | 'retry backoff';
|
|
92
|
+
deadlineMs: number;
|
|
93
|
+
graceMs: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What the child is waiting on, and until when; `parked` when it is waiting
|
|
98
|
+
* on nothing this process can wait out.
|
|
99
|
+
*
|
|
100
|
+
* A worker-typed (`agent`/`llm`) step that is runnable, or whose retry
|
|
101
|
+
* backoff has elapsed, with no attempt running, needs a worker — or the
|
|
102
|
+
* `run.resume` a caller like `classifyOutcome` issues — to move. That is the
|
|
103
|
+
* park `classifyOutcome` reports, and waiting on it would only delay the same
|
|
104
|
+
* answer. A deterministic step in that state is the kernel between two of its
|
|
105
|
+
* own actions, which the caller's settling window covers.
|
|
106
|
+
*/
|
|
107
|
+
async function inFlightDeadline(
|
|
108
|
+
journal: JournalClient,
|
|
109
|
+
runId: string,
|
|
110
|
+
snapshot: RunGetResult,
|
|
111
|
+
): Promise<InFlight | 'parked' | undefined> {
|
|
112
|
+
const steps = Object.entries(snapshot.steps);
|
|
113
|
+
const running = steps.find(([, step]) => step.state === 'running' && step.lease_deadline_ms !== undefined);
|
|
114
|
+
if (running !== undefined) {
|
|
115
|
+
return {
|
|
116
|
+
stepId: running[0], what: 'lease', deadlineMs: running[1].lease_deadline_ms!, graceMs: LEASE_SWEEP_GRACE_MS,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
const worker = ([, step]: [string, { type: string }]) => step.type !== 'deterministic';
|
|
120
|
+
if (steps.some(entry => worker(entry) && entry[1].state === 'runnable')) return 'parked';
|
|
121
|
+
const backoff = steps.find(([, step]) => step.state === 'backoff');
|
|
122
|
+
if (backoff === undefined) return undefined;
|
|
123
|
+
const wakeAtMs = (await readChildJournal(journal, runId))
|
|
124
|
+
.map(entry => retryWake(entry, backoff[0]))
|
|
125
|
+
.filter((value): value is number => value !== undefined)
|
|
126
|
+
.at(-1);
|
|
127
|
+
if (wakeAtMs === undefined) return undefined;
|
|
128
|
+
if (wakeAtMs <= Date.now()) return worker(backoff) ? 'parked' : undefined;
|
|
129
|
+
return { stepId: backoff[0], what: 'retry backoff', deadlineMs: wakeAtMs, graceMs: 0 };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function retryWake(entry: unknown, stepId: string): number | undefined {
|
|
133
|
+
if (typeof entry !== 'object' || entry === null) return undefined;
|
|
134
|
+
const { entry_type: type, step_id: step, payload } = entry as Record<string, unknown>;
|
|
135
|
+
if (type !== 'sleep.until' || step !== stepId || typeof payload !== 'object' || payload === null) return undefined;
|
|
136
|
+
const wakeAtMs = (payload as Record<string, unknown>)['wake_at_ms'];
|
|
137
|
+
return typeof wakeAtMs === 'number' && Number.isFinite(wakeAtMs) ? wakeAtMs : undefined;
|
|
138
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { DispatchResult } from '@relayflows/surface';
|
|
2
|
+
import type { FlowHandle } from './authored-flow.js';
|
|
3
|
+
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
4
|
+
import type { LoadedAuthoredFlowNode } from './authored-flow-loader.js';
|
|
5
|
+
import { flowRequirements, type RequirementsFlowDefinition } from './flow-requirements.js';
|
|
6
|
+
|
|
7
|
+
export const MAX_DISPATCH_DEPTH = 3;
|
|
8
|
+
|
|
9
|
+
/** Resolve one attenuated direct child from the statically loaded `use` graph. */
|
|
10
|
+
export function resolveDispatchChild(options: {
|
|
11
|
+
readonly handle: FlowHandle;
|
|
12
|
+
readonly definition: RequirementsFlowDefinition & { readonly name: string };
|
|
13
|
+
readonly graph?: readonly LoadedAuthoredFlowNode[];
|
|
14
|
+
readonly flowName: string;
|
|
15
|
+
readonly depth: number;
|
|
16
|
+
}): LoadedAuthoredFlowNode {
|
|
17
|
+
if (typeof options.flowName !== 'string' || options.flowName.trim() === '') {
|
|
18
|
+
throw new AuthoredFlowExecutionError('dispatch_invalid', 'f.dispatch requires a non-empty declared child flow name');
|
|
19
|
+
}
|
|
20
|
+
if (options.depth >= MAX_DISPATCH_DEPTH) {
|
|
21
|
+
throw new AuthoredFlowExecutionError(
|
|
22
|
+
'dispatch_depth_exceeded',
|
|
23
|
+
`f.dispatch depth exceeds ${MAX_DISPATCH_DEPTH}; flatten the flow tree or move work into the current child`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const current = options.graph?.find(node => node.handle === options.handle);
|
|
27
|
+
const direct = current?.use.map(path => options.graph!.find(node => node.path === path)) ?? [];
|
|
28
|
+
const matches = direct.filter((node): node is LoadedAuthoredFlowNode =>
|
|
29
|
+
node !== undefined && node.getDefinition(node.handle).name === options.flowName);
|
|
30
|
+
if (matches.length !== 1) {
|
|
31
|
+
const declared = direct.filter((node): node is LoadedAuthoredFlowNode => node !== undefined)
|
|
32
|
+
.map(node => node.getDefinition(node.handle).name).sort();
|
|
33
|
+
throw new AuthoredFlowExecutionError(
|
|
34
|
+
matches.length > 1 ? 'dispatch_invalid' : 'dispatch_unknown',
|
|
35
|
+
`flow "${options.definition.name}" cannot dispatch ${JSON.stringify(options.flowName)}; direct use children: ${declared.length === 0 ? '(none)' : declared.join(', ')}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
const child = matches[0]!;
|
|
39
|
+
const childDefinition = child.getDefinition(child.handle);
|
|
40
|
+
assertChildCapabilities(options.definition, childDefinition);
|
|
41
|
+
if (childDefinition.header.budget !== undefined) {
|
|
42
|
+
throw new AuthoredFlowExecutionError(
|
|
43
|
+
'dispatch_invalid',
|
|
44
|
+
`child flow "${childDefinition.name}" declares its own budget; put the run-tree ceiling on "${options.definition.name}" until nested budget intersection ships`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return child;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function parseDispatchReceipt(value: string, step: string): DispatchResult {
|
|
51
|
+
let parsed: unknown;
|
|
52
|
+
try { parsed = JSON.parse(value); } catch {
|
|
53
|
+
throw new AuthoredFlowExecutionError('journal_protocol_violation', `dispatch step "${step}" recorded invalid JSON`);
|
|
54
|
+
}
|
|
55
|
+
const receipt = parsed as Partial<DispatchResult> | null;
|
|
56
|
+
if (typeof receipt !== 'object' || receipt === null || typeof receipt.name !== 'string'
|
|
57
|
+
|| receipt.completionReason !== 'success'
|
|
58
|
+
|| (receipt.completionDetail !== undefined && typeof receipt.completionDetail !== 'string')) {
|
|
59
|
+
throw new AuthoredFlowExecutionError('journal_protocol_violation', `dispatch step "${step}" recorded an invalid child receipt`);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
name: receipt.name,
|
|
63
|
+
completionReason: 'success',
|
|
64
|
+
...(receipt.completionDetail === undefined ? {} : { completionDetail: receipt.completionDetail }),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Last visible child operations; the dispatch receipt joins all of them. */
|
|
69
|
+
export function childLeaves(steps: readonly { readonly id: string; readonly after?: readonly string[] }[]): string[] {
|
|
70
|
+
const visible = steps.filter(step => !/(?:^|--)complete-[1-9][0-9]*$/.test(step.id));
|
|
71
|
+
const parents = new Set(visible.flatMap(step => step.after ?? []));
|
|
72
|
+
return visible.map(step => step.id).filter(id => !parents.has(id));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A child may use only provider/MCP capabilities its parent already declared. */
|
|
76
|
+
function assertChildCapabilities(
|
|
77
|
+
parent: RequirementsFlowDefinition & { readonly name: string },
|
|
78
|
+
child: RequirementsFlowDefinition & { readonly name: string },
|
|
79
|
+
): void {
|
|
80
|
+
const parentTools = parent.header?.tools ?? {};
|
|
81
|
+
const childTools = child.header?.tools ?? {};
|
|
82
|
+
for (const [tool, declared] of Object.entries(childTools)) {
|
|
83
|
+
if (tool === 'mcp') {
|
|
84
|
+
const allowed = new Set(Array.isArray(parentTools.mcp) ? parentTools.mcp as readonly unknown[] : []);
|
|
85
|
+
const requested = Array.isArray(declared) ? declared : [];
|
|
86
|
+
if (requested.some(server => !allowed.has(server))) {
|
|
87
|
+
throw new AuthoredFlowExecutionError(
|
|
88
|
+
'dispatch_invalid',
|
|
89
|
+
`child flow "${child.name}" widens tools.mcp beyond parent "${parent.name}"`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const parentHelpers = helperAuthority(parent);
|
|
95
|
+
for (const requirement of flowRequirements(child).integrations) {
|
|
96
|
+
if ((requirement.from === 'tools' || requirement.from === 'helper')
|
|
97
|
+
&& !parentHelpers.has(requirement.provider)) {
|
|
98
|
+
throw new AuthoredFlowExecutionError(
|
|
99
|
+
'dispatch_invalid',
|
|
100
|
+
`child flow "${child.name}" requires ${requirement.detail}, which parent "${parent.name}" did not grant`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Helper providers this flow can delegate, from explicit or statically visible use. */
|
|
107
|
+
export function helperAuthority(
|
|
108
|
+
definition: RequirementsFlowDefinition,
|
|
109
|
+
): ReadonlySet<string> {
|
|
110
|
+
return new Set(flowRequirements(definition).integrations
|
|
111
|
+
.filter(requirement => requirement.from === 'tools' || requirement.from === 'helper')
|
|
112
|
+
.map(requirement => requirement.provider));
|
|
113
|
+
}
|
|
@@ -11,11 +11,18 @@ export type AuthoredFlowExecutionErrorCode =
|
|
|
11
11
|
| 'helper_slack.credential_missing'
|
|
12
12
|
| 'helper_slack.mount_required'
|
|
13
13
|
| 'budget_syntax_invalid'
|
|
14
|
+
/** The step's CLI is too old for its model — an environment refusal, not a spec error. */
|
|
15
|
+
| 'cli_outdated'
|
|
16
|
+
/** The step's credential hit a provider usage/rate limit during preflight. */
|
|
17
|
+
| 'provider_usage_limited'
|
|
14
18
|
| 'agent_cli_unresolved'
|
|
15
19
|
| 'agent_parked'
|
|
16
20
|
| 'llm_cli_unresolved'
|
|
17
21
|
| 'llm_parked'
|
|
18
22
|
| 'duplicate_completion'
|
|
23
|
+
| 'dispatch_depth_exceeded'
|
|
24
|
+
| 'dispatch_invalid'
|
|
25
|
+
| 'dispatch_unknown'
|
|
19
26
|
| 'journal_protocol_violation'
|
|
20
27
|
| 'missing_completion'
|
|
21
28
|
| 'memory_unreachable'
|
|
@@ -36,6 +43,9 @@ export type AuthoredFlowExecutionErrorCode =
|
|
|
36
43
|
| 'unsettled_derived_work'
|
|
37
44
|
| 'unsupported_promise_lifecycle'
|
|
38
45
|
| 'unsupported_workspace_permission'
|
|
46
|
+
| 'unbounded_subscription'
|
|
47
|
+
| 'activity_closed'
|
|
48
|
+
| 'subscription_suspended'
|
|
39
49
|
| 'unawaited_step'
|
|
40
50
|
| 'unsupported_verb';
|
|
41
51
|
|
|
@@ -73,12 +83,38 @@ export class AuthoredFlowExecutionError extends Error {
|
|
|
73
83
|
* Node-child error frame — can forward it instead of dropping it.
|
|
74
84
|
*/
|
|
75
85
|
readonly details?: StepFailedDetails,
|
|
86
|
+
readonly suspension?: AuthoredFlowSuspension,
|
|
76
87
|
) {
|
|
77
88
|
super(`${code}: ${message}`);
|
|
78
89
|
this.name = 'AuthoredFlowExecutionError';
|
|
79
90
|
}
|
|
80
91
|
}
|
|
81
92
|
|
|
93
|
+
/** Serialized into the CLI report so Cloud can atomically finish activation or wait for a wake. */
|
|
94
|
+
export type AuthoredFlowSuspension =
|
|
95
|
+
/**
|
|
96
|
+
* Exact durable `subscription.prepared` facts Cloud must persist before it
|
|
97
|
+
* fences provider ingress and invokes `subscription.activate`. Binding
|
|
98
|
+
* generation and ingress cursor are Cloud-assigned receipts, deliberately
|
|
99
|
+
* absent from the authored request.
|
|
100
|
+
*/
|
|
101
|
+
| {
|
|
102
|
+
readonly kind: 'activation';
|
|
103
|
+
readonly subscriptionId: string;
|
|
104
|
+
readonly eventTypes: readonly string[];
|
|
105
|
+
readonly pattern?: Readonly<Record<string, unknown>>;
|
|
106
|
+
readonly stream: string;
|
|
107
|
+
readonly settleMs: number;
|
|
108
|
+
readonly idleMs: number;
|
|
109
|
+
readonly deadlineAtMs: number;
|
|
110
|
+
readonly includeSelf: boolean;
|
|
111
|
+
}
|
|
112
|
+
| {
|
|
113
|
+
readonly kind: 'event_wait';
|
|
114
|
+
readonly subscriptionId: string;
|
|
115
|
+
readonly stream: string;
|
|
116
|
+
readonly deadlineAtMs: number;
|
|
117
|
+
};
|
|
82
118
|
/** The question an authored body parked on, as the kernel journals it. */
|
|
83
119
|
export interface AuthoredHumanWait {
|
|
84
120
|
readonly waitId: string;
|