@relayflows/sdk 2.0.55 → 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.
Files changed (179) hide show
  1. package/dist/authored-broker.d.ts +13 -0
  2. package/dist/authored-broker.d.ts.map +1 -0
  3. package/dist/authored-broker.js +75 -0
  4. package/dist/authored-broker.js.map +1 -0
  5. package/dist/authored-budget.d.ts +1 -1
  6. package/dist/authored-budget.d.ts.map +1 -1
  7. package/dist/authored-budget.js +8 -2
  8. package/dist/authored-budget.js.map +1 -1
  9. package/dist/authored-flow-error.d.ts +6 -0
  10. package/dist/authored-flow-error.d.ts.map +1 -1
  11. package/dist/authored-flow-error.js.map +1 -1
  12. package/dist/authored-flow-executor.d.ts +11 -0
  13. package/dist/authored-flow-executor.d.ts.map +1 -1
  14. package/dist/authored-flow-executor.js +104 -20
  15. package/dist/authored-flow-executor.js.map +1 -1
  16. package/dist/authored-flow-loader.d.ts.map +1 -1
  17. package/dist/authored-flow-loader.js +42 -3
  18. package/dist/authored-flow-loader.js.map +1 -1
  19. package/dist/authored-helper-effect.d.ts.map +1 -1
  20. package/dist/authored-helper-effect.js +1 -1
  21. package/dist/authored-helper-effect.js.map +1 -1
  22. package/dist/authored-hooks.d.ts +2 -1
  23. package/dist/authored-hooks.d.ts.map +1 -1
  24. package/dist/authored-hooks.js +2 -2
  25. package/dist/authored-hooks.js.map +1 -1
  26. package/dist/authored-human.d.ts +18 -0
  27. package/dist/authored-human.d.ts.map +1 -1
  28. package/dist/authored-human.js +32 -4
  29. package/dist/authored-human.js.map +1 -1
  30. package/dist/authored-mcp.d.ts.map +1 -1
  31. package/dist/authored-mcp.js +3 -2
  32. package/dist/authored-mcp.js.map +1 -1
  33. package/dist/authored-node-entry.js +4 -0
  34. package/dist/authored-node-entry.js.map +1 -1
  35. package/dist/authored-node-runner.d.ts.map +1 -1
  36. package/dist/authored-node-runner.js +1 -0
  37. package/dist/authored-node-runner.js.map +1 -1
  38. package/dist/authored-plugin-effect.d.ts.map +1 -1
  39. package/dist/authored-plugin-effect.js +3 -2
  40. package/dist/authored-plugin-effect.js.map +1 -1
  41. package/dist/authored-preflight.d.ts.map +1 -1
  42. package/dist/authored-preflight.js +2 -1
  43. package/dist/authored-preflight.js.map +1 -1
  44. package/dist/authored-root.d.ts.map +1 -1
  45. package/dist/authored-root.js +13 -3
  46. package/dist/authored-root.js.map +1 -1
  47. package/dist/authored-serial-ordinals.d.ts +41 -0
  48. package/dist/authored-serial-ordinals.d.ts.map +1 -0
  49. package/dist/authored-serial-ordinals.js +45 -0
  50. package/dist/authored-serial-ordinals.js.map +1 -0
  51. package/dist/authored-worker-step.d.ts +5 -1
  52. package/dist/authored-worker-step.d.ts.map +1 -1
  53. package/dist/authored-worker-step.js +19 -2
  54. package/dist/authored-worker-step.js.map +1 -1
  55. package/dist/budget-preflight.d.ts.map +1 -1
  56. package/dist/budget-preflight.js +11 -2
  57. package/dist/budget-preflight.js.map +1 -1
  58. package/dist/bundle-transport.d.ts +2 -0
  59. package/dist/bundle-transport.d.ts.map +1 -1
  60. package/dist/bundle-transport.js +4 -0
  61. package/dist/bundle-transport.js.map +1 -1
  62. package/dist/cached-flow-runtime.d.ts +3 -0
  63. package/dist/cached-flow-runtime.d.ts.map +1 -0
  64. package/dist/cached-flow-runtime.js +37 -0
  65. package/dist/cached-flow-runtime.js.map +1 -0
  66. package/dist/cli/check.d.ts.map +1 -1
  67. package/dist/cli/check.js +13 -1
  68. package/dist/cli/check.js.map +1 -1
  69. package/dist/cli/cloud-deploy.js +6 -5
  70. package/dist/cli/cloud-deploy.js.map +1 -1
  71. package/dist/cli/cloud-mirror-session.d.ts +8 -2
  72. package/dist/cli/cloud-mirror-session.d.ts.map +1 -1
  73. package/dist/cli/cloud-mirror-session.js +20 -27
  74. package/dist/cli/cloud-mirror-session.js.map +1 -1
  75. package/dist/cli/direct-run.d.ts.map +1 -1
  76. package/dist/cli/direct-run.js +2 -1
  77. package/dist/cli/direct-run.js.map +1 -1
  78. package/dist/cli/run.d.ts.map +1 -1
  79. package/dist/cli/run.js +19 -4
  80. package/dist/cli/run.js.map +1 -1
  81. package/dist/cli.d.ts.map +1 -1
  82. package/dist/cli.js +39 -18
  83. package/dist/cli.js.map +1 -1
  84. package/dist/cloud-deploy.d.ts +3 -3
  85. package/dist/cloud-deploy.d.ts.map +1 -1
  86. package/dist/cloud-deploy.js +15 -9
  87. package/dist/cloud-deploy.js.map +1 -1
  88. package/dist/cloud-flow-source.d.ts +13 -0
  89. package/dist/cloud-flow-source.d.ts.map +1 -0
  90. package/dist/cloud-flow-source.js +55 -0
  91. package/dist/cloud-flow-source.js.map +1 -0
  92. package/dist/cloud-mirror-transport.d.ts +2 -0
  93. package/dist/cloud-mirror-transport.d.ts.map +1 -1
  94. package/dist/cloud-mirror-transport.js +2 -0
  95. package/dist/cloud-mirror-transport.js.map +1 -1
  96. package/dist/cloud-mirror.d.ts.map +1 -1
  97. package/dist/cloud-mirror.js +10 -1
  98. package/dist/cloud-mirror.js.map +1 -1
  99. package/dist/communication/broker-preflight.d.ts +3 -0
  100. package/dist/communication/broker-preflight.d.ts.map +1 -0
  101. package/dist/communication/broker-preflight.js +22 -0
  102. package/dist/communication/broker-preflight.js.map +1 -0
  103. package/dist/communication/broker-spec.d.ts +20 -0
  104. package/dist/communication/broker-spec.d.ts.map +1 -0
  105. package/dist/communication/broker-spec.js +46 -0
  106. package/dist/communication/broker-spec.js.map +1 -0
  107. package/dist/communication/broker-worker.d.ts +17 -0
  108. package/dist/communication/broker-worker.d.ts.map +1 -0
  109. package/dist/communication/broker-worker.js +214 -0
  110. package/dist/communication/broker-worker.js.map +1 -0
  111. package/dist/communication/pty.d.ts +29 -0
  112. package/dist/communication/pty.d.ts.map +1 -0
  113. package/dist/communication/pty.js +40 -0
  114. package/dist/communication/pty.js.map +1 -0
  115. package/dist/communication/relay.d.ts +18 -0
  116. package/dist/communication/relay.d.ts.map +1 -1
  117. package/dist/communication/relay.js +4 -0
  118. package/dist/communication/relay.js.map +1 -1
  119. package/dist/communication/skills.d.ts +3 -0
  120. package/dist/communication/skills.d.ts.map +1 -0
  121. package/dist/communication/skills.js +17 -0
  122. package/dist/communication/skills.js.map +1 -0
  123. package/dist/communication/worker.d.ts.map +1 -1
  124. package/dist/communication/worker.js +9 -26
  125. package/dist/communication/worker.js.map +1 -1
  126. package/dist/failure-kinds.d.ts +1 -1
  127. package/dist/failure-kinds.d.ts.map +1 -1
  128. package/dist/failure-kinds.js +2 -0
  129. package/dist/failure-kinds.js.map +1 -1
  130. package/dist/plugin-manifest.js +1 -1
  131. package/dist/plugin-manifest.js.map +1 -1
  132. package/dist/preflight.d.ts.map +1 -1
  133. package/dist/preflight.js +2 -1
  134. package/dist/preflight.js.map +1 -1
  135. package/dist/worker.d.ts +1 -0
  136. package/dist/worker.d.ts.map +1 -1
  137. package/dist/worker.js +12 -0
  138. package/dist/worker.js.map +1 -1
  139. package/package.json +2 -2
  140. package/src/authored-broker.ts +73 -0
  141. package/src/authored-budget.ts +6 -2
  142. package/src/authored-flow-error.ts +6 -0
  143. package/src/authored-flow-executor.ts +115 -20
  144. package/src/authored-flow-loader.ts +43 -3
  145. package/src/authored-helper-effect.ts +2 -1
  146. package/src/authored-hooks.ts +4 -3
  147. package/src/authored-human.ts +35 -4
  148. package/src/authored-mcp.ts +7 -2
  149. package/src/authored-node-entry.ts +4 -1
  150. package/src/authored-node-runner.ts +1 -0
  151. package/src/authored-plugin-effect.ts +7 -2
  152. package/src/authored-preflight.ts +2 -1
  153. package/src/authored-root.ts +13 -3
  154. package/src/authored-serial-ordinals.ts +76 -0
  155. package/src/authored-worker-step.ts +17 -2
  156. package/src/budget-preflight.ts +9 -1
  157. package/src/bundle-transport.ts +4 -0
  158. package/src/cached-flow-runtime.ts +32 -0
  159. package/src/cli/check.ts +11 -1
  160. package/src/cli/cloud-deploy.ts +5 -5
  161. package/src/cli/cloud-mirror-session.ts +23 -29
  162. package/src/cli/direct-run.ts +2 -1
  163. package/src/cli/run.ts +18 -3
  164. package/src/cli.ts +31 -13
  165. package/src/cloud-deploy.ts +19 -14
  166. package/src/cloud-flow-source.ts +58 -0
  167. package/src/cloud-mirror-transport.ts +4 -0
  168. package/src/cloud-mirror.ts +9 -1
  169. package/src/communication/broker-preflight.ts +22 -0
  170. package/src/communication/broker-spec.ts +48 -0
  171. package/src/communication/broker-worker.ts +198 -0
  172. package/src/communication/pty.ts +47 -0
  173. package/src/communication/relay.ts +12 -1
  174. package/src/communication/skills.ts +16 -0
  175. package/src/communication/worker.ts +9 -26
  176. package/src/failure-kinds.ts +2 -0
  177. package/src/plugin-manifest.ts +1 -1
  178. package/src/preflight.ts +3 -1
  179. package/src/worker.ts +11 -0
