@relayflows/sdk 2.0.16 → 2.0.18

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 (184) hide show
  1. package/dist/authored-flow-error.d.ts +21 -1
  2. package/dist/authored-flow-error.d.ts.map +1 -1
  3. package/dist/authored-flow-error.js +14 -0
  4. package/dist/authored-flow-error.js.map +1 -1
  5. package/dist/authored-flow-executor.d.ts.map +1 -1
  6. package/dist/authored-flow-executor.js +133 -3
  7. package/dist/authored-flow-executor.js.map +1 -1
  8. package/dist/authored-flow-lifecycle.d.ts +10 -0
  9. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  10. package/dist/authored-flow-lifecycle.js +7 -0
  11. package/dist/authored-flow-lifecycle.js.map +1 -1
  12. package/dist/authored-flow-operation.d.ts +11 -0
  13. package/dist/authored-flow-operation.d.ts.map +1 -1
  14. package/dist/authored-flow-operation.js +32 -7
  15. package/dist/authored-flow-operation.js.map +1 -1
  16. package/dist/authored-human.d.ts +49 -0
  17. package/dist/authored-human.d.ts.map +1 -0
  18. package/dist/authored-human.js +97 -0
  19. package/dist/authored-human.js.map +1 -0
  20. package/dist/authored-node-entry.js +3 -2
  21. package/dist/authored-node-entry.js.map +1 -1
  22. package/dist/authored-node-runner.d.ts.map +1 -1
  23. package/dist/authored-node-runner.js +52 -18
  24. package/dist/authored-node-runner.js.map +1 -1
  25. package/dist/authored-root.d.ts.map +1 -1
  26. package/dist/authored-root.js +29 -1
  27. package/dist/authored-root.js.map +1 -1
  28. package/dist/authored-worker-step.d.ts.map +1 -1
  29. package/dist/authored-worker-step.js +14 -25
  30. package/dist/authored-worker-step.js.map +1 -1
  31. package/dist/cli/answer.d.ts +17 -0
  32. package/dist/cli/answer.d.ts.map +1 -0
  33. package/dist/cli/answer.js +89 -0
  34. package/dist/cli/answer.js.map +1 -0
  35. package/dist/cli/build.js +1 -1
  36. package/dist/cli/build.js.map +1 -1
  37. package/dist/cli/check-triggers.d.ts.map +1 -1
  38. package/dist/cli/check-triggers.js +12 -0
  39. package/dist/cli/check-triggers.js.map +1 -1
  40. package/dist/cli/check.d.ts +25 -0
  41. package/dist/cli/check.d.ts.map +1 -1
  42. package/dist/cli/check.js +14 -2
  43. package/dist/cli/check.js.map +1 -1
  44. package/dist/cli/cloud-connect-cli.d.ts +54 -0
  45. package/dist/cli/cloud-connect-cli.d.ts.map +1 -0
  46. package/dist/cli/cloud-connect-cli.js +109 -0
  47. package/dist/cli/cloud-connect-cli.js.map +1 -0
  48. package/dist/cli/cloud-deploy.d.ts +3 -1
  49. package/dist/cli/cloud-deploy.d.ts.map +1 -1
  50. package/dist/cli/cloud-deploy.js +44 -6
  51. package/dist/cli/cloud-deploy.js.map +1 -1
  52. package/dist/cli/cloud-run.d.ts +2 -1
  53. package/dist/cli/cloud-run.d.ts.map +1 -1
  54. package/dist/cli/cloud-run.js +17 -3
  55. package/dist/cli/cloud-run.js.map +1 -1
  56. package/dist/cli/cloud-schedule.d.ts +31 -0
  57. package/dist/cli/cloud-schedule.d.ts.map +1 -0
  58. package/dist/cli/cloud-schedule.js +156 -0
  59. package/dist/cli/cloud-schedule.js.map +1 -0
  60. package/dist/cli/direct-run.d.ts.map +1 -1
  61. package/dist/cli/direct-run.js +6 -2
  62. package/dist/cli/direct-run.js.map +1 -1
  63. package/dist/cli/run.d.ts +22 -2
  64. package/dist/cli/run.d.ts.map +1 -1
  65. package/dist/cli/run.js +39 -4
  66. package/dist/cli/run.js.map +1 -1
  67. package/dist/cli.d.ts.map +1 -1
  68. package/dist/cli.js +148 -8
  69. package/dist/cli.js.map +1 -1
  70. package/dist/cloud-connect.d.ts +52 -0
  71. package/dist/cloud-connect.d.ts.map +1 -0
  72. package/dist/cloud-connect.js +138 -0
  73. package/dist/cloud-connect.js.map +1 -0
  74. package/dist/cloud-deploy.d.ts +19 -1
  75. package/dist/cloud-deploy.d.ts.map +1 -1
  76. package/dist/cloud-deploy.js +52 -2
  77. package/dist/cloud-deploy.js.map +1 -1
  78. package/dist/cloud-http.d.ts +17 -2
  79. package/dist/cloud-http.d.ts.map +1 -1
  80. package/dist/cloud-http.js +44 -8
  81. package/dist/cloud-http.js.map +1 -1
  82. package/dist/cloud-run.d.ts +30 -0
  83. package/dist/cloud-run.d.ts.map +1 -1
  84. package/dist/cloud-run.js +70 -27
  85. package/dist/cloud-run.js.map +1 -1
  86. package/dist/cloud-schedule.d.ts +55 -0
  87. package/dist/cloud-schedule.d.ts.map +1 -0
  88. package/dist/cloud-schedule.js +146 -0
  89. package/dist/cloud-schedule.js.map +1 -0
  90. package/dist/failure-kinds.d.ts +3 -3
  91. package/dist/failure-kinds.d.ts.map +1 -1
  92. package/dist/failure-kinds.js +4 -0
  93. package/dist/failure-kinds.js.map +1 -1
  94. package/dist/flow-requirements.d.ts +68 -0
  95. package/dist/flow-requirements.d.ts.map +1 -0
  96. package/dist/flow-requirements.js +152 -0
  97. package/dist/flow-requirements.js.map +1 -0
  98. package/dist/index.d.ts +5 -0
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +5 -0
  101. package/dist/index.js.map +1 -1
  102. package/dist/journal-client.d.ts +11 -0
  103. package/dist/journal-client.d.ts.map +1 -1
  104. package/dist/journal-client.js +13 -0
  105. package/dist/journal-client.js.map +1 -1
  106. package/dist/named-gate-lowering.js +9 -0
  107. package/dist/named-gate-lowering.js.map +1 -1
  108. package/dist/named-gates.d.ts +1 -1
  109. package/dist/named-gates.d.ts.map +1 -1
  110. package/dist/named-gates.js +11 -1
  111. package/dist/named-gates.js.map +1 -1
  112. package/dist/preflight.d.ts.map +1 -1
  113. package/dist/preflight.js +155 -9
  114. package/dist/preflight.js.map +1 -1
  115. package/dist/progress.d.ts +2 -1
  116. package/dist/progress.d.ts.map +1 -1
  117. package/dist/progress.js +9 -3
  118. package/dist/progress.js.map +1 -1
  119. package/dist/protocol.d.ts +25 -1
  120. package/dist/protocol.d.ts.map +1 -1
  121. package/dist/schedule-trigger.d.ts +35 -0
  122. package/dist/schedule-trigger.d.ts.map +1 -0
  123. package/dist/schedule-trigger.js +53 -0
  124. package/dist/schedule-trigger.js.map +1 -0
  125. package/dist/spec.d.ts +11 -1
  126. package/dist/spec.d.ts.map +1 -1
  127. package/dist/spec.js.map +1 -1
  128. package/dist/trigger-executor.js +1 -1
  129. package/dist/trigger-executor.js.map +1 -1
  130. package/dist/validate.js +1 -1
  131. package/dist/validate.js.map +1 -1
  132. package/dist/worker-cli.d.ts +10 -0
  133. package/dist/worker-cli.d.ts.map +1 -1
  134. package/dist/worker-cli.js +51 -22
  135. package/dist/worker-cli.js.map +1 -1
  136. package/dist/worker.d.ts.map +1 -1
  137. package/dist/worker.js +9 -0
  138. package/dist/worker.js.map +1 -1
  139. package/dist/wrapper-session.d.ts +3 -1
  140. package/dist/wrapper-session.d.ts.map +1 -1
  141. package/dist/wrapper-session.js +6 -3
  142. package/dist/wrapper-session.js.map +1 -1
  143. package/package.json +3 -3
  144. package/src/authored-flow-error.ts +30 -0
  145. package/src/authored-flow-executor.ts +141 -2
  146. package/src/authored-flow-lifecycle.ts +7 -0
  147. package/src/authored-flow-operation.ts +35 -10
  148. package/src/authored-human.ts +150 -0
  149. package/src/authored-node-entry.ts +3 -2
  150. package/src/authored-node-runner.ts +55 -18
  151. package/src/authored-root.ts +28 -1
  152. package/src/authored-worker-step.ts +15 -26
  153. package/src/cli/answer.ts +98 -0
  154. package/src/cli/build.ts +1 -1
  155. package/src/cli/check-triggers.ts +11 -0
  156. package/src/cli/check.ts +39 -2
  157. package/src/cli/cloud-connect-cli.ts +130 -0
  158. package/src/cli/cloud-deploy.ts +40 -6
  159. package/src/cli/cloud-run.ts +16 -4
  160. package/src/cli/cloud-schedule.ts +160 -0
  161. package/src/cli/direct-run.ts +6 -1
  162. package/src/cli/run.ts +53 -5
  163. package/src/cli.ts +134 -9
  164. package/src/cloud-connect.ts +187 -0
  165. package/src/cloud-deploy.ts +69 -3
  166. package/src/cloud-http.ts +52 -8
  167. package/src/cloud-run.ts +99 -26
  168. package/src/cloud-schedule.ts +175 -0
  169. package/src/failure-kinds.ts +4 -0
  170. package/src/flow-requirements.ts +201 -0
  171. package/src/index.ts +14 -0
  172. package/src/journal-client.ts +20 -0
  173. package/src/named-gate-lowering.ts +9 -0
  174. package/src/named-gates.ts +11 -1
  175. package/src/preflight.ts +132 -9
  176. package/src/progress.ts +11 -4
  177. package/src/protocol.ts +23 -0
  178. package/src/schedule-trigger.ts +73 -0
  179. package/src/spec.ts +12 -1
  180. package/src/trigger-executor.ts +1 -1
  181. package/src/validate.ts +1 -1
  182. package/src/worker-cli.ts +41 -0
  183. package/src/worker.ts +9 -0
  184. package/src/wrapper-session.ts +5 -1
