@relayflows/sdk 2.0.54 → 2.0.56
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-broker.d.ts +13 -0
- package/dist/authored-broker.d.ts.map +1 -0
- package/dist/authored-broker.js +75 -0
- package/dist/authored-broker.js.map +1 -0
- package/dist/authored-budget.d.ts +1 -1
- package/dist/authored-budget.d.ts.map +1 -1
- package/dist/authored-budget.js +8 -2
- package/dist/authored-budget.js.map +1 -1
- package/dist/authored-flow-error.d.ts +6 -0
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts +11 -0
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +104 -20
- 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 +42 -3
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-helper-effect.d.ts.map +1 -1
- package/dist/authored-helper-effect.js +1 -1
- package/dist/authored-helper-effect.js.map +1 -1
- package/dist/authored-hooks.d.ts +2 -1
- package/dist/authored-hooks.d.ts.map +1 -1
- package/dist/authored-hooks.js +2 -2
- package/dist/authored-hooks.js.map +1 -1
- package/dist/authored-human.d.ts +18 -0
- package/dist/authored-human.d.ts.map +1 -1
- package/dist/authored-human.js +32 -4
- package/dist/authored-human.js.map +1 -1
- package/dist/authored-mcp.d.ts.map +1 -1
- package/dist/authored-mcp.js +3 -2
- package/dist/authored-mcp.js.map +1 -1
- package/dist/authored-node-entry.js +4 -0
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts +8 -0
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +60 -3
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-plugin-effect.d.ts.map +1 -1
- package/dist/authored-plugin-effect.js +3 -2
- package/dist/authored-plugin-effect.js.map +1 -1
- package/dist/authored-preflight.d.ts.map +1 -1
- package/dist/authored-preflight.js +2 -1
- package/dist/authored-preflight.js.map +1 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +13 -3
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-serial-ordinals.d.ts +41 -0
- package/dist/authored-serial-ordinals.d.ts.map +1 -0
- package/dist/authored-serial-ordinals.js +45 -0
- package/dist/authored-serial-ordinals.js.map +1 -0
- package/dist/authored-worker-step.d.ts +5 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +19 -2
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/budget-preflight.d.ts.map +1 -1
- package/dist/budget-preflight.js +11 -2
- package/dist/budget-preflight.js.map +1 -1
- package/dist/bundle-transport.d.ts +2 -0
- package/dist/bundle-transport.d.ts.map +1 -1
- package/dist/bundle-transport.js +4 -0
- package/dist/bundle-transport.js.map +1 -1
- package/dist/cached-flow-runtime.d.ts +3 -0
- package/dist/cached-flow-runtime.d.ts.map +1 -0
- package/dist/cached-flow-runtime.js +37 -0
- package/dist/cached-flow-runtime.js.map +1 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +13 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-deploy.js +6 -5
- package/dist/cli/cloud-deploy.js.map +1 -1
- package/dist/cli/cloud-mirror-session.d.ts +8 -2
- package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
- package/dist/cli/cloud-mirror-session.js +20 -27
- package/dist/cli/cloud-mirror-session.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +2 -1
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +19 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +39 -18
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +3 -3
- package/dist/cloud-deploy.d.ts.map +1 -1
- package/dist/cloud-deploy.js +15 -9
- package/dist/cloud-deploy.js.map +1 -1
- package/dist/cloud-flow-source.d.ts +13 -0
- package/dist/cloud-flow-source.d.ts.map +1 -0
- package/dist/cloud-flow-source.js +55 -0
- package/dist/cloud-flow-source.js.map +1 -0
- package/dist/cloud-mirror-transport.d.ts +2 -0
- package/dist/cloud-mirror-transport.d.ts.map +1 -1
- package/dist/cloud-mirror-transport.js +2 -0
- package/dist/cloud-mirror-transport.js.map +1 -1
- package/dist/cloud-mirror.d.ts.map +1 -1
- package/dist/cloud-mirror.js +10 -1
- package/dist/cloud-mirror.js.map +1 -1
- package/dist/communication/broker-preflight.d.ts +3 -0
- package/dist/communication/broker-preflight.d.ts.map +1 -0
- package/dist/communication/broker-preflight.js +22 -0
- package/dist/communication/broker-preflight.js.map +1 -0
- package/dist/communication/broker-spec.d.ts +20 -0
- package/dist/communication/broker-spec.d.ts.map +1 -0
- package/dist/communication/broker-spec.js +46 -0
- package/dist/communication/broker-spec.js.map +1 -0
- package/dist/communication/broker-worker.d.ts +17 -0
- package/dist/communication/broker-worker.d.ts.map +1 -0
- package/dist/communication/broker-worker.js +214 -0
- package/dist/communication/broker-worker.js.map +1 -0
- package/dist/communication/pty.d.ts +29 -0
- package/dist/communication/pty.d.ts.map +1 -0
- package/dist/communication/pty.js +40 -0
- package/dist/communication/pty.js.map +1 -0
- package/dist/communication/relay.d.ts +18 -0
- package/dist/communication/relay.d.ts.map +1 -1
- package/dist/communication/relay.js +4 -0
- package/dist/communication/relay.js.map +1 -1
- package/dist/communication/skills.d.ts +3 -0
- package/dist/communication/skills.d.ts.map +1 -0
- package/dist/communication/skills.js +17 -0
- package/dist/communication/skills.js.map +1 -0
- package/dist/communication/worker.d.ts.map +1 -1
- package/dist/communication/worker.js +9 -26
- package/dist/communication/worker.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 +2 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/plugin-manifest.js +1 -1
- package/dist/plugin-manifest.js.map +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +2 -1
- package/dist/preflight.js.map +1 -1
- package/dist/worker.d.ts +1 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +12 -0
- package/dist/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/authored-broker.ts +73 -0
- package/src/authored-budget.ts +6 -2
- package/src/authored-flow-error.ts +6 -0
- package/src/authored-flow-executor.ts +115 -20
- package/src/authored-flow-loader.ts +43 -3
- package/src/authored-helper-effect.ts +2 -1
- package/src/authored-hooks.ts +4 -3
- package/src/authored-human.ts +35 -4
- package/src/authored-mcp.ts +7 -2
- package/src/authored-node-entry.ts +4 -1
- package/src/authored-node-runner.ts +57 -3
- package/src/authored-plugin-effect.ts +7 -2
- package/src/authored-preflight.ts +2 -1
- package/src/authored-root.ts +13 -3
- package/src/authored-serial-ordinals.ts +76 -0
- package/src/authored-worker-step.ts +17 -2
- package/src/budget-preflight.ts +9 -1
- package/src/bundle-transport.ts +4 -0
- package/src/cached-flow-runtime.ts +32 -0
- package/src/cli/check.ts +11 -1
- package/src/cli/cloud-deploy.ts +5 -5
- package/src/cli/cloud-mirror-session.ts +23 -29
- package/src/cli/direct-run.ts +2 -1
- package/src/cli/run.ts +18 -3
- package/src/cli.ts +31 -13
- package/src/cloud-deploy.ts +19 -14
- package/src/cloud-flow-source.ts +58 -0
- package/src/cloud-mirror-transport.ts +4 -0
- package/src/cloud-mirror.ts +9 -1
- package/src/communication/broker-preflight.ts +22 -0
- package/src/communication/broker-spec.ts +48 -0
- package/src/communication/broker-worker.ts +198 -0
- package/src/communication/pty.ts +47 -0
- package/src/communication/relay.ts +12 -1
- package/src/communication/skills.ts +16 -0
- package/src/communication/worker.ts +9 -26
- package/src/failure-kinds.ts +2 -0
- package/src/plugin-manifest.ts +1 -1
- package/src/preflight.ts +3 -1
- package/src/worker.ts +11 -0
package/src/authored-human.ts
CHANGED
|
@@ -105,13 +105,44 @@ export async function readHumanAnswer(
|
|
|
105
105
|
runId: string,
|
|
106
106
|
waitId: string,
|
|
107
107
|
): Promise<HumanAnswer | undefined> {
|
|
108
|
+
return (await readHumanWait(journal, runId, waitId)).answer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `readHumanAnswer`, plus every question the run has parked on under
|
|
113
|
+
* `waitId`. The answer is keyed by the wait id alone, so the executor checks
|
|
114
|
+
* that the question it is about to answer is the one that was asked.
|
|
115
|
+
*/
|
|
116
|
+
export async function readHumanWait(
|
|
117
|
+
journal: JournalClient,
|
|
118
|
+
runId: string,
|
|
119
|
+
waitId: string,
|
|
120
|
+
): Promise<{ answer?: HumanAnswer; asked: Array<{ question: unknown; to: unknown }> }> {
|
|
121
|
+
const asked: Array<{ question: unknown; to: unknown }> = [];
|
|
108
122
|
for (const entry of await readWaitEntries(journal, runId)) {
|
|
109
|
-
if (entry.
|
|
110
|
-
|
|
111
|
-
|
|
123
|
+
if (entry.payload?.wait_id !== waitId) continue;
|
|
124
|
+
if (entry.entry_type === 'wait.human') {
|
|
125
|
+
asked.push({ question: entry.payload.prompt, to: entry.payload.requested_of });
|
|
126
|
+
} else if (entry.payload.completionReason === 'human_responded') {
|
|
127
|
+
return { answer: parseHumanAnswer(entry.payload.result, waitId), asked };
|
|
112
128
|
}
|
|
113
129
|
}
|
|
114
|
-
return
|
|
130
|
+
return { asked };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Every `f.human` wait id the run has parked on, answered or not. A parked
|
|
135
|
+
* question is not in the step index until its answer is lowered, but it
|
|
136
|
+
* already holds its serial ordinal.
|
|
137
|
+
*/
|
|
138
|
+
export async function readHumanWaitIds(journal: JournalClient, runId: string): Promise<string[]> {
|
|
139
|
+
const ids = new Set<string>();
|
|
140
|
+
for (const entry of await readWaitEntries(journal, runId)) {
|
|
141
|
+
const waitId = entry.payload?.wait_id;
|
|
142
|
+
if (entry.entry_type === 'wait.human' && typeof waitId === 'string'
|
|
143
|
+
&& /(?:^|--)human-[1-9][0-9]*$/u.test(waitId)) ids.add(waitId);
|
|
144
|
+
}
|
|
145
|
+
return [...ids];
|
|
115
146
|
}
|
|
116
147
|
|
|
117
148
|
/** Every `wait.human` on the run that no `wait.completed` has closed. */
|
package/src/authored-mcp.ts
CHANGED
|
@@ -142,13 +142,18 @@ export async function runMcpEffect(
|
|
|
142
142
|
childRunId = outcome.run_id;
|
|
143
143
|
if (outcome.status !== 'completed') await completed;
|
|
144
144
|
if (diagnostic !== undefined) {
|
|
145
|
-
try {
|
|
145
|
+
try {
|
|
146
|
+
await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps,
|
|
147
|
+
rootRunId === undefined ? {} : { rootRunId }, outcome.status);
|
|
148
|
+
}
|
|
146
149
|
catch (error) {
|
|
147
150
|
if (!(error instanceof AuthoredFlowExecutionError) || error.code !== 'step_failed') throw error;
|
|
148
151
|
throw new McpStepError(diagnostic, outcome.run_id);
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
|
-
|
|
154
|
+
// Indexed on the root like every other child, so a resume can see its ordinal.
|
|
155
|
+
const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps,
|
|
156
|
+
rootRunId === undefined ? {} : { rootRunId }, outcome.status) as { output: unknown };
|
|
152
157
|
return receipt.output;
|
|
153
158
|
} finally {
|
|
154
159
|
clearTimeout(timer);
|
|
@@ -53,7 +53,7 @@ try {
|
|
|
53
53
|
executableSha256: hash(process.execPath), payloadSha256: hash(process.argv[1]!) } });
|
|
54
54
|
const request = await new Promise<{ channelKey: string; metadata: AuthoredRootMetadata; socketPath: string;
|
|
55
55
|
rootRunId: string; dataDir: string; localAgentStream?: string; workerCapacity?: number;
|
|
56
|
-
agentEnvironment?: unknown }>((resolve, reject) => {
|
|
56
|
+
journaledSteps?: unknown; agentEnvironment?: unknown }>((resolve, reject) => {
|
|
57
57
|
let buffer = '';
|
|
58
58
|
process.stdin.setEncoding('utf8');
|
|
59
59
|
const onData = (chunk: string): void => {
|
|
@@ -90,6 +90,8 @@ try {
|
|
|
90
90
|
}
|
|
91
91
|
if (request.localAgentStream !== request.metadata.localAgentStream) throw new Error('authored root local agent surface mismatch');
|
|
92
92
|
if (request.workerCapacity !== undefined && !isAgentCapacity(request.workerCapacity)) throw new Error('invalid authored worker capacity');
|
|
93
|
+
if (request.journaledSteps !== undefined && (!Array.isArray(request.journaledSteps)
|
|
94
|
+
|| !request.journaledSteps.every(step => typeof step === 'string'))) throw new Error('invalid authored journaled steps');
|
|
93
95
|
client = new JournalClient(request.socketPath, { readBudgetMs: FLOW_READ_BUDGET_MS, budgetHandshake: true });
|
|
94
96
|
await client.connect(); await client.hello('flows-authored-node');
|
|
95
97
|
const result = await executeAuthoredFlow(loaded.handle, client,
|
|
@@ -102,6 +104,7 @@ try {
|
|
|
102
104
|
flowGraph: loaded.graph,
|
|
103
105
|
localAgentStream: request.localAgentStream, signal: controller.signal,
|
|
104
106
|
...(request.workerCapacity === undefined ? {} : { workerCapacity: request.workerCapacity }),
|
|
107
|
+
...(request.journaledSteps === undefined ? {} : { journaledSteps: request.journaledSteps as string[] }),
|
|
105
108
|
...(agentEnvironment === undefined ? {} : { agentEnvironment: { ...process.env, ...agentEnvironment } }),
|
|
106
109
|
onProgress: event => send({ type: 'progress', event }),
|
|
107
110
|
onWait: event => send({ type: 'wait', event }),
|
|
@@ -3,9 +3,9 @@ import { FLOW_READ_BUDGET_MS } from './journal-read-policy.js';
|
|
|
3
3
|
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
5
5
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
6
|
-
import { readFileSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { accessSync, constants as fsConstants, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
7
7
|
import { tmpdir } from 'node:os';
|
|
8
|
-
import { isAbsolute, join } from 'node:path';
|
|
8
|
+
import { delimiter, isAbsolute, join } from 'node:path';
|
|
9
9
|
import type { Readable } from 'node:stream';
|
|
10
10
|
import type { AuthoredRootMetadata } from './authored-root.js';
|
|
11
11
|
import type { AuthoredExecutionRuntime, AuthoredFlowExecutionResult, ExecuteAuthoredFlowOptions } from './authored-flow-executor.js';
|
|
@@ -35,10 +35,51 @@ const refusal = (): AuthoredFlowExecutionError => new AuthoredFlowExecutionError
|
|
|
35
35
|
'authored execution requires the embedded Node runner and Node >=22.14 on PATH (or an absolute FLOWS_AUTHORED_NODE); no workflow body was executed',
|
|
36
36
|
);
|
|
37
37
|
interface NodeAuthority { path: string; version: string; executableSha256: string }
|
|
38
|
+
/**
|
|
39
|
+
* One authored run asks for the Node authority before root admission and
|
|
40
|
+
* again before spawning the body. Each probe spawns Node and hashes its whole
|
|
41
|
+
* executable (~100 MB), which a cold sandbox pays on the startup path twice.
|
|
42
|
+
* Reuse the previous answer only while the same candidate is selected (the
|
|
43
|
+
* override or the first `node` on PATH, by path and by realpath, so a
|
|
44
|
+
* retargeted symlink re-probes) and the executable it resolved is unchanged.
|
|
45
|
+
* The spawned child still hashes the executable it actually runs and the
|
|
46
|
+
* parent compares that report with this authority, so a binary replaced
|
|
47
|
+
* after the probe is refused, never trusted.
|
|
48
|
+
*/
|
|
49
|
+
let probedAuthority: { selection: string; fingerprint: string; authority: NodeAuthority } | undefined;
|
|
50
|
+
function executableFingerprint(path: string): string {
|
|
51
|
+
const stat = statSync(path, { bigint: true });
|
|
52
|
+
return [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':');
|
|
53
|
+
}
|
|
54
|
+
/** The candidate `spawnSync(override ?? 'node')` would run, by path and realpath. */
|
|
55
|
+
function selectedCandidate(override: string | undefined): string | undefined {
|
|
56
|
+
const directories = (process.env['PATH'] ?? '').split(delimiter);
|
|
57
|
+
// A relative PATH entry resolves against the working directory; do not
|
|
58
|
+
// guess which candidate the spawn would pick, just probe every time.
|
|
59
|
+
if (override === undefined && directories.some(directory => !isAbsolute(directory))) return undefined;
|
|
60
|
+
const candidates = override !== undefined ? [override] : directories.map(directory => join(directory, 'node'));
|
|
61
|
+
for (const candidate of candidates) {
|
|
62
|
+
try {
|
|
63
|
+
if (!statSync(candidate).isFile()) continue;
|
|
64
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
65
|
+
return `${candidate}\0${realpathSync(candidate)}`;
|
|
66
|
+
} catch { /* Not this candidate. */ }
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
38
70
|
function nodeAuthority(): NodeAuthority {
|
|
39
71
|
if (embeddedSource === undefined) throw refusal();
|
|
40
72
|
const override = process.env['FLOWS_AUTHORED_NODE'];
|
|
41
73
|
if (override !== undefined && !isAbsolute(override)) throw refusal();
|
|
74
|
+
const selection = selectedCandidate(override);
|
|
75
|
+
if (selection !== undefined && probedAuthority?.selection === selection) {
|
|
76
|
+
try {
|
|
77
|
+
if (executableFingerprint(probedAuthority.authority.path) === probedAuthority.fingerprint) {
|
|
78
|
+
return probedAuthority.authority;
|
|
79
|
+
}
|
|
80
|
+
} catch { /* A vanished executable is re-probed, and refused there. */ }
|
|
81
|
+
}
|
|
82
|
+
probedAuthority = undefined;
|
|
42
83
|
const probe = spawnSync(override ?? 'node', ['--input-type=module', '--eval', `
|
|
43
84
|
import {createHook} from 'node:async_hooks';
|
|
44
85
|
import {stripTypeScriptTypes,register} from 'node:module';
|
|
@@ -56,10 +97,22 @@ process.stdout.write(JSON.stringify({path:process.execPath,version:process.versi
|
|
|
56
97
|
const [major, minor] = value.version.split('.').map(Number);
|
|
57
98
|
if (major! < 22 || (major === 22 && minor! < 14)) throw refusal();
|
|
58
99
|
const path = realpathSync(value.path);
|
|
59
|
-
|
|
100
|
+
const fingerprint = executableFingerprint(path);
|
|
101
|
+
const authority = { path, version: value.version, executableSha256: hash(readFileSync(path)) };
|
|
102
|
+
// Reuse only an answer for a candidate that could be identified, and not
|
|
103
|
+
// one whose file was rewritten while it was being hashed.
|
|
104
|
+
if (selection !== undefined && executableFingerprint(path) === fingerprint) {
|
|
105
|
+
probedAuthority = { selection, fingerprint, authority };
|
|
106
|
+
}
|
|
107
|
+
return authority;
|
|
60
108
|
} catch { throw refusal(); }
|
|
61
109
|
}
|
|
62
110
|
|
|
111
|
+
/** Test seam: resolve the authored Node authority exactly as the runner does. */
|
|
112
|
+
export function authoredNodeAuthorityForTest(): Readonly<NodeAuthority> {
|
|
113
|
+
return nodeAuthority();
|
|
114
|
+
}
|
|
115
|
+
|
|
63
116
|
/** Called before root admission/resume; declarative commands never reach it. */
|
|
64
117
|
export function assertAuthoredRuntimeAvailable(): void {
|
|
65
118
|
if (process.versions['bun'] === undefined) assertAuthoredPromiseHooks();
|
|
@@ -138,6 +191,7 @@ export async function runAuthoredInNode(
|
|
|
138
191
|
child.stdin!.write(JSON.stringify({ channelKey, metadata, socketPath, rootRunId,
|
|
139
192
|
dataDir: options.dataDir, localAgentStream: options.localAgentStream,
|
|
140
193
|
workerCapacity: options.workerCapacity,
|
|
194
|
+
...(options.journaledSteps === undefined ? {} : { journaledSteps: options.journaledSteps }),
|
|
141
195
|
agentEnvironment: options.agentEnvironment }) + '\n');
|
|
142
196
|
} else if (!ready || result) throw new Error('unexpected authored runtime message');
|
|
143
197
|
else if (message.type === 'progress') options.onProgress?.(message.event);
|
|
@@ -127,13 +127,18 @@ export async function runPluginEffect(
|
|
|
127
127
|
if (dispatchExpired) await cancelChildRun();
|
|
128
128
|
if (outcome.status !== 'completed') await completed;
|
|
129
129
|
if (diagnostic !== undefined) {
|
|
130
|
-
try {
|
|
130
|
+
try {
|
|
131
|
+
await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps,
|
|
132
|
+
rootRunId === undefined ? {} : { rootRunId }, outcome.status);
|
|
133
|
+
}
|
|
131
134
|
catch (error) {
|
|
132
135
|
if (!(error instanceof AuthoredFlowExecutionError) || error.code !== 'step_failed') throw error;
|
|
133
136
|
throw new PluginStepError(diagnostic, outcome.run_id);
|
|
134
137
|
}
|
|
135
138
|
}
|
|
136
|
-
|
|
139
|
+
// Indexed on the root like every other child, so a resume can see its ordinal.
|
|
140
|
+
const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps,
|
|
141
|
+
rootRunId === undefined ? {} : { rootRunId }, outcome.status) as { output: unknown };
|
|
137
142
|
return receipt.output;
|
|
138
143
|
}, admissionKey), deadline]);
|
|
139
144
|
} finally {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { brokerInstruction } from './communication/broker-spec.js';
|
|
1
2
|
import { dirname, resolve } from 'node:path';
|
|
2
3
|
import { checkAuthoredFlow, readProjectConfig, type ProjectConfig } from './cli/check.js';
|
|
3
4
|
import { probeCliAsync } from './cli/cli-probe.js';
|
|
@@ -21,7 +22,7 @@ export function authoredPreflight(path: string, environment?: NodeJS.ProcessEnv)
|
|
|
21
22
|
if (!resolved.ok) return checkAuthoredFlow(flow, path, config, { environment });
|
|
22
23
|
await Promise.all(resolved.resolutions.map(async resolution => {
|
|
23
24
|
const step = resolved.compiled!.steps.find(step => step.id === resolution.stepId)!;
|
|
24
|
-
const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined;
|
|
25
|
+
const managed = step.type === 'agent' && (communicationInstruction(step.instruction) !== undefined || brokerInstruction(step.instruction) !== undefined);
|
|
25
26
|
const key = cliProbeKey(resolution, managed, step.secrets);
|
|
26
27
|
if (probeCache.has(key)) return;
|
|
27
28
|
let probing = pending.get(key);
|
package/src/authored-root.ts
CHANGED
|
@@ -19,12 +19,13 @@ import { SPEC_SCHEMA_VERSION } from './spec.js';
|
|
|
19
19
|
import type { RunLifecycleOptions } from './cli/run.js';
|
|
20
20
|
import { isLeaseLost, withWorkerLease } from './worker-lease.js';
|
|
21
21
|
import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
|
|
22
|
-
import { readOpenHumanWaits, resumeCommand } from './authored-human.js';
|
|
22
|
+
import { readHumanWaitIds, readOpenHumanWaits, resumeCommand } from './authored-human.js';
|
|
23
23
|
import { helperReceiptWaitId, releaseHelperReceiptWaits } from './helper-park.js';
|
|
24
24
|
import { readSubscriptionPark } from './authored-subscription-park.js';
|
|
25
25
|
import { localAgentCredentialEnvironment } from './local-agent-environment.js';
|
|
26
26
|
import { completedResult, rootKernelStep, rootStepIfPresent } from './authored-root-readback.js';
|
|
27
27
|
import { parallelPromiseIntrinsicsIntact } from './authored-flow-lifecycle.js';
|
|
28
|
+
import { readAuthoredStepIndex } from './authored-step-index.js';
|
|
28
29
|
|
|
29
30
|
export type DurableAuthoredFlowResult =
|
|
30
31
|
| (AuthoredFlowExecutionResult & { readonly rootRunId: string })
|
|
@@ -391,6 +392,14 @@ async function driveRoot(
|
|
|
391
392
|
// A lost root lease must also stop body reads still inside their budget.
|
|
392
393
|
const releaseReads = journal.scopeReads?.(rootSignal) ?? (() => {});
|
|
393
394
|
try {
|
|
395
|
+
// A re-dispatched serial body replays an earlier attempt; give it that
|
|
396
|
+
// attempt's step index so a reordered resume is refused, not re-run.
|
|
397
|
+
// A first attempt has nothing to replay and reads nothing.
|
|
398
|
+
const parallel = metadata.identityVersion === 2 && metadata.executionMode === 'parallel';
|
|
399
|
+
const journaledSteps = parallel || dispatch.attempt <= 1 ? undefined : [
|
|
400
|
+
...(await readAuthoredStepIndex(journal, dispatch.run_id)).map(record => record.step),
|
|
401
|
+
...await readHumanWaitIds(journal, dispatch.run_id),
|
|
402
|
+
];
|
|
394
403
|
const callerSignal = options.lifecycle?.signal;
|
|
395
404
|
const signal = callerSignal === undefined ? rootSignal : AbortSignal.any([callerSignal, rootSignal]);
|
|
396
405
|
if (process.versions['bun'] !== undefined) {
|
|
@@ -401,6 +410,7 @@ async function driveRoot(
|
|
|
401
410
|
agentEnvironment: localAgentCredentialEnvironment(options.agentEnvironment),
|
|
402
411
|
}),
|
|
403
412
|
...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
|
|
413
|
+
...(journaledSteps === undefined ? {} : { journaledSteps }),
|
|
404
414
|
...options.lifecycle, signal,
|
|
405
415
|
});
|
|
406
416
|
}
|
|
@@ -416,8 +426,8 @@ async function driveRoot(
|
|
|
416
426
|
...(options.agentEnvironment === undefined ? {} : { agentEnvironment: options.agentEnvironment }),
|
|
417
427
|
...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
|
|
418
428
|
rootRunId: dispatch.run_id,
|
|
419
|
-
executionMode:
|
|
420
|
-
|
|
429
|
+
executionMode: parallel ? 'parallel' : 'serial',
|
|
430
|
+
...(journaledSteps === undefined ? {} : { journaledSteps }),
|
|
421
431
|
extensions: loaded.extensions,
|
|
422
432
|
flowGraph: loaded.graph,
|
|
423
433
|
...options.lifecycle,
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Serial step ids are `<kind>-<N>`, with ONE ordinal counter shared by every
|
|
5
|
+
* kind and assigned in call order (authored-flow-executor `nextId`). Inside a
|
|
6
|
+
* `Promise.all` that call order follows result arrival: each branch's next
|
|
7
|
+
* call is made when its previous step resolves. A resume replays completed
|
|
8
|
+
* results from the journal, in whatever order they come back, so two
|
|
9
|
+
* branches can take each other's ordinals.
|
|
10
|
+
*
|
|
11
|
+
* The kernel catches part of that: an admission key is bound to its spec, so
|
|
12
|
+
* `run-3` re-requested with a different command is `run_admission_conflict`.
|
|
13
|
+
* It cannot catch a KIND swap. `run-3` and `llm-3` are different admission
|
|
14
|
+
* keys, so a resumed `f.run` that lands on the ordinal an `llm` already
|
|
15
|
+
* holds gets a fresh key and runs again, exiting 0. This guard refuses that
|
|
16
|
+
* from the root's journaled step index before the step is admitted.
|
|
17
|
+
*
|
|
18
|
+
* The root driver reads the index, plus the root's `wait.human` ids, once
|
|
19
|
+
* per re-dispatched attempt (authored-root.ts); a first attempt reads
|
|
20
|
+
* nothing. The guard adds no journal record of its own. It sees what those
|
|
21
|
+
* hold — `f.llm`/`f.agent` from admission, an `f.dispatch` from its child's
|
|
22
|
+
* first step, an `f.human` from its park, other steps from completion — so an
|
|
23
|
+
* ordinal whose only holder was an `f.run` or effect still in flight at the
|
|
24
|
+
* crash is invisible to it. Stable per-branch identities (a new identity
|
|
25
|
+
* version) remove the class.
|
|
26
|
+
*/
|
|
27
|
+
export type SerialOrdinals = ReadonlyMap<number, ReadonlyArray<{ readonly kind: string; readonly step: string }>>;
|
|
28
|
+
|
|
29
|
+
const SERIAL_ID = /^(.+)-([1-9][0-9]*)$/u;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The ordinals journaled directly under `prefix` (a dispatched child's
|
|
33
|
+
* `dispatch-N--`, or '' for the root body). A descendant's step claims its
|
|
34
|
+
* dispatch's ordinal here: `dispatch-3--run-1` holds ordinal 3 for `dispatch`
|
|
35
|
+
* even before the child's receipt is journaled. Lowered `.gate` steps and
|
|
36
|
+
* anything not shaped `<kind>-<N>` hold none.
|
|
37
|
+
*/
|
|
38
|
+
export function serialOrdinals(journaledSteps: readonly string[], prefix: string): SerialOrdinals {
|
|
39
|
+
const ordinals = new Map<number, Array<{ kind: string; step: string }>>();
|
|
40
|
+
for (const step of journaledSteps) {
|
|
41
|
+
if (!step.startsWith(prefix)) continue;
|
|
42
|
+
const local = step.slice(prefix.length);
|
|
43
|
+
const descendant = local.indexOf('--');
|
|
44
|
+
const match = SERIAL_ID.exec(descendant < 0 ? local : local.slice(0, descendant));
|
|
45
|
+
if (match === null || (descendant >= 0 && match[1] !== 'dispatch')) continue;
|
|
46
|
+
const ordinal = Number(match[2]);
|
|
47
|
+
if (!Number.isSafeInteger(ordinal)) continue;
|
|
48
|
+
const holders = ordinals.get(ordinal) ?? [];
|
|
49
|
+
holders.push({ kind: match[1]!, step });
|
|
50
|
+
ordinals.set(ordinal, holders);
|
|
51
|
+
}
|
|
52
|
+
return ordinals;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Refuse `<kind>-<ordinal>` when the journal holds that ordinal under another kind. */
|
|
56
|
+
export function assertSerialOrdinal(ordinals: SerialOrdinals, kind: string, ordinal: number): void {
|
|
57
|
+
const journaled = ordinals.get(ordinal)?.find(holder => holder.kind !== kind);
|
|
58
|
+
if (journaled === undefined) return;
|
|
59
|
+
throw serialReplayOrderMismatch(
|
|
60
|
+
`ordinal ${ordinal} is journaled as ${kindName(journaled.kind)} step "${journaled.step}", `
|
|
61
|
+
+ `but this resume reached it with ${kindName(kind)}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function serialReplayOrderMismatch(detail: string): AuthoredFlowExecutionError {
|
|
66
|
+
return new AuthoredFlowExecutionError(
|
|
67
|
+
'serial_replay_order_mismatch',
|
|
68
|
+
`serial replay order mismatch: ${detail}. Concurrent branches resolved in a different order `
|
|
69
|
+
+ 'than the journaled run, so the step was refused instead of executed.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Helper ids embed the root run id (`slack-<root>-N`); name the provider only. */
|
|
74
|
+
function kindName(kind: string): string {
|
|
75
|
+
return kind.replace(/-[0-9A-HJKMNP-TV-Z]{26}$/u, '');
|
|
76
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { authoredBroker } from './authored-broker.js';
|
|
2
|
+
import { brokerInstruction, brokerOptionsError } from './communication/broker-spec.js';
|
|
1
3
|
import { dirname, resolve, isAbsolute, relative, sep } from 'node:path';
|
|
2
4
|
import type { AuthoredBudget } from './authored-budget.js';
|
|
3
5
|
import { parseBudget } from './budget.js';
|
|
@@ -159,14 +161,24 @@ export function authoredWorkerRunner(
|
|
|
159
161
|
onCompleted === undefined ? context : { ...context, onCompleted }, execution.report.status);
|
|
160
162
|
};
|
|
161
163
|
const admissionKey = authoredChildAdmissionKey(rootRunId, id);
|
|
164
|
+
const cleanup = step.type === 'agent' && brokerInstruction(step.instruction)?.operation === 'release';
|
|
162
165
|
const admit = async () => budget === undefined
|
|
163
166
|
? consume(await journal.runStart(spec, undefined, admissionKey))
|
|
164
|
-
: budget.execute(journal, spec, consume, admissionKey);
|
|
167
|
+
: budget.execute(journal, spec, consume, admissionKey, false, cleanup);
|
|
165
168
|
return slotScopes === undefined ? admit() : slotScopes[step.type === 'llm' ? 'llm' : 'agent'].run(admit);
|
|
166
169
|
}
|
|
167
170
|
|
|
171
|
+
const broker = authoredBroker(definition, rootRunId, localAgentStream, async step => {
|
|
172
|
+
const output = await run(step);
|
|
173
|
+
if (typeof output === 'object' && output !== null && AGENT_TIMEOUT in output) {
|
|
174
|
+
return { completionReason: 'timeout', summary: String(output[AGENT_TIMEOUT]), artifacts: [] };
|
|
175
|
+
}
|
|
176
|
+
return output;
|
|
177
|
+
});
|
|
168
178
|
return {
|
|
169
179
|
slots,
|
|
180
|
+
message: broker.message,
|
|
181
|
+
release: broker.release,
|
|
170
182
|
/** Refuse every agent/LLM call still waiting for a worker slot (body teardown). */
|
|
171
183
|
stop(reason: unknown): void {
|
|
172
184
|
if (ownsSlots) {
|
|
@@ -177,9 +189,12 @@ export function authoredWorkerRunner(
|
|
|
177
189
|
slotScopes?.llm.close(reason);
|
|
178
190
|
}
|
|
179
191
|
},
|
|
180
|
-
async agent(id: string, options: AgentOptions, verification?: NamedGate): Promise<AgentResult> {
|
|
192
|
+
async agent(id: string, options: AgentOptions, verification?: NamedGate, name = id): Promise<AgentResult> {
|
|
181
193
|
const secrets = validateStepSecrets((options as AgentOptions & { secrets?: unknown }).secrets,
|
|
182
194
|
agentEnvironment ?? process.env);
|
|
195
|
+
if (options.transport === 'broker') return broker.agent(id, name, options, verification, secrets);
|
|
196
|
+
const brokerProblem = brokerOptionsError(options);
|
|
197
|
+
if (brokerProblem) throw new AuthoredFlowExecutionError('agent_cli_unresolved', brokerProblem);
|
|
183
198
|
if (options.workspace !== undefined && localAgentStream !== undefined) {
|
|
184
199
|
throw new AuthoredFlowExecutionError('unsupported_workspace_permission',
|
|
185
200
|
'The local agent worker accepts stream-only steps. Remove workspace or attach a worker that holds its revision pins.');
|
package/src/budget-preflight.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { brokerInstruction } from './communication/broker-spec.js';
|
|
1
2
|
import type { CompiledFlowSpec } from './compile.js';
|
|
2
3
|
import type { PreflightWarning } from './preflight.js';
|
|
3
4
|
import { hasPricing } from './model-pricing.js';
|
|
@@ -34,10 +35,17 @@ export function budgetDiagnostics(
|
|
|
34
35
|
flow: CompiledFlowSpec,
|
|
35
36
|
resolved: ReadonlyMap<string, BudgetStepResolution> = new Map(),
|
|
36
37
|
): PreflightWarning[] {
|
|
37
|
-
|
|
38
|
+
const dollarBudget = flow.budget?.pricing === 'frozen' && flow.budget.maxDollars !== undefined;
|
|
38
39
|
const warnings: PreflightWarning[] = [];
|
|
39
40
|
for (const step of flow.steps) {
|
|
40
41
|
if (step.type === 'deterministic') continue;
|
|
42
|
+
const broker = step.type === 'agent' ? brokerInstruction(step.instruction) : undefined;
|
|
43
|
+
if (broker) {
|
|
44
|
+
if (broker.operation !== 'release') warnings.push({ severity: 'warning', kind: 'budget_unmetered', stepId: step.id,
|
|
45
|
+
message: `Step "${step.id}" uses a broker session that reports no token or dollar usage. Token and dollar ceilings cannot bound its spend; timeout bounds its duration.` });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!dollarBudget) continue;
|
|
41
49
|
const resolution = resolved.get(step.id);
|
|
42
50
|
const model = resolution?.model
|
|
43
51
|
?? step.model ?? (step.type === 'agent' && step.agent !== undefined
|
package/src/bundle-transport.ts
CHANGED
|
@@ -11,6 +11,10 @@ export class BundleFailure extends Error {
|
|
|
11
11
|
constructor(readonly kind: BundleFailureKind, message: string) { super(message); }
|
|
12
12
|
}
|
|
13
13
|
export interface DigestReference { name: string; digest: string }
|
|
14
|
+
/** Bare cloud names exclude file extensions and directory separators. */
|
|
15
|
+
export function isFlowName(value: string): boolean {
|
|
16
|
+
return /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/u.test(value);
|
|
17
|
+
}
|
|
14
18
|
export function parseDigestReference(value: string): DigestReference | undefined {
|
|
15
19
|
// Match the safe single-component name grammar used by sealBundle.
|
|
16
20
|
const match = /^([A-Za-z0-9][A-Za-z0-9._-]*)@sha256:([0-9a-f]{64})$/.exec(value);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { mkdir, readFile, realpath, symlink } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
/** Cached single-file flows use the CLI's installed authoring runtime. */
|
|
7
|
+
export async function prepareCachedFlowRuntime(directory: string): Promise<void> {
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const packages = [
|
|
10
|
+
['@relayflows/surface', require.resolve('@relayflows/surface/runtime')],
|
|
11
|
+
['@relayflows/sdk', fileURLToPath(import.meta.url)],
|
|
12
|
+
] as const;
|
|
13
|
+
for (const [name, entry] of packages) {
|
|
14
|
+
let root = dirname(entry);
|
|
15
|
+
for (;;) {
|
|
16
|
+
const manifest = await readFile(join(root, 'package.json'), 'utf8').catch(() => '{}');
|
|
17
|
+
if ((JSON.parse(manifest) as { name?: string }).name === name) break;
|
|
18
|
+
const parent = dirname(root);
|
|
19
|
+
if (parent === root) throw new Error(`Cannot locate installed ${name}.`);
|
|
20
|
+
root = parent;
|
|
21
|
+
}
|
|
22
|
+
const target = join(directory, 'node_modules', name);
|
|
23
|
+
await mkdir(dirname(target), { recursive: true });
|
|
24
|
+
try { await symlink(root, target, 'dir'); }
|
|
25
|
+
catch (error) {
|
|
26
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
27
|
+
if (await realpath(target) !== await realpath(root)) {
|
|
28
|
+
throw new Error(`Cached runtime at ${target} differs from this CLI. Remove that link and retry.`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/cli/check.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { checkBrokerEnvironment } from '../communication/broker-preflight.js';
|
|
2
|
+
import { brokerInstruction } from '../communication/broker-spec.js';
|
|
1
3
|
import { validateSpecWithDaemon, type DaemonValidation, type DaemonValidationMode, type DaemonValidationDeps } from '../daemon-spec-validation.js';
|
|
2
4
|
import { communicationInstruction } from '../communication/spec.js';
|
|
3
5
|
import { rememberResolvedCliIdentities } from '../resolved-cli-identity.js';
|
|
@@ -246,6 +248,14 @@ export function checkAuthoredFlow(
|
|
|
246
248
|
effectiveProbeCache,
|
|
247
249
|
)
|
|
248
250
|
: undefined;
|
|
251
|
+
if (flow?.steps.some(step => step.type === 'agent' && brokerInstruction(step.instruction)?.operation === 'spawn')) {
|
|
252
|
+
try { checkBrokerEnvironment(); }
|
|
253
|
+
catch (error) {
|
|
254
|
+
result.ok = false;
|
|
255
|
+
result.diagnostics.push({ severity: 'refusal', kind: 'probe_failed',
|
|
256
|
+
message: error instanceof Error ? error.message : 'Broker environment could not be checked.' });
|
|
257
|
+
}
|
|
258
|
+
}
|
|
249
259
|
if (flow?.steps.some(step => step.type === 'agent' && communicationInstruction(step.instruction))) {
|
|
250
260
|
try { if (options?.communicationChecked !== true) checkCommunicationEnvironment(flow); }
|
|
251
261
|
catch (error) {
|
|
@@ -520,7 +530,7 @@ function bindResolvedCliPaths(
|
|
|
520
530
|
const resolution = byStep.get(step.id);
|
|
521
531
|
if (resolution === undefined) return step;
|
|
522
532
|
const directory = resolution.source === 'project' ? configDirectory : flowDirectory;
|
|
523
|
-
const managed = step.type === 'agent' && communicationInstruction(step.instruction) !== undefined;
|
|
533
|
+
const managed = step.type === 'agent' && (communicationInstruction(step.instruction) !== undefined || brokerInstruction(step.instruction) !== undefined);
|
|
524
534
|
const outcome = probeCache.get(cliProbeKey(resolution, managed, step.secrets));
|
|
525
535
|
const executable = outcome !== undefined && 'result' in outcome
|
|
526
536
|
? outcome.result.executable
|
package/src/cli/cloud-deploy.ts
CHANGED
|
@@ -98,7 +98,7 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
|
|
|
98
98
|
value = arg;
|
|
99
99
|
}
|
|
100
100
|
if (value === undefined) return undefined;
|
|
101
|
-
if (flow === undefined &&
|
|
101
|
+
if (flow === undefined && on.length > 0 && repo === undefined) return undefined;
|
|
102
102
|
return { command: 'cloud-deploy', value, flow, repo, on, approver, name, agents, draft, noConnect, json, plugins };
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -119,7 +119,7 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
|
|
|
119
119
|
// by deployToCloud, not here.
|
|
120
120
|
let harnesses: readonly string[] = agents instanceof CloudFlowError ? [] : agents ?? [];
|
|
121
121
|
try {
|
|
122
|
-
if (args.approver === undefined) {
|
|
122
|
+
if (args.on.length > 0 && args.approver === undefined) {
|
|
123
123
|
throw new CloudFlowError('invalid_input',
|
|
124
124
|
'--approver <handle> is required: every launched run receives it as input.approver for f.human.');
|
|
125
125
|
}
|
|
@@ -128,7 +128,7 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
|
|
|
128
128
|
if (agents === undefined) harnesses = (await flowRequirementsForPath(args.value))?.harnesses ?? [];
|
|
129
129
|
const deployment = await deployToCloud({
|
|
130
130
|
path: args.value,
|
|
131
|
-
repository: parseRepository(args.repo
|
|
131
|
+
...(args.repo === undefined ? {} : { repository: parseRepository(args.repo) }),
|
|
132
132
|
sources: args.on.map(parseTriggerSource),
|
|
133
133
|
approver: args.approver,
|
|
134
134
|
draft: args.draft,
|
|
@@ -144,12 +144,12 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
|
|
|
144
144
|
io.stdout(`${deployment.status === 'draft' ? 'SAVED' : 'DEPLOYED'} ${deployment.agentId} ${deployment.status}`
|
|
145
145
|
+ (deployment.version === undefined ? '' : ` · ${describeVersionChange(deployment.version)}`));
|
|
146
146
|
io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
|
|
147
|
-
io.stdout(` repository: ${describeRepository(deployment.repository)}`);
|
|
147
|
+
if (deployment.repository) io.stdout(` repository: ${describeRepository(deployment.repository)}`);
|
|
148
148
|
for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
|
|
149
149
|
const requires = describeFlowRequirements(deployment.requirements);
|
|
150
150
|
if (requires) io.stdout(` requires: ${requires}`);
|
|
151
151
|
for (const provider of deployment.connected) io.stdout(` connected: ${provider}`);
|
|
152
|
-
io.stdout(deployment.status === 'draft'
|
|
152
|
+
io.stdout(deployment.sources.length === 0 ? `Run locally: flows run ${deployment.name}` : deployment.status === 'draft'
|
|
153
153
|
? 'Saved without activating; activate it from the Cloud dashboard, or redeploy without --draft.'
|
|
154
154
|
: 'Each matching ticket launches a run of this source in a fresh branch; list with: flows deployments');
|
|
155
155
|
return 0;
|
|
@@ -1,26 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
4
|
-
// Opt-in, and deliberately the only opt-in thing on this path.
|
|
5
|
-
//
|
|
6
|
-
// A local run is already watchable by default: `observer-session.ts` beside
|
|
7
|
-
// this one projects the run into its own channel and prints a read-only link,
|
|
8
|
-
// off a workspace key, for free. That is the right default — it is a step
|
|
9
|
-
// projection, it costs nothing, and a run that never joins a workspace is not
|
|
10
|
-
// a defect (RFC-0001 settled decision 7).
|
|
11
|
-
//
|
|
12
|
-
// The dashboard is the richer, hosted view of the same run: the flow source,
|
|
13
|
-
// every step's transcript, the run graph, the logs, and the run sitting in the
|
|
14
|
-
// same history as the hosted ones. It is also the one that *stores* all of
|
|
15
|
-
// that. So it is asked for — `--cloud-mirror`, or `FLOWS_CLOUD_MIRROR=1` for a
|
|
16
|
-
// shell — and never turned on by a login happening to be present. Mirroring
|
|
17
|
-
// someone's local runs because they once signed in is not a default anyone
|
|
18
|
-
// consented to.
|
|
19
|
-
//
|
|
20
|
-
// Best-effort once it is on, like the observer: a registration failure is one
|
|
21
|
-
// labeled stderr line, and nothing here can change a run's exit code or its
|
|
22
|
-
// journal. But it is a *louder* line than it used to be, because the run asked
|
|
23
|
-
// for this and did not get it.
|
|
1
|
+
// Signed-in local runs project their journal onto the Cloud dashboard.
|
|
2
|
+
// Reporting is best-effort and never changes the durable run outcome.
|
|
24
3
|
|
|
25
4
|
import { readFile } from 'node:fs/promises';
|
|
26
5
|
import type { CliIo } from '../cli.js';
|
|
@@ -59,7 +38,7 @@ export interface CloudMirrorSession {
|
|
|
59
38
|
*/
|
|
60
39
|
receipt(): Promise<CloudMirrorReceipt | undefined>;
|
|
61
40
|
/** A journaled entry; the first one names the run for a declarative flow. */
|
|
62
|
-
onJournalEntry(entry: { run_id: string }): void;
|
|
41
|
+
onJournalEntry(entry: { run_id: string; entry_type?: string; step_id?: string | null }): void;
|
|
63
42
|
/** Step transitions, pushed as lifecycle events the run page's stream shows. */
|
|
64
43
|
onProgress(event: ProgressEvent): void;
|
|
65
44
|
/** Publish the final report and the terminal status. Never rejects. */
|
|
@@ -82,7 +61,7 @@ export interface CloudMirrorRequest {
|
|
|
82
61
|
/** Lines the CLI has printed for this run, uploaded as the run's `runner.log`. */
|
|
83
62
|
log: () => readonly string[];
|
|
84
63
|
/** How the dashboard was asked for, so a refusal can name the right switch. */
|
|
85
|
-
requested: 'flag' | 'env';
|
|
64
|
+
requested: 'flag' | 'env' | 'login';
|
|
86
65
|
}
|
|
87
66
|
|
|
88
67
|
/**
|
|
@@ -147,7 +126,14 @@ export function createCloudMirrorSession(
|
|
|
147
126
|
void open(run.runId);
|
|
148
127
|
},
|
|
149
128
|
onJournalEntry(entry) {
|
|
150
|
-
|
|
129
|
+
const opened = open(entry.run_id);
|
|
130
|
+
if (entry.step_id && (entry.entry_type === 'step.attempt.started' || entry.entry_type === 'step.completed')) {
|
|
131
|
+
void opened.then(mirror => mirror?.event({
|
|
132
|
+
eventType: entry.entry_type === 'step.completed' ? 'relayflow.step.completed' : 'relayflow.step.started',
|
|
133
|
+
stepName: entry.step_id!,
|
|
134
|
+
payload: { journalRunId: entry.run_id },
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
151
137
|
},
|
|
152
138
|
async receipt() {
|
|
153
139
|
if (opening === undefined) return undefined;
|
|
@@ -194,6 +180,7 @@ export function mirrorSourceFromPath(
|
|
|
194
180
|
path: string,
|
|
195
181
|
inputArgument: string | undefined,
|
|
196
182
|
dataDir: string,
|
|
183
|
+
named?: { flowId: string; flowVersionId: string; source: string },
|
|
197
184
|
): (runId: string) => Promise<MirrorRunSource> {
|
|
198
185
|
const fromJournal = mirrorSourceFromJournal(dataDir);
|
|
199
186
|
return async (runId) => {
|
|
@@ -203,9 +190,10 @@ export function mirrorSourceFromPath(
|
|
|
203
190
|
// the dashboard, so fall back to what the journal recorded — the same
|
|
204
191
|
// source a resume mirrors.
|
|
205
192
|
if (parseDigestReference(path)) return fromJournal(runId);
|
|
206
|
-
const workflow = await readFile(path, 'utf8');
|
|
193
|
+
const workflow = named?.source ?? await readFile(path, 'utf8');
|
|
207
194
|
if (!isAuthoredFlowPath(path)) return { workflow, fileType: 'yaml' };
|
|
208
|
-
return { workflow, fileType: 'ts', inputs: parseDirectInput(inputArgument)
|
|
195
|
+
return { workflow, fileType: 'ts', inputs: parseDirectInput(inputArgument),
|
|
196
|
+
...(named === undefined ? {} : { flowId: named.flowId, flowVersionId: named.flowVersionId }) };
|
|
209
197
|
};
|
|
210
198
|
}
|
|
211
199
|
|
|
@@ -305,7 +293,13 @@ function completionReport(report: RunReport): Record<string, unknown> {
|
|
|
305
293
|
* missing-login case is the common one and names the two commands that fix
|
|
306
294
|
* it: sign in, or stop asking.
|
|
307
295
|
*/
|
|
308
|
-
function mirrorRefusal(error: unknown, requested: 'flag' | 'env'): string {
|
|
296
|
+
function mirrorRefusal(error: unknown, requested: 'flag' | 'env' | 'login'): string {
|
|
297
|
+
if (requested === 'login') {
|
|
298
|
+
if (error instanceof CloudFlowError && error.reason === 'auth_missing') {
|
|
299
|
+
return 'Not signed in; this run stays local. Sign in with `agent-relay cloud login` to report runs.';
|
|
300
|
+
}
|
|
301
|
+
return `Local run reporting unavailable: ${error instanceof Error ? error.message : String(error)}; the run itself is unaffected.`;
|
|
302
|
+
}
|
|
309
303
|
const asked = requested === 'flag' ? '--cloud-mirror' : `${MIRROR_ENV}=1`;
|
|
310
304
|
if (error instanceof CloudFlowError && error.code === 'configuration') {
|
|
311
305
|
return error.reason === 'auth_missing'
|