@@ -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: metadata.identityVersion === 2 && metadata.executionMode === 'parallel'
420
- ? 'parallel' : 'serial',
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.');
@@ -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
- if (flow.budget?.pricing !== 'frozen' || flow.budget.maxDollars === undefined) return [];
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
@@ -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
@@ -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 && (repo === undefined || on.length === 0)) return 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
- // The Cloud half of `flows run` / `flows resume`: also put this local run on
2
- // the Cloud dashboard.
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
- void open(entry.run_id);
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'
@@ -221,7 +221,8 @@ export async function runDirectFlow(
221
221
  // diagnostic carried up from `classifyOutcome` already names the step, its
222
222
  // exit code and its output tail; this branch is what lets it reach the
223
223
  // terminal. `resumeFlow` takes the same branch, through the same helper.
224
- if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
224
+ if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed'
225
+ || error.code === 'serial_replay_order_mismatch')) {
225
226
  return authoredStepFailure('run', base, socketPath, error);
226
227
  }
227
228
  if (error instanceof AuthoredFlowExecutionError && error.code === 'result_unreadable') {
package/src/cli/run.ts CHANGED
@@ -7,6 +7,7 @@ import { communicationInstruction } from '../communication/spec.js';
7
7
  import { checkCommunicationEnvironment, CommunicationEnvironmentError } from '../communication/preflight.js';
8
8
  import { parseHumanRecipient } from '../human-to.js';
9
9
  import { parseDigestReference } from '../bundle-transport.js';
10
+ import { resolveNamedFlow } from '../cloud-flow-source.js';
10
11
  import { prepareDigestRun } from './run-digest.js';
11
12
  import { attachReuseSummary } from './reuse.js';
12
13
  import { resumeHelperEffect } from '../authored-helper-effect.js';
@@ -47,6 +48,7 @@ import { DEFAULT_LOCAL_AGENT_CAPACITY } from '../worker-slots.js';
47
48
  import { localAgentEnvironment } from '../local-agent-environment.js';
48
49
  import {
49
50
  checkFlow,
51
+ inputFailureReport,
50
52
  type CheckReport,
51
53
  } from './check.js';
52
54
 
@@ -182,6 +184,17 @@ export async function runFlow(
182
184
  dataDir: string,
183
185
  options: RunLifecycleOptions = {},
184
186
  ): Promise<RunExecution> {
187
+ try {
188
+ const named = await resolveNamedFlow(path);
189
+ if (named) {
190
+ const { runDirectFlow } = await import('./direct-run.js');
191
+ return runDirectFlow(named.path, '{}', dataDir, options);
192
+ }
193
+ } catch (error) {
194
+ return { exitCode: 2, report: fromCheckReport('run', inputFailureReport({
195
+ kind: 'input_unreadable', message: error instanceof Error ? error.message : String(error),
196
+ }, path)) };
197
+ }
185
198
  const agentEnvironment = localAgentEnvironment();
186
199
  const prepared = parseDigestReference(path) ? await prepareDigestRun(path, options.bucket) : undefined;
187
200
  if (prepared && 'exitCode' in prepared) return prepared;
@@ -432,7 +445,8 @@ export async function resumeFlow(
432
445
  // leaving it on `protocolFailure` meant `flows run` printed the evidence
433
446
  // while `flows resume` still printed `protocol_error` and
434
447
  // `RUN <id> unknown` for the identical failure.
435
- if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed')) {
448
+ if (error instanceof AuthoredFlowExecutionError && (error.code === 'step_failed' || error.code === 'gate_failed'
449
+ || error.code === 'serial_replay_order_mismatch')) {
436
450
  return authoredStepFailure('resume', base, socketPath, error, runId);
437
451
  }
438
452
  if (error instanceof AuthoredFlowExecutionError && error.code === 'result_unreadable') {
@@ -632,10 +646,11 @@ export function authoredStepFailure(
632
646
  severity: 'failure',
633
647
  // A predicate gate that judged false is a run failure with its own
634
648
  // name, so the report says which kind of check the body did not pass.
635
- kind: error.code === 'gate_failed' ? 'gate_failed' : 'step_failed',
649
+ // A refused out-of-order replay names itself the same way.
650
+ kind: error.code === 'gate_failed' || error.code === 'serial_replay_order_mismatch' ? error.code : 'step_failed',
636
651
  // The `step_failed: ` prefix `AuthoredFlowExecutionError` adds is
637
652
  // redundant once the diagnostic is labelled `[step_failed]`.
638
- message: error.message.replace(/^(?:step_failed|gate_failed): /, ''),
653
+ message: error.message.replace(/^(?:step_failed|gate_failed|serial_replay_order_mismatch): /, ''),
639
654
  }],
640
655
  },
641
656
  };
package/src/cli.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { resolveNamedFlow, type NamedFlowSource } from './cloud-flow-source.js';
3
+ import { isFlowName } from './bundle-transport.js';
4
+ import { CloudFlowError } from './cloud-http.js';
2
5
  import { unavailableValidation, validationAnnotation, type DaemonValidationMode } from './daemon-spec-validation.js';
3
6
  import { foldUnprovableEffects, type FoldOptions } from './cli/diagnostic-fold.js';
4
7
  import { startDetachedRun, emitDetachedHandle } from './cli/detached-run.js';
@@ -134,6 +137,7 @@ const USAGE = [
134
137
  'flows plugin update [--json] [--yes] [--to <ref>] [<name>]',
135
138
  'flows build [--out <dir>] <flow.yaml|flow.ts>',
136
139
  'flows build --verify <bundle-dir>',
140
+ 'flows deploy <flow.ts> [--name <name>] [--json]',
137
141
  'flows deploy <flow.ts> --repo <owner/name|gitlab:group/project> --on <provider>[:key=value,...] [--on ...] --approver <handle> [--agents claude[,codex]] [--name <name>] [--draft] [--plugin <ref>] [--no-connect] [--json]',
138
142
  'flows deploy <flow.ts> --flow <name|listener-id> [--plugin <ref>] [--no-connect] [--json]',
139
143
  'flows deployments [--json]',
@@ -152,6 +156,7 @@ const USAGE = [
152
156
  'flows run --cloud [--json] [--wait] [--sync-code] [--no-connect] [--budget-minutes <n>] <flow.ts> --input <inline-json-or-file>',
153
157
  'flows sync [--json] [--dry-run] [--dir <path>] <run-id>',
154
158
  'flows run [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <flow.ts> --input <inline-json-or-file>',
159
+ 'flows run <name> [--input <inline-json-or-file>]',
155
160
  'flows tick start --schedule-id <id> --interval-ms <ms> [--epoch-ms <ms>] [--max-catch-up <n>] [--poll-interval-ms <ms>] [--data-dir <dir>] <spec.json>',
156
161
  'flows resume [--allow-human-influenced] [--json] [--no-spawn] [--no-observer-link] [--cloud-mirror] [--detach] [--data-dir <dir>] [--local-agent [--agent-capacity <n>]] <run-id>',
157
162
  'flows answer --cloud [--json] [--note <text>] [--source <path>] <run-id> <yes|no>',
@@ -259,7 +264,7 @@ export async function runCli(
259
264
  const link = resolveObserverLinkEnv(process.env);
260
265
  if (!link.suppressed && link.workspaceKey === undefined) {
261
266
  io.stderr('Observer unavailable: no workspace key configured; set RELAYCAST_WORKSPACE_KEY or configure '
262
- + '~/.agentworkforce/relay/workspaces.json for an Observer link, or pass --cloud-mirror for the Cloud dashboard');
267
+ + '~/.agentworkforce/relay/workspaces.json for an Observer link, or sign in with agent-relay cloud login for the Cloud dashboard');
263
268
  }
264
269
  }
265
270
 
@@ -379,6 +384,27 @@ export async function runCli(
379
384
  }, io) as CliExitCode);