@@ -7,12 +7,11 @@ import { classifyOutcome, type RunLifecycleOptions } from './cli/run.js';
7
7
  import type { PreflightDiagnostic } from './preflight.js';
8
8
  import { AuthoredFlowExecutionError } from './authored-flow-error.js';
9
9
  import type { JournalClient } from './journal-client.js';
10
- import { SPEC_SCHEMA_VERSION, type FlowSpec, type StepSpec } from './spec.js';
10
+ import { SPEC_SCHEMA_VERSION, type FlowSpec, type PermissionsSpec, type StepSpec } from './spec.js';
11
11
  import { isSurfaceCompletionReason, readCompletedStepOutput, readSuccessfulOutput } from './authored-step-output.js';
12
12
  import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
13
13
  import { snapshotJsonValue } from './json-value.js';
14
14
  import { authoredChildAdmissionKey } from './authored-admission.js';
15
- import { diffWorkspaceFiles, snapshotWorkspaceFiles } from './agent-artifacts.js';
16
15
 
17
16
  const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
18
17
 
@@ -97,13 +96,10 @@ export function authoredWorkerRunner(
97
96
  if (options.workspace !== undefined && WORKSPACE_PERMISSION_ANNOTATION.test(options.workspace)) {
98
97
  throw new AuthoredFlowExecutionError(
99
98
  'unsupported_workspace_permission',
100
- `flow "${definition.name}" step "${id}": workspace "${options.workspace}" declares a `
101
- + 'permission annotation ("...: readonly" / "...: readwrite"), but nothing enforces it — '
102
- + 'no parser anywhere in this package turns that annotation into a real restriction '
103
- + '(kernel/DAEMON-LIFECYCLE.md\'s permission model is untouched by f.agent). '
104
- + 'Silently accepting and ignoring it would let a flow believe a restriction is in effect '
105
- + "when it is not. Declare a bare surface name (no trailing \": readonly\"/\": readwrite\") "
106
- + 'if you do not need enforcement, or use the declarative spec\'s `permissions` field, which is real.',
99
+ `flow "${definition.name}" step "${id}": workspace "${options.workspace}": `
100
+ + "Workspace permission suffixes are unsupported. Use a bare workspace name and f.agent's "
101
+ + "permissions option, for example permissions: { fileGlobs: ['src/**'], accessPreset: 'readonly' }. "
102
+ + 'This declaration is validated and recorded with the step spec; it is not currently enforced (gate 8 / #442).',
107
103
  );
108
104
  }
109
105
  if (options.cli !== undefined && typeof options.cli !== 'string') {
@@ -130,22 +126,12 @@ export function authoredWorkerRunner(
130
126
  `f.agent options.transport must be 'direct' or 'relay' (got ${JSON.stringify(options.transport)}).`,
131
127
  );
132
128
  }
133
- // Artifact detection only tells the truth for the local-agent DIRECT
134
- // path: that is the only case that runs in this same process, on this
135
- // same filesystem, so `options.cwd` (or `process.cwd()`) is provably
136
- // where the CLI actually wrote — a workspace-scoped step never reaches
137
- // here with a local agent attached (refused above). `transport: 'relay'`
138
- // dispatches to agent-relay, which executes on a remote host even
139
- // though a local agent stream is still attached, so it gets no local
140
- // snapshot either. Any other worker attachment may execute on a
141
- // different host entirely; snapshotting this process's filesystem for
142
- // that case would be a guess, not a fact, so `artifacts` stays `[]`
143
- // there, exactly as before this fix.
144
- const artifactRoot = localAgentStream === undefined || options.transport === 'relay'
145
- ? undefined : options.cwd ?? process.cwd();
146
- const before = artifactRoot === undefined ? undefined : await snapshotWorkspaceFiles(artifactRoot);
129
+ const permissions = options.permissions;
130
+ const permissionsSnapshot = permissions === undefined ? undefined
131
+ : snapshotJsonValue(permissions, 'f.agent options.permissions') as unknown as PermissionsSpec;
147
132
  const output = await run({
148
133
  id, type: 'agent', instruction: options.task,
134
+ ...(permissionsSnapshot === undefined ? {} : { permissions: permissionsSnapshot }),
149
135
  ...(localAgentStream === undefined ? {} : { surfaces: { streams: [{ stream: localAgentStream }] } }),
150
136
  ...(options.workspace === undefined ? {} : { surfaces: { workspace: [{ surface: options.workspace }] } }),
151
137
  ...(options.cli === undefined ? {} : { cli: options.cli }),
@@ -158,9 +144,12 @@ export function authoredWorkerRunner(
158
144
  throw new AuthoredFlowExecutionError('journal_protocol_violation', `step "${id}" produced a non-object output`);
159
145
  }
160
146
  const stdout = 'stdout_tail' in output ? output.stdout_tail : undefined;
161
- const artifacts = before === undefined || artifactRoot === undefined
162
- ? []
163
- : diffWorkspaceFiles(before, await snapshotWorkspaceFiles(artifactRoot));
147
+ // The worker that ran the CLI measured the artifacts and journaled them
148
+ // in the step's output; read that fact back rather than re-scanning a
149
+ // directory this process may not even share with the agent.
150
+ const journaled = 'artifacts' in output ? output.artifacts : undefined;
151
+ const artifacts = Array.isArray(journaled) && journaled.every(entry => typeof entry === 'string')
152
+ ? [...journaled] : [];
164
153
  return { summary: typeof stdout === 'string' ? stdout : JSON.stringify(output), artifacts };
165
154
  },
166
155
  async llm(id: string, prompt: string, options?: LlmOptions, verification?: NamedGate): Promise<unknown> {
@@ -0,0 +1,98 @@
1
+ import { userInfo } from 'node:os';
2
+ import { readAuthoredRootMetadata } from '../authored-root.js';
3
+ import { HUMAN_WAIT_ID, humanAnswerPayload, readHumanAnswer, readOpenHumanWaits, resumeCommand } from '../authored-human.js';
4
+ import { JournalClient, JournalProtocolError } from '../journal-client.js';
5
+ import type { EnsureDaemonOptions } from '../daemon-lifecycle.js';
6
+ import { connect, emptyReport, protocolFailure, socketFor, type RunExecution } from './run.js';
7
+
8
+ export interface AnswerOptions {
9
+ readonly note?: string;
10
+ /** Who answered, when relaying a person's decision; defaults to the OS user. */
11
+ readonly answeredBy?: string;
12
+ readonly daemon?: EnsureDaemonOptions;
13
+ }
14
+
15
+ /**
16
+ * `flows answer <run-id> <wait-id> yes|no`: record a person's answer to a
17
+ * parked `f.human`. The answer is an `event.emit` keyed by the wait id; the
18
+ * kernel closes the `wait.human` as `human_responded` and the root becomes
19
+ * runnable. Nothing runs here — this process attaches no worker — so the
20
+ * report names the `flows resume` that continues the body with the answer.
21
+ */
22
+ export async function answerFlow(
23
+ runId: string,
24
+ waitId: string,
25
+ answer: boolean,
26
+ dataDir: string,
27
+ options: AnswerOptions = {},
28
+ ): Promise<RunExecution> {
29
+ const socketPath = socketFor(dataDir);
30
+ const base = emptyReport('answer');
31
+ if (!HUMAN_WAIT_ID.test(waitId)) {
32
+ return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
33
+ severity: 'refusal', kind: 'human_wait_unknown',
34
+ message: `"${waitId}" is not an f.human wait id; they are named human-<n> in the order the body asked.`,
35
+ }] } };
36
+ }
37
+ const client = new JournalClient(socketPath);
38
+ const connected = await connect(client, 'answer', dataDir, base, { daemon: options.daemon ?? {} });
39
+ if (connected !== undefined) return connected;
40
+ try {
41
+ const open = await readOpenHumanWaits(client, runId);
42
+ const wait = open.find(candidate => candidate.waitId === waitId);
43
+ if (wait === undefined) {
44
+ const answered = await readHumanAnswer(client, runId, waitId).catch(() => undefined);
45
+ const others = open.map(candidate => `${candidate.waitId} (${candidate.to}: ${JSON.stringify(candidate.question)})`);
46
+ return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
47
+ severity: 'refusal', kind: 'human_wait_unknown',
48
+ message: answered !== undefined
49
+ ? `Run "${runId}" already has an answer to ${waitId} (${answered.answer ? 'yes' : 'no'}); the kernel closes a wait once.`
50
+ : `Run "${runId}" is not asking ${waitId}.`
51
+ + (others.length === 0 ? ' It has no open question.' : ` Open: ${others.join(', ')}.`),
52
+ }] } };
53
+ }
54
+ const answeredBy = options.answeredBy ?? safeUsername();
55
+ if (answeredBy === '') {
56
+ return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
57
+ severity: 'refusal', kind: 'human_wait_unknown',
58
+ message: 'An answer must say who gave it and this environment has no OS user; pass --by <identity>.',
59
+ }] } };
60
+ }
61
+ const payload = humanAnswerPayload(answer, {
62
+ ...(options.note === undefined ? {} : { note: options.note }),
63
+ answeredBy,
64
+ });
65
+ const emitted = await client.eventEmit(runId, waitId, payload);
66
+ if (emitted.matched !== 1) {
67
+ return protocolFailure('answer', base, socketPath, new Error(
68
+ `event.emit matched ${emitted.matched} waits for ${waitId}; expected the one open question`,
69
+ ), runId);
70
+ }
71
+ const snapshot = await client.runGet(runId);
72
+ const root = await readAuthoredRootMetadata(client, runId).catch(() => undefined);
73
+ return {
74
+ exitCode: 0,
75
+ report: {
76
+ ...base, ok: true, runId, socketPath, status: snapshot.status,
77
+ answer: { waitId, answer, ...(options.note === undefined ? {} : { note: options.note }) },
78
+ humanWait: { waitId, question: wait.question, to: wait.to },
79
+ next: resumeCommand(runId, dataDir, root?.localAgentStream !== undefined),
80
+ diagnostics: [],
81
+ },
82
+ };
83
+ } catch (error) {
84
+ if (error instanceof JournalProtocolError && error.code === 'run_not_found') {
85
+ return { exitCode: 2, report: { ...base, runId, socketPath, diagnostics: [{
86
+ severity: 'refusal', kind: 'run_unavailable',
87
+ message: `Run "${runId}" is not known to relayflowd at "${socketPath}".`,
88
+ }] } };
89
+ }
90
+ return protocolFailure('answer', base, socketPath, error, runId);
91
+ } finally {
92
+ client.close();
93
+ }
94
+ }
95
+
96
+ function safeUsername(): string {
97
+ try { return userInfo().username; } catch { return ''; }
98
+ }
package/src/cli/build.ts CHANGED
@@ -112,7 +112,7 @@ export async function buildFlow(path: string, out: string, warn: (line: string)
112
112
  ...(config.path !== undefined ? { projectConfigPath: config.path } : {}),
113
113
  projectSearchStart: directory,
114
114
  models: config.models,
115
- ...(config.path !== undefined ? { modelRegistryPath: config.path } : {}),
115
+ ...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
116
116
  probes: {
117
117
  cli: () => { throw new Error('deferred to deployment'); },
118
118
  executor: () => { throw new Error('deferred to deployment'); },
@@ -2,7 +2,9 @@ import { dirname, resolve } from 'node:path';
2
2
  import { loadAuthoredFlow, type LoadedAuthoredFlow } from '../authored-flow-loader.js';
3
3
  import { preflightWebhookTriggers } from '../preflight.js';
4
4
  import { preflightProviderTriggers } from '../provider-trigger-contract.js';
5
+ import { scheduleLowering } from '../schedule-trigger.js';
5
6
  import { checkSlackHelpers } from '../slack-preflight.js';
7
+ import { flowRequirements } from '../flow-requirements.js';
6
8
  import { inputFailureReport, readProjectConfig, type CheckReport } from './check.js';
7
9
 
8
10
  /**
@@ -31,12 +33,21 @@ export async function checkAuthoredTriggers(path: string): Promise<{
31
33
  const diagnostics = [
32
34
  ...triggerDiagnostics, ...providerDiagnostics, ...helperReport.diagnostics,
33
35
  ];
36
+ // A schedule is inspectable data: print what it lowers to, and say plainly
37
+ // when the local runner cannot drive it. Neither is a refusal — Cloud can.
38
+ const schedules = triggers.flatMap((trigger, handler) => {
39
+ if (trigger.kind !== 'schedule') return [];
40
+ const lowering = scheduleLowering(definition.name, trigger);
41
+ return [{ handler, ...lowering }];
42
+ });
34
43
  return {
35
44
  loaded,
36
45
  report: {
37
46
  // Severity, not emptiness: a warning must never refuse a flow.
38
47
  ok: !diagnostics.some(diagnostic => diagnostic.severity === 'refusal'),
39
48
  path, gates: [], resolutions: [], diagnostics,
49
+ ...(schedules.length === 0 ? {} : { schedules }),
50
+ requirements: flowRequirements(definition, { projectCli: config.cli }),
40
51
  ...(config.path === undefined ? {} : { projectConfigPath: config.path }),
41
52
  },
42
53
  };
package/src/cli/check.ts CHANGED
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { parse as parseYaml } from 'yaml';
5
5
  import { CompileError, compileSpec, kernelToAuthoring } from '../compile.js';
6
6
  import { helperReady } from '../yaml-helper-effect.js';
7
+ import { flowRequirements, type FlowRequirements } from '../flow-requirements.js';
7
8
  import {
8
9
  adapterIdentification,
9
10
  authenticationProbe,
@@ -33,7 +34,15 @@ export interface ProjectConfig {
33
34
  mcp?: Record<string, McpServerConfig>;
34
35
  cli?: string;
35
36
  executors: string[];
37
+ /**
38
+ * Exact model allowlist. Only a flows.json that DECLARES `models` is a
39
+ * registry that preflight enforces: `{"cli":"claude"}` alone names the CLI
40
+ * and leaves model policy to the adapter defaults, exactly as no flows.json
41
+ * would. Otherwise every scaffolded project (`flows create` writes only
42
+ * `cli`) refuses its adapter's own default model as "not listed".
43
+ */
36
44
  models: string[];
45
+ modelRegistryPath?: string;
37
46
  directory: string;
38
47
  path?: string;
39
48
  }
@@ -45,9 +54,26 @@ export interface CheckReport {
45
54
  projectConfigPath?: string;
46
55
  gates: StepGateInspection[];
47
56
  resolutions: CliResolution[];
57
+ /** Authored `schedule.*` handlers and the `flows.tick` subscription each lowers to. */
58
+ schedules?: ScheduleInspection[];
59
+ /** Integrations, harnesses and MCP servers the flow declares it needs (`flow-requirements.ts`). */
60
+ requirements?: FlowRequirements;
48
61
  diagnostics: Array<PreflightDiagnostic | CheckInputDiagnostic | CheckWarningDiagnostic>;
49
62
  }
50
63
 
64
+ export interface ScheduleInspection {
65
+ /** Position among the flow's handlers, so two identical declarations stay distinct. */
66
+ handler: number;
67
+ cron?: string;
68
+ tz?: string;
69
+ intervalMs?: number;
70
+ epochMs?: number;
71
+ staleAfterMs?: number;
72
+ scheduleId: string;
73
+ /** Present when the local tick runner cannot drive it (only a cron-aware runner can). */
74
+ localUnsupported?: string;
75
+ }
76
+
51
77
  export interface CheckWarningDiagnostic {
52
78
  severity: 'warning';
53
79
  kind: CheckWarningKind;
@@ -102,6 +128,15 @@ export function checkFlow(path: string): CheckExecution {
102
128
  }
103
129
  }
104
130
 
131
+ /** Requirements never turn a preflight refusal into an unrelated exception. */
132
+ function safeRequirements(authoring: FlowSpec, projectCli: string | undefined): FlowRequirements | undefined {
133
+ try {
134
+ return flowRequirements(authoring, projectCli === undefined ? {} : { projectCli });
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ }
139
+
105
140
  /** Preflight a validated authored flow through the same path as YAML/JSON. */
106
141
  export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConfig?: ProjectConfig): CheckExecution {
107
142
  const absolutePath = resolve(path);
@@ -113,7 +148,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
113
148
  projectConfigPath: config.path,
114
149
  projectSearchStart: dirname(absolutePath),
115
150
  models: config.models,
116
- ...(config.path !== undefined ? { modelRegistryPath: config.path } : {}),
151
+ ...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
117
152
  probes,
118
153
  });