380
385
  }
381
386
 
387
+ let named: NamedFlowSource | undefined;
388
+ if (parsed.command === 'run') {
389
+ try {
390
+ named = await resolveNamedFlow(parsed.value);
391
+ if (!named && parsed.input !== undefined && !isAuthoredFlowPath(parsed.value)) {
392
+ throw new CloudFlowError('invalid_input', '--input requires an authored .flow.ts or a saved flow name.');
393
+ }
394
+ if (named) {
395
+ if (parsed.reuseFromRunId !== undefined) throw new CloudFlowError('invalid_input', '--reuse-from is not supported for authored named flows.');
396
+ parsed.value = named.path;
397
+ parsed.input ??= '{}';
398
+ }
399
+ } catch (error) {
400
+ const code = error instanceof CloudFlowError ? error.refusal?.code ?? error.code : 'flow_resolution_failed';
401
+ const message = error instanceof Error ? error.message : String(error);
402
+ if (parsed.json) io.stdout(JSON.stringify({ ok: false, code, message }));
403
+ else io.stderr(`${code}: ${message}`);
404
+ return 2;
405
+ }
406
+ }
407
+
382
408
  // Attach-or-spawn runs inside `runFlow`/`resumeFlow`/`runDirectFlow`, at the
383
409
  // single `connect()` seam immediately before journal-client.ts is used --
384
410
  // not here. Hoisting it above the dispatch would start a daemon as a side
@@ -388,21 +414,13 @@ export async function runCli(
388
414
  onObserverUrl: url => options.detachedReceipt?.observerUrl(url),
389
415
  });
390
416
  const runnerLog: string[] = [];
391
- // Opt-in, unlike the observer link beside it. The observer is the default
392
- // way to watch a local run: it is free, it needs only a workspace key, and
393
- // it carries a step projection. The dashboard is the richer, hosted view —
394
- // it stores the flow source, every step's transcript and this invocation's
395
- // own output — so a local run joins it because someone asked, never because
396
- // a login happened to be lying around.
397
- const mirror = !(parsed.cloudMirror || cloudMirrorRequested(process.env))
398
- ? undefined
399
- : createCloudMirrorSession({
417
+ const mirror = createCloudMirrorSession({
400
418
  source: parsed.command === 'run'
401
- ? mirrorSourceFromPath(parsed.value, parsed.input, parsed.dataDir)
419
+ ? mirrorSourceFromPath(parsed.value, parsed.input, parsed.dataDir, named)
402
420
  : mirrorSourceFromJournal(parsed.dataDir),
403
421
  dataDir: parsed.dataDir,
404
422
  log: () => runnerLog,
405
- requested: parsed.cloudMirror ? 'flag' : 'env',
423
+ requested: parsed.cloudMirror ? 'flag' : cloudMirrorRequested(process.env) ? 'env' : 'login',
406
424
  }, io);
407
425
  // Everything this invocation prints, kept in order, so the mirror can upload
408
426
  // it as the run's `runner.log` — the object the dashboard's log pane reads,
@@ -888,7 +906,7 @@ function parseArgs(args: readonly string[]): ParsedArgs | undefined {
888
906
  if (wait || syncCode || noConnect || budgetMinutes !== undefined) return undefined;
889
907
  if (reuseFromRunId !== undefined && isAuthoredFlowPath(positionals[0]!)) return undefined;
890
908
 
891
- if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!)) return undefined;
909
+ if (command === 'run' && input !== undefined && !isAuthoredFlowPath(positionals[0]!) && !isFlowName(positionals[0]!)) return undefined;
892
910
  return command === 'check'
893
911
  ? { command, json, watch, explainWarnings, value: positionals[0]!,
894
912
  daemonValidation: daemonValidation ?? (process.env['FLOWS_NO_DAEMON_CHECK'] === '1' ? 'off'