119
154
  const flow = result.ok
@@ -132,6 +167,7 @@ export function checkAuthoredFlow(authoring: FlowSpec, path: string, projectConf
132
167
  gates: result.gates,
133
168
  resolutions: result.resolutions,
134
169
  diagnostics: result.diagnostics,
170
+ requirements: safeRequirements(authoring, config.cli),
135
171
  },
136
172
  ...(flow !== undefined ? { flow } : {}),
137
173
  };
@@ -181,7 +217,7 @@ export async function checkBuildableFlow(path: string): Promise<CheckExecution>
181
217
  ...(config.path !== undefined ? { projectConfigPath: config.path } : {}),
182
218
  projectSearchStart: dirname(absolutePath),
183
219
  models: config.models,
184
- ...(config.path !== undefined ? { modelRegistryPath: config.path } : {}),
220
+ ...(config.modelRegistryPath !== undefined ? { modelRegistryPath: config.modelRegistryPath } : {}),
185
221
  probes: deferred,
186
222
  });
187
223
  // Refusals rooted in build-machine environment probes (`probe_failed`)
@@ -314,6 +350,7 @@ export function readProjectConfig(start: string): ProjectConfig {
314
350
  ...(value['cli'] !== undefined ? { cli: value['cli'] as string } : {}),
315
351
  executors: (value['executors'] as string[] | undefined) ?? [],
316
352
  models: (value['models'] as string[] | undefined) ?? [],
353
+ ...(value['models'] !== undefined ? { modelRegistryPath: configPath } : {}),
317
354
  directory: dirname(configPath),
318
355
  path: configPath,
319
356
  };
@@ -0,0 +1,130 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { createInterface } from 'node:readline';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { loadAuthoredFlow } from '../authored-flow-loader.js';
6
+ import { ensureIntegrationsConnected, type ConnectPrompt, type ConnectionsOutcome } from '../cloud-connect.js';
7
+ import { CloudFlowError, cloudRequest, isCloudRecord, type CloudConnectionOptions } from '../cloud-http.js';
8
+ import { CompileError, compileSpec, kernelToAuthoring } from '../compile.js';
9
+ import { FLOW_HARNESSES, flowRequirements, type FlowRequirements, type FlowRequirementsContext } from '../flow-requirements.js';
10
+ import { readProjectConfig } from './check.js';
11
+ import type { CliIo } from '../cli.js';
12
+
13
+ /**
14
+ * The hosted verbs' shared "are the integrations connected?" step. It runs
15
+ * before `flows deploy`, `flows schedule` and `flows run --cloud` submit
16
+ * anything, so a flow that would fail at its first `f.slack.post` with
17
+ * `helper_slack.credential_missing` is connected — or refused — first.
18
+ *
19
+ * The flow is read the same way the submission reads it (authored loader or
20
+ * YAML compile); a source that does not load is left for the submission to
21
+ * refuse with its own, better diagnostic, so this step never masks one.
22
+ */
23
+
24
+ export interface FlowConnectionsInput extends FlowRequirementsContext {
25
+ path: string;
26
+ /** `undefined` refuses a missing integration instead of prompting (`--no-connect`, `--json`, no TTY). */
27
+ prompt: ConnectPrompt | undefined;
28
+ /** Known already (deploy resolved it); otherwise `whoami` supplies it. */
29
+ workspaceId?: string;
30
+ }
31
+
32
+ export async function flowRequirementsForPath(
33
+ path: string, context: FlowRequirementsContext = {},
34
+ ): Promise<FlowRequirements | undefined> {
35
+ const projectCli = context.projectCli ?? projectCliFor(path);
36
+ const withCli = { ...context, ...(projectCli === undefined ? {} : { projectCli }) };
37
+ try {
38
+ if (/\.flow\.ts$/iu.test(path)) {
39
+ const loaded = await loadAuthoredFlow(path);
40
+ return flowRequirements(loaded.getDefinition(loaded.handle), withCli);
41
+ }
42
+ if (!/\.(?:ya?ml|json)$/iu.test(path)) return undefined;
43
+ const parsed: unknown = parseYaml(await readFile(path, 'utf8'));
44
+ let spec;
45
+ try {
46
+ spec = compileSpec(parsed);
47
+ } catch (error) {
48
+ if (!(error instanceof CompileError)) throw error;
49
+ spec = compileSpec(kernelToAuthoring(parsed));
50
+ }
51
+ return flowRequirements(spec, withCli);
52
+ } catch {
53
+ return undefined;
54
+ }
55
+ }
56
+
57
+ function projectCliFor(path: string): string | undefined {
58
+ try {
59
+ return readProjectConfig(dirname(resolve(path))).cli;
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /** `whoami`'s current workspace: the one every hosted verb submits into. */
66
+ export async function currentWorkspaceId(options: CloudConnectionOptions): Promise<string> {
67
+ const whoami = await cloudRequest('/api/v1/auth/whoami', options);
68
+ const workspace = isCloudRecord(whoami) && isCloudRecord(whoami.currentWorkspace) ? whoami.currentWorkspace : undefined;
69
+ if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) {
70
+ throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.');
71
+ }
72
+ return workspace.id;
73
+ }
74
+
75
+ /**
76
+ * Connects (or refuses on) every integration `path` requires. Nothing is
77
+ * contacted when the flow requires no integration, so a flow with no
78
+ * helpers, sources or repository submits exactly as before; the derived
79
+ * requirements still come back so a later harness refusal can name its remedy.
80
+ * `undefined` only when the source does not load — the submission says why.
81
+ */
82
+ export async function ensureFlowConnections(
83
+ input: FlowConnectionsInput, options: CloudConnectionOptions = {},
84
+ ): Promise<{ requirements: FlowRequirements; outcome: ConnectionsOutcome } | undefined> {
85
+ const requirements = await flowRequirementsForPath(input.path, input);
86
+ if (requirements === undefined) return undefined;
87
+ if (requirements.integrations.length === 0) return { requirements, outcome: { ready: [], connected: [] } };
88
+ const workspaceId = input.workspaceId ?? await currentWorkspaceId(options);
89
+ const outcome = await ensureIntegrationsConnected(requirements, {
90
+ ...options, workspaceId, ...(input.prompt === undefined ? {} : { prompt: input.prompt }),
91
+ });
92
+ return { requirements, outcome };
93
+ }
94
+
95
+ /**
96
+ * A terminal prompt, or nothing. `--json` output is for a machine, and a
97
+ * pipe cannot answer a question, so both refuse instead of asking; `--no-connect`
98
+ * is the explicit form of the same choice.
99
+ */
100
+ export function cliConnectPrompt(
101
+ io: CliIo, { noConnect, json }: { noConnect: boolean; json: boolean },
102
+ streams: { stdin: NodeJS.ReadStream; stdout: NodeJS.WriteStream } = { stdin: process.stdin, stdout: process.stdout },
103
+ ): ConnectPrompt | undefined {
104
+ if (noConnect || json || !streams.stdin.isTTY || !streams.stdout.isTTY) return undefined;
105
+ return {
106
+ info: line => io.stderr(line),
107
+ confirm: question => new Promise(resolveAnswer => {
108
+ const rl = createInterface({ input: streams.stdin, output: streams.stdout });
109
+ rl.question(question, answer => {
110
+ rl.close();
111
+ resolveAnswer(/^\s*(?:y(?:es)?)?\s*$/iu.test(answer));
112
+ });
113
+ }),
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Cloud refuses a run or activation whose coding-agent credential is missing
119
+ * (`flow_model_not_connected`, `cli_credentials_missing`); there is no status
120
+ * route to ask first, so the refusal is where the remedy is named.
121
+ */
122
+ export function harnessRemedy(error: unknown, harnesses: readonly string[]): string {
123
+ if (!(error instanceof CloudFlowError) || error.refusal === undefined) return '';
124
+ if (!['flow_model_not_connected', 'cli_credentials_missing'].includes(error.refusal.code)) return '';
125
+ if (/agent-relay cloud connect/u.test(error.refusal.error)) return '';
126
+ const message = error.refusal.error.toLowerCase();
127
+ const named = FLOW_HARNESSES.filter(harness => message.includes(harness));
128
+ const targets = named.length > 0 ? named : harnesses.length > 0 ? harnesses : ['claude'];
129
+ return ` Connect it with: ${targets.map(harness => `agent-relay cloud connect ${harness}`).join('; ')}.`;
130
+ }
@@ -3,6 +3,8 @@ import {
3
3
  deployToCloud, listCloudDeployments, parseAgentHarnesses, parseRepository, parseTriggerSource, undeployFromCloud,
4
4
  type FlowTriggerSource,
5
5
  } from '../cloud-deploy.js';
6
+ import { describeFlowRequirements } from '../flow-requirements.js';
7
+ import { cliConnectPrompt, flowRequirementsForPath, harnessRemedy } from './cloud-connect-cli.js';
6
8
  import type { CliIo } from '../cli.js';
7
9
 
8
10
  export interface CloudDeployArgs {
@@ -14,12 +16,14 @@ export interface CloudDeployArgs {
14
16
  name: string | undefined;
15
17
  agents: string | undefined;
16
18
  draft: boolean;
19
+ /** Refuse a missing integration instead of offering to connect it. */
20
+ noConnect: boolean;
17
21
  json: boolean;
18
22
  }
19
23
 
20
24
  /**
21
25
  * `flows deploy <flow.ts> --repo <owner/name> --on <provider>[:k=v,…] [--on …]
22
- * --approver <handle> [--name <n>] [--json]`
26
+ * --approver <handle> [--name <n>] [--agents <list>] [--draft] [--no-connect] [--json]`
23
27
  *
24
28
  * Parsed here rather than in `parseDeployArgs` because the two `deploy` forms
25
29
  * share nothing but the word: the digest form copies a sealed bundle into a
@@ -32,6 +36,7 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
32
36
  let name: string | undefined;
33
37
  let agents: string | undefined;
34
38
  let draft = false;
39
+ let noConnect = false;
35
40
  let json = false;
36
41
  const on: string[] = [];
37
42
  for (let i = 0; i < args.length; i++) {
@@ -46,6 +51,11 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
46
51
  draft = true;
47
52
  continue;
48
53
  }
54
+ if (arg === '--no-connect') {
55
+ if (noConnect) return undefined;
56
+ noConnect = true;
57
+ continue;
58
+ }
49
59
  if (arg === '--agents') {
50
60
  const next = args[i + 1];
51
61
  if (agents !== undefined || next === undefined || next.startsWith('-')) return undefined;
@@ -68,7 +78,7 @@ export function parseCloudDeployArgs(args: readonly string[]): CloudDeployArgs |
68
78
  value = arg;
69
79
  }
70
80
  if (value === undefined || repo === undefined || on.length === 0) return undefined;
71
- return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, json };
81
+ return { command: 'cloud-deploy', value, repo, on, approver, name, agents, draft, noConnect, json };
72
82
  }
73
83
 
74
84
  function describeSource(source: FlowTriggerSource): string {
@@ -77,11 +87,19 @@ function describeSource(source: FlowTriggerSource): string {
77
87
  }
78
88
 
79
89
  export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promise<0 | 1 | 2> {
90
+ const agents = args.agents === undefined ? undefined : parseAgentHarnessesOr(args.agents);
91
+ // What the source declares, so a harness refusal names the right remedy
92
+ // even when `--agents` was not given; the loader's own failure is reported
93
+ // by deployToCloud, not here.
94
+ let harnesses: readonly string[] = agents instanceof CloudFlowError ? [] : agents ?? [];
80
95
  try {
81
96
  if (args.approver === undefined) {
82
97
  throw new CloudFlowError('invalid_input',
83
98
  '--approver <handle> is required: every launched run receives it as input.approver for f.human.');
84
99
  }
100
+ if (agents instanceof CloudFlowError) throw agents;
101
+ const connect = cliConnectPrompt(io, { noConnect: args.noConnect, json: args.json });
102
+ if (agents === undefined) harnesses = (await flowRequirementsForPath(args.value))?.harnesses ?? [];
85
103
  const deployment = await deployToCloud({
86
104
  path: args.value,
87
105
  repository: parseRepository(args.repo),
@@ -89,7 +107,8 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
89
107
  approver: args.approver,
90
108
  draft: args.draft,
91
109
  ...(args.name === undefined ? {} : { name: args.name }),
92
- ...(args.agents === undefined ? {} : { agents: parseAgentHarnesses(args.agents) }),
110
+ ...(agents === undefined ? {} : { agents }),
111
+ ...(connect === undefined ? {} : { connect }),
93
112
  });
94
113
  if (args.json) {
95
114
  io.stdout(JSON.stringify({ ok: true, ...deployment }));
@@ -99,12 +118,24 @@ export async function runCloudDeployCli(args: CloudDeployArgs, io: CliIo): Promi
99
118
  io.stdout(` flow: ${deployment.name} (${args.value}, sha256 ${deployment.sourceSha256.slice(0, 12)})`);
100
119
  io.stdout(` repository: ${deployment.repository.owner}/${deployment.repository.name}`);
101
120
  for (const source of deployment.sources) io.stdout(` on: ${describeSource(source)}`);
121
+ const requires = describeFlowRequirements(deployment.requirements);
122
+ if (requires) io.stdout(` requires: ${requires}`);
123
+ for (const provider of deployment.connected) io.stdout(` connected: ${provider}`);
102
124
  io.stdout(deployment.status === 'draft'
103
125
  ? 'Saved without activating; activate it from the Cloud dashboard, or redeploy without --draft.'
104
126
  : 'Each matching ticket launches a run of this source in a fresh branch; list with: flows deployments');
105
127
  return 0;
106
128
  } catch (error) {
107
- return reportCloudFailure(error, args.json, io);
129
+ return reportCloudFailure(error, args.json, io, harnesses);
130
+ }
131
+ }
132
+
133
+ function parseAgentHarnessesOr(value: string): ReturnType<typeof parseAgentHarnesses> | CloudFlowError {
134
+ try {
135
+ return parseAgentHarnesses(value);
136
+ } catch (error) {
137
+ if (error instanceof CloudFlowError) return error;
138
+ throw error;
108
139
  }
109
140
  }
110
141
 
@@ -140,9 +171,11 @@ export async function runCloudUndeployCli({ agentId, json }: { agentId: string;
140
171
  }
141
172
  }
142
173
 
143
- function reportCloudFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
174
+ function reportCloudFailure(error: unknown, json: boolean, io: CliIo, harnesses: readonly string[] = []): 1 | 2 {
144
175
  const code = error instanceof CloudFlowError ? error.code : 'cloud_deploy_failed';
145
176
  let message = error instanceof Error ? error.message : 'Cloud deploy failed.';
177
+ // Cloud names the missing coding-agent credential on activation; say how it is connected.
178
+ message += harnessRemedy(error, harnesses);
146
179
  // The deploy routes take a browser session or a `cli:auth` token. A
147
180
  // deployment (CI) token gets 403 `session_required`; say what fixes it.
148
181
  if (error instanceof CloudFlowError && error.status === 403) {
@@ -152,6 +185,7 @@ function reportCloudFailure(error: unknown, json: boolean, io: CliIo): 1 | 2 {
152
185
  if (json) io.stdout(JSON.stringify({ ok: false, code, message }));
153
186
  else io.stderr(`${code}: ${message}`);
154
187
  return error instanceof CloudFlowError
155
- && (['configuration', 'unsupported_source', 'invalid_input'].includes(error.code) || error.status === 403 || error.status === 401)
188
+ && (['configuration', 'unsupported_source', 'invalid_input', 'integration_not_connected'].includes(error.code)
189
+ || error.status === 403 || error.status === 401)
156
190
  ? 2 : 1;
157
191
  }
@@ -2,12 +2,13 @@ import { CloudFlowError } from '../cloud-http.js';
2
2
  import { runInCloud, waitForCloudFlowRun, type RunInCloudOptions } from '../cloud-run.js';
3
3
  import { DirectInputError, isAuthoredFlowPath, parseDirectInput } from '../direct-input.js';
4
4
  import { snapshotJsonValue } from '../json-value.js';
5
+ import { cliConnectPrompt, ensureFlowConnections, harnessRemedy } from './cloud-connect-cli.js';
5
6
  import type { CliIo } from '../cli.js';
6
7
 
7
8
  /** Presentation only: the central CLI parser owns argv; the SDK owns the lifecycle. */
8
9
  export async function runCloudCli(
9
- { value: path, json, wait, input, syncCode }: {
10
- value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean;
10
+ { value: path, json, wait, input, syncCode, noConnect = false }: {
11
+ value: string; json: boolean; wait: boolean; input: string | undefined; syncCode: boolean; noConnect?: boolean;
11
12
  },
12
13
  io: CliIo,
13
14
  ): Promise<0 | 1 | 2> {
@@ -20,6 +21,7 @@ export async function runCloudCli(
20
21
  // packing or upload — admitted nothing and is safe to retry; only an
21
22
  // interrupted submission has unknown admission.
22
23
  let submitting = false;
24
+ let harnesses: readonly string[] = [];
23
25
  try {
24
26
  const options: RunInCloudOptions = { signal: controller.signal, onSubmit: () => { submitting = true; } };
25
27
  if (isAuthoredFlowPath(path)) {
@@ -32,6 +34,15 @@ export async function runCloudCli(
32
34
  throw error;
33
35
  }
34
36
  }
37
+ // After local validation, before anything is packed or uploaded: a helper
38
+ // the run would call against an unconnected integration is connected
39
+ // here, or refused here. A command that is going to be refused for its
40
+ // arguments must not first open a browser.
41
+ const connections = await ensureFlowConnections({
42
+ path, prompt: cliConnectPrompt(io, { noConnect, json }),
43
+ }, { signal: controller.signal });
44
+ harnesses = connections?.requirements.harnesses ?? [];
45
+ for (const provider of connections?.outcome.connected ?? []) if (!json) io.stdout(`CONNECTED ${provider}`);
35
46
  // The tree is the invoking directory, as with v1: the flow path is where
36
47
  // the body lives, not the boundary of what the run may read.
37
48
  if (syncCode) options.syncCode = { root: process.cwd() };
@@ -65,11 +76,12 @@ export async function runCloudCli(
65
76
  : submitting
66
77
  ? 'Submission interrupted before a receipt was received. Admission is unknown; Cloud may have started the run. Do not resubmit blindly.'
67
78
  : 'Interrupted before the run was submitted; nothing was admitted. Safe to run again.'
68
- : error instanceof Error ? error.message : 'Cloud run failed.';
79
+ : (error instanceof Error ? error.message : 'Cloud run failed.') + harnessRemedy(error, harnesses);
69
80
  if (json) io.stdout(JSON.stringify({ ok: false, code, message, ...(runId ? { runId } : {}) }));
70
81
  else io.stderr(`${code}: ${message}${runId ? ` (run ${runId})` : ''}`);
71
82
  return error instanceof CloudFlowError
72
- && (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported'].includes(error.code)
83
+ && (['configuration', 'unsupported_source', 'invalid_input', 'unsupported_storage_backend', 'sync_too_large', 'sync_unsupported',
84
+ 'integration_not_connected'].includes(error.code)
73
85
  || (runId === undefined && error.code === 'http_error' && [401, 403].includes(error.status ?? 0))) ? 2 : 1;
74
86
  } finally {
75
87
  process.off('SIGINT', abort);