@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
@@ -0,0 +1,175 @@
1
+ import { cronGrid, everyToMs, parseCron, type ScheduleTriggerSource } from '@relayflows/surface';
2
+ import { CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions } from './cloud-http.js';
3
+ import { cloudSubmissionBody, prepareCloudSubmission, type CloudFlowSource } from './cloud-run.js';
4
+ import type { JsonValue } from './json-value.js';
5
+
6
+ /**
7
+ * Hosted schedules. `POST /api/v1/workflows/schedules` stores a workflow
8
+ * request and relaycron fires it on the cron; each fire replays the stored
9
+ * request through the same `/workflows/run` admission a `flows run --cloud`
10
+ * takes. So a schedule sends exactly what a run sends — `prepareCloudSubmission`
11
+ * — wrapped in the schedule envelope. Nothing runs at schedule time.
12
+ */
13
+
14
+ export interface ScheduleInCloudInput {
15
+ flow: CloudFlowSource;
16
+ /** Five-field cron. Exactly one of `cron` or `every`, or neither to use the flow's declared `schedule.*`. */
17
+ cron?: string;
18
+ /** Fixed interval such as `5m`; lowered to a cron the server accepts. */
19
+ every?: string;
20
+ /** IANA zone the cron is evaluated in; default UTC. */
21
+ tz?: string;
22
+ /** Authored input, as `flows run --cloud --input`. */
23
+ input?: JsonValue;
24
+ /** Defaults to the flow's declared name. */
25
+ name?: string;
26
+ }
27
+
28
+ export interface CloudSchedule {
29
+ id: string;
30
+ name: string;
31
+ cronExpression: string;
32
+ timezone: string;
33
+ status: string;
34
+ createdAt?: string;
35
+ lastTriggeredAt?: string;
36
+ lastTriggeredRunId?: string;
37
+ lastTriggerStatus?: string;
38
+ }
39
+
40
+ const SCHEDULE_ID = /^[A-Za-z0-9_-]{1,128}$/u;
41
+
42
+ /** `every("5m")` as a cron the server accepts; only intervals cron can express exactly. */
43
+ export function everyToCron(every: string): string {
44
+ const ms = everyToMs(every);
45
+ const minutes = ms / 60_000;
46
+ if (Number.isInteger(minutes) && minutes >= 1 && minutes < 60 && 60 % minutes === 0) return `*/${minutes} * * * *`;
47
+ if (minutes === 60) return '0 * * * *';
48
+ const hours = ms / 3_600_000;
49
+ if (Number.isInteger(hours) && hours > 1 && hours < 24 && 24 % hours === 0) return `0 */${hours} * * *`;
50
+ if (hours === 24) return '0 0 * * *';
51
+ throw new CloudFlowError('invalid_input',
52
+ `--every ${every} has no exact cron: use a divisor of an hour (1m-30m), 1h, a divisor of a day (2h-12h), or 1d; or give --cron.`);
53
+ }
54
+
55
+ /** The cron a declared `schedule.*` source means on Cloud. */
56
+ export function declaredScheduleCron(source: ScheduleTriggerSource): { cron: string; tz?: string } {
57
+ if (source.cron !== undefined) return { cron: source.cron, ...(source.tz === undefined ? {} : { tz: source.tz }) };
58
+ const minutes = (source.intervalMs ?? 0) / 60_000;
59
+ const every = Number.isInteger(minutes) ? `${minutes}m` : `${Math.round((source.intervalMs ?? 0) / 1000)}s`;
60
+ return { cron: everyToCron(every) };
61
+ }
62
+
63
+ function assertTimeZone(tz: string): string {
64
+ try {
65
+ new Intl.DateTimeFormat('en-US', { timeZone: tz });
66
+ } catch {
67
+ throw new CloudFlowError('invalid_input', `--tz ${JSON.stringify(tz)} is not a known IANA zone.`);
68
+ }
69
+ return tz;
70
+ }
71
+
72
+ /**
73
+ * The argument checks `scheduleInCloud` makes before it reads the flow, so a
74
+ * caller can refuse a bad cron, interval or zone before any side effect (the
75
+ * CLI connects integrations in between). Declared-schedule resolution needs
76
+ * the flow and stays in `scheduleInCloud`.
77
+ */
78
+ export function validateScheduleArgs(input: Pick<ScheduleInCloudInput, 'cron' | 'every' | 'tz'>): void {
79
+ if (input.cron !== undefined && input.every !== undefined) {
80
+ throw new CloudFlowError('invalid_input', 'Give --cron or --every, not both.');
81
+ }
82
+ if (input.cron !== undefined) {
83
+ try { parseCron(input.cron); } catch (error) {
84
+ throw new CloudFlowError('invalid_input', `--cron: ${error instanceof Error ? error.message : String(error)}`);
85
+ }
86
+ }
87
+ if (input.every !== undefined) everyToCron(input.every);
88
+ if (input.tz !== undefined) assertTimeZone(input.tz);
89
+ }
90
+
91
+ export async function scheduleInCloud(
92
+ input: ScheduleInCloudInput, options: CloudConnectionOptions = {},
93
+ ): Promise<CloudSchedule> {
94
+ validateScheduleArgs(input);
95
+ const submission = await prepareCloudSubmission(input.flow, {
96
+ ...(Object.prototype.hasOwnProperty.call(input, 'input') ? { input: input.input } : {}),
97
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
98
+ });
99
+ let cron: string;
100
+ let tz = input.tz;
101
+ if (input.cron !== undefined) {
102
+ try { parseCron(input.cron); } catch (error) {
103
+ throw new CloudFlowError('invalid_input', `--cron: ${error instanceof Error ? error.message : String(error)}`);
104
+ }
105
+ cron = input.cron.trim();
106
+ } else if (input.every !== undefined) {
107
+ cron = everyToCron(input.every);
108
+ } else {
109
+ if (submission.schedules.length !== 1) {
110
+ throw new CloudFlowError('invalid_input', submission.schedules.length === 0
111
+ ? 'The flow declares no schedule.* handler; give --cron or --every.'
112
+ : 'The flow declares several schedules; say which with --cron or --every.');
113
+ }
114
+ const declared = declaredScheduleCron(submission.schedules[0]!);
115
+ cron = declared.cron;
116
+ tz ??= declared.tz;
117
+ }
118
+ const timezone = tz === undefined ? 'UTC' : assertTimeZone(tz);
119
+ const name = (input.name ?? submission.name).trim();
120
+ if (!name) throw new CloudFlowError('invalid_input', 'Schedule name must not be empty.');
121
+ options.signal?.throwIfAborted();
122
+ const result = await cloudFetch('/api/v1/workflows/schedules', options, {
123
+ method: 'POST', detail: true,
124
+ body: JSON.stringify({
125
+ name,
126
+ schedule_type: 'cron',
127
+ cron_expression: cron,
128
+ timezone,
129
+ workflowRequest: cloudSubmissionBody(submission),
130
+ }),
131
+ });
132
+ const record = isCloudRecord(result) && isCloudRecord(result.schedule) ? result.schedule : undefined;
133
+ if (record === undefined) throw new CloudFlowError('invalid_response', 'Cloud did not return a schedule.');
134
+ return toCloudSchedule(record);
135
+ }
136
+
137
+ function toCloudSchedule(row: Record<string, unknown>): CloudSchedule {
138
+ if (typeof row.id !== 'string' || !SCHEDULE_ID.test(row.id) || typeof row.name !== 'string') {
139
+ throw new CloudFlowError('invalid_response', 'Cloud returned a malformed schedule.');
140
+ }
141
+ const optional = (key: string): Record<string, string> =>
142
+ typeof row[key] === 'string' ? { [key]: row[key] as string } : {};
143
+ return {
144
+ id: row.id, name: row.name,
145
+ cronExpression: typeof row.cronExpression === 'string' ? row.cronExpression : '',
146
+ timezone: typeof row.timezone === 'string' ? row.timezone : 'UTC',
147
+ status: typeof row.status === 'string' ? row.status : 'unknown',
148
+ ...optional('createdAt'), ...optional('lastTriggeredAt'), ...optional('lastTriggeredRunId'), ...optional('lastTriggerStatus'),
149
+ };
150
+ }
151
+
152
+ export async function listCloudSchedules(options: CloudConnectionOptions = {}): Promise<CloudSchedule[]> {
153
+ const payload = await cloudRequest('/api/v1/workflows/schedules', options);
154
+ if (!isCloudRecord(payload) || !Array.isArray(payload.schedules)) {
155
+ throw new CloudFlowError('invalid_response', 'Cloud did not return a schedules list.');
156
+ }
157
+ return payload.schedules.map(row => {
158
+ if (!isCloudRecord(row)) throw new CloudFlowError('invalid_response', 'Cloud returned a malformed schedule row.');
159
+ return toCloudSchedule(row);
160
+ });
161
+ }
162
+
163
+ export async function unscheduleInCloud(scheduleId: string, options: CloudConnectionOptions = {}): Promise<void> {
164
+ if (!SCHEDULE_ID.test(scheduleId)) throw new CloudFlowError('invalid_input', `"${scheduleId}" is not a schedule id.`);
165
+ const result = await cloudFetch(`/api/v1/workflows/schedules/${encodeURIComponent(scheduleId)}`, options,
166
+ { method: 'DELETE', detail: true });
167
+ if (!isCloudRecord(result) || result.deleted !== true) {
168
+ throw new CloudFlowError('invalid_response', 'Cloud did not confirm the deletion.');
169
+ }
170
+ }
171
+
172
+ /** Exposed for previews: the tick grid a UTC cron amounts to, if it is exactly one. */
173
+ export function cronIntervalMs(cron: string): number | undefined {
174
+ return cronGrid(parseCron(cron))?.intervalMs;
175
+ }
@@ -111,6 +111,10 @@ export const RUN_FAILURE_KINDS = [
111
111
  'run_parked',
112
112
  'run_declined',
113
113
  'run_unavailable',
114
+ /** A predicate `.gate(fn)` judged false; the verdict is journaled as `<step>.gate`. */
115
+ 'gate_failed',
116
+ /** `flows answer` named a wait the run is not asking: unknown, or already answered. */
117
+ 'human_wait_unknown',
114
118
  ] as const;
115
119
 
116
120
  /**
@@ -0,0 +1,201 @@
1
+ import { helperProviders } from '@relayflows/surface/runtime';
2
+ import type { TriggerSource } from '@relayflows/surface';
3
+ import { providerDeclaration } from './provider-trigger-contract.js';
4
+ import type { FlowSpec } from './spec.js';
5
+ import { helperCall } from './yaml-helpers.js';
6
+
7
+ /**
8
+ * What a flow needs from the workspace it deploys into, read from inert
9
+ * declarations only: the header, the trigger sources, and the text of the
10
+ * body. Nothing here executes a body or opens a socket, so the same answer is
11
+ * available to `flows check`, to the hosted verbs before they submit, and to
12
+ * Cloud's deploy wizard from the source alone.
13
+ *
14
+ * Helper use is recognised exactly as `preflightHelpers` recognises it — a
15
+ * `tools.<namespace>: true` flag or a `f.<namespace>` reference in the body —
16
+ * so a flow that preflight would refuse for a missing mount is a flow whose
17
+ * integration this module names. Coding-agent harnesses come from the `cli:`
18
+ * each `f.agent`/`f.llm` call declares, else the project default, else Cloud's
19
+ * default (`claude`): the same precedence `resolveCli` applies at run time.
20
+ */
21
+
22
+ export const FLOW_HARNESSES = ['claude', 'codex', 'gemini'] as const;
23
+ export type FlowHarness = (typeof FLOW_HARNESSES)[number];
24
+
25
+ export interface FlowIntegrationRequirement {
26
+ /** Cloud integration provider id (`slack`, `github`, `linear`, …). */
27
+ provider: string;
28
+ /** `tools`: a header declaration; `source`: a trigger or deploy target; `helper`: body use without a flag, or a YAML helper step. */
29
+ from: 'tools' | 'source' | 'helper';
30
+ /** The declaration that requires it, as a reader would name it: `tools.slack`, `--on github`, `f.slack`. */
31
+ detail: string;
32
+ }
33
+
34
+ export interface FlowHarnessRequirement {
35
+ harness: FlowHarness;
36
+ /** `agent "review"`, `llm step`, `step "draft"`, or `default`. */
37
+ detail: string;
38
+ }
39
+
40
+ export interface FlowRequirements {
41
+ integrations: FlowIntegrationRequirement[];
42
+ harnesses: FlowHarness[];
43
+ /** One entry per harness, naming the first declaration that needs it. */
44
+ harnessUses: FlowHarnessRequirement[];
45
+ /** MCP servers `tools.mcp` names; the workspace must declare each in `flows.json`. */
46
+ mcp: string[];
47
+ }
48
+
49
+ export interface FlowRequirementsContext {
50
+ /** Trigger sources the deployment listens on (`--on`, or the wizard's chosen sources). */
51
+ sources?: readonly { provider: string }[];
52
+ /** Set when the deployment targets a repository: every launched run needs GitHub. */
53
+ repository?: boolean | { owner: string; name: string };
54
+ /** The nearest `flows.json` `cli`, when one applies. */
55
+ projectCli?: string;
56
+ }
57
+
58
+ /** The inert subset of an authored definition this module reads. */
59
+ export interface RequirementsFlowDefinition {
60
+ readonly header?: { readonly tools?: Readonly<Record<string, unknown>> };
61
+ readonly body?: Function;
62
+ readonly handlers?: readonly { readonly trigger: TriggerSource }[];
63
+ }
64
+
65
+ export function flowRequirements(
66
+ flow: RequirementsFlowDefinition | FlowSpec,
67
+ context: FlowRequirementsContext = {},
68
+ ): FlowRequirements {
69
+ const integrations = new Map<string, FlowIntegrationRequirement>();
70
+ const harnessUses = new Map<FlowHarness, FlowHarnessRequirement>();
71
+ const mcp = new Set<string>();
72
+ const declare = (requirement: FlowIntegrationRequirement): void => {
73
+ if (!integrations.has(requirement.provider)) integrations.set(requirement.provider, requirement);
74
+ };
75
+ const need = (harness: FlowHarness | undefined, detail: string): void => {
76
+ if (harness !== undefined && !harnessUses.has(harness)) harnessUses.set(harness, { harness, detail });
77
+ };
78
+ const fallback = harnessFromCli(context.projectCli) ?? 'claude';
79
+
80
+ if (isCompiledSpec(flow)) {
81
+ for (const step of flow.steps) {
82
+ // Preflight owns shape refusals; a malformed step is simply not a worker step here.
83
+ if (typeof step !== 'object' || step === null || (step.type !== 'llm' && step.type !== 'agent')) continue;
84
+ // A YAML helper step (`slack: { post: … }`) compiles to an agent step
85
+ // carrying a helper envelope: it needs the provider's mount, not a harness.
86
+ const helper = step.type === 'agent' ? compiledHelper(step) : undefined;
87
+ if (helper !== undefined) {
88
+ declare({ provider: helper, from: 'helper', detail: `step "${step.id}"` });
89
+ continue;
90
+ }
91
+ const named = step.type === 'agent' && step.agent !== undefined ? flow.agents?.[step.agent]?.cli : undefined;
92
+ const cli = step.cli ?? named ?? flow.cli;
93
+ need(cli === undefined ? fallback : harnessFromCli(cli), `step "${step.id}"`);
94
+ }
95
+ } else {
96
+ const tools = flow.header?.tools ?? {};
97
+ for (const { provider, namespace } of helperProviders) {
98
+ if (tools[namespace] === true) declare({ provider, from: 'tools', detail: `tools.${namespace}` });
99
+ }
100
+ for (const entry of stringList(tools['relayfile'])) {
101
+ const provider = entry.split('/')[0]?.trim();
102
+ if (provider) declare({ provider, from: 'tools', detail: 'tools.relayfile' });
103
+ }
104
+ for (const server of stringList(tools['mcp'])) mcp.add(server);
105
+ for (const handler of flow.handlers ?? []) {
106
+ const declaration = providerDeclaration(handler.trigger);
107
+ if (declaration !== undefined) {
108
+ declare({ provider: declaration.provider, from: 'source', detail: `on ${declaration.provider} ${declaration.type}` });
109
+ }
110
+ }
111
+ // Only the default body is scanned for helper and worker calls: hosted
112
+ // deployments and schedules dispatch the default body with the trigger's
113
+ // payload as input, and handler bodies are not dispatched yet (flows #301).
114
+ // A handler's *trigger* is still a requirement — it is what wakes the flow.
115
+ const text = typeof flow.body === 'function' ? Function.prototype.toString.call(flow.body) : '';
116
+ const root = contextParameter(text);
117
+ if (root !== undefined) {
118
+ for (const { provider, namespace } of helperProviders) {
119
+ if (helperReference(root, namespace).test(text)) declare({ provider, from: 'helper', detail: `f.${namespace}` });
120
+ }
121
+ for (const use of workerCalls(root, text)) need(use.cli === undefined ? fallback : harnessFromCli(use.cli), use.detail);
122
+ }
123
+ }
124
+
125
+ for (const source of context.sources ?? []) {
126
+ declare({ provider: source.provider, from: 'source', detail: `--on ${source.provider}` });
127
+ }
128
+ if (context.repository) declare({ provider: 'github', from: 'source', detail: 'deploy target' });
129
+
130
+ const uses = [...harnessUses.values()];
131
+ return {
132
+ integrations: [...integrations.values()],
133
+ harnesses: uses.map(use => use.harness),
134
+ harnessUses: uses,
135
+ mcp: [...mcp],
136
+ };
137
+ }
138
+
139
+ /** `slack (tools.slack), github (deploy target), claude (agent "review")` — the `REQUIRES` line's body. */
140
+ export function describeFlowRequirements(requirements: FlowRequirements): string {
141
+ return [
142
+ ...requirements.integrations.map(integration => `${integration.provider} (${integration.detail})`),
143
+ ...requirements.harnessUses.map(use => `${use.harness} (${use.detail})`),
144
+ ...requirements.mcp.map(server => `mcp ${server} (tools.mcp)`),
145
+ ].join(', ');
146
+ }
147
+
148
+ /** `claude`, `/opt/bin/codex`, `gemini.exe` → the Cloud harness; anything else is not one. */
149
+ export function harnessFromCli(cli: string | undefined): FlowHarness | undefined {
150
+ if (cli === undefined) return undefined;
151
+ const base = cli.trim().split(/[\\/]/u).pop()?.replace(/\.(?:exe|cmd|bat)$/iu, '').toLowerCase();
152
+ return (FLOW_HARNESSES as readonly string[]).includes(base ?? '') ? base as FlowHarness : undefined;
153
+ }
154
+
155
+ /** The provider of a compiled YAML helper step, or undefined for an ordinary agent step or a malformed envelope. */
156
+ function compiledHelper(step: Parameters<typeof helperCall>[0]): string | undefined {
157
+ try {
158
+ return helperCall(step)?.provider;
159
+ } catch {
160
+ return undefined;
161
+ }
162
+ }
163
+
164
+ function isCompiledSpec(flow: RequirementsFlowDefinition | FlowSpec): flow is FlowSpec {
165
+ return Array.isArray((flow as FlowSpec).steps) && typeof (flow as FlowSpec).version === 'string';
166
+ }
167
+
168
+ function stringList(value: unknown): string[] {
169
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
170
+ }
171
+
172
+ /** The body's first parameter (`f` in `async (f, input) => …`), escaped for a pattern. */
173
+ function contextParameter(body: string): string | undefined {
174
+ const parameter = body.match(/^(?:async\s+)?(?:function(?:\s+[\w$]+)?\s*)?(?:\(\s*([\w$]+)|([\w$]+)\s*=>)/u);
175
+ return (parameter?.[1] ?? parameter?.[2])?.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
176
+ }
177
+
178
+ /** Same recognition as `preflightHelpers`: `f.slack`, `f .slack`, `f["slack"]`. */
179
+ function helperReference(root: string, namespace: string): RegExp {
180
+ return new RegExp(`(?:^|[^\\w$.])${root}\\s*(?:\\.\\s*${namespace}\\b|\\[\\s*['"]${namespace}['"]\\s*\\])`, 'u');
181
+ }
182
+
183
+ interface WorkerCall { detail: string; cli?: string }
184
+
185
+ /**
186
+ * Each `f.agent(name, { cli })` / `f.llm(prompt, { cli })` call and the CLI it
187
+ * declares. The options object is read only up to the next worker call, so a
188
+ * `cli:` belongs to the call it follows; a call without one takes the default.
189
+ */
190
+ function workerCalls(root: string, body: string): WorkerCall[] {
191
+ const call = new RegExp(`(?:^|[^\\w$.])${root}\\s*\\.\\s*(agent|llm)\\s*\\(\\s*(?:(['"\`])([^'"\`]*)\\2)?`, 'gu');
192
+ const starts = [...body.matchAll(call)];
193
+ return starts.map((match, index) => {
194
+ const slice = body.slice(match.index! + match[0].length, starts[index + 1]?.index ?? body.length);
195
+ const cli = slice.match(/(?:^|[^\w$])cli\s*:\s*(['"`])([^'"`]*)\1/u)?.[2];
196
+ const detail = match[1] === 'agent'
197
+ ? (match[3] ? `agent ${JSON.stringify(match[3])}` : 'agent step')
198
+ : 'llm step';
199
+ return { detail, ...(cli === undefined ? {} : { cli }) };
200
+ });
201
+ }
package/src/index.ts CHANGED
@@ -67,10 +67,23 @@ export {
67
67
  downloadCloudPatch, applyCloudPatch, packWorkingTree, patchedPaths, MAX_SYNC_BYTES,
68
68
  type CloudPatch, type PackedTree,
69
69
  } from './cloud-sync.js';
70
+ export {
71
+ scheduleInCloud, listCloudSchedules, unscheduleInCloud, everyToCron, declaredScheduleCron,
72
+ type ScheduleInCloudInput, type CloudSchedule,
73
+ } from './cloud-schedule.js';
74
+ export { prepareCloudSubmission, cloudSubmissionBody, type CloudSubmission } from './cloud-run.js';
70
75
  export {
71
76
  deployToCloud, listCloudDeployments, undeployFromCloud, parseRepository, parseTriggerSource, FLOW_TRIGGER_PROVIDERS,
72
77
  type DeployToCloudInput, type CloudDeployment, type CloudDeploymentSummary, type FlowTriggerSource, type FlowTriggerProvider,
73
78
  } from './cloud-deploy.js';
79
+ export {
80
+ ensureIntegrationsConnected, integrationConnected, providerLabel,
81
+ type ConnectPrompt, type EnsureConnectionsOptions, type ConnectionsOutcome,
82
+ } from './cloud-connect.js';
83
+ export {
84
+ flowRequirements, describeFlowRequirements, harnessFromCli, FLOW_HARNESSES,
85
+ type FlowRequirements, type FlowRequirementsContext, type FlowIntegrationRequirement, type FlowHarnessRequirement, type FlowHarness,
86
+ } from './flow-requirements.js';
74
87
 
75
88
  export { canonicalize, specHash } from './canonical.js';
76
89
  export {
@@ -233,3 +246,4 @@ export { createFlow, type CreateFlowOptions, type CreatedFlow } from './create-f
233
246
 
234
247
  export { renderProgress, type ProgressEvent } from './progress.js';
235
248
  export { webhookTriggerSpec } from './trigger-executor.js';
249
+ export { scheduleTriggerSpec, scheduleLowering, SCHEDULE_EXECUTOR, type ScheduleLowering } from './schedule-trigger.js';
@@ -386,6 +386,26 @@ export class JournalClient extends EventEmitter {
386
386
  }
387
387
 
388
388
  /** Satisfy `wait.event`; a human response arrives here too. */
389
+ /**
390
+ * Park the dispatched attempt this connection holds on a human question.
391
+ * Releases the lease; the caller must not heartbeat or complete afterwards.
392
+ */
393
+ stepWait(
394
+ runId: string,
395
+ stepId: string,
396
+ attempt: number,
397
+ idempotencyKey: string,
398
+ wait: { wait_id: string; prompt: string; requested_of: string; options?: string[]; timeout_at_ms?: number },
399
+ ): Promise<VerbContract['step.wait']['result']> {
400
+ return this.request('step.wait', {
401
+ run_id: runId,
402
+ step_id: stepId,
403
+ attempt,
404
+ idempotency_key: idempotencyKey,
405
+ ...wait,
406
+ });
407
+ }
408
+
389
409
  eventEmit(runId: string, eventKey: string, payload: unknown): Promise<VerbContract['event.emit']['result']> {
390
410
  return this.request('event.emit', { run_id: runId, event_key: eventKey, payload });
391
411
  }
@@ -60,6 +60,15 @@ export function lowerNamedGates(steps: readonly StepSpec[]): StepSpec[] {
60
60
  }
61
61
 
62
62
  function gateCommand(gate: NamedDataGate, deterministic: boolean): string {
63
+ if (gate.type === 'artifact_exists') {
64
+ // The whole output envelope is bound; the verdict is whether the worker's
65
+ // journaled `artifacts` list names the path. Nothing on disk is consulted,
66
+ // so the journal alone reproduces the verdict on replay and resume.
67
+ return `node -e ${quote(`const input=JSON.parse(process.env.FLOWS_INPUT);
68
+ const output=input.output;
69
+ const artifacts=output!==null&&typeof output==='object'&&Array.isArray(output.artifacts)?output.artifacts:[];
70
+ process.exit(artifacts.includes(${JSON.stringify(gate.path)})?0:1);`)}`;
71
+ }
63
72
  const path = gate.type === 'subprocess_gate' ? gate.from_output
64
73
  : gate.type === 'word_count_bounds' ? undefined : gate.in_output_at;
65
74
  // Only compiler-owned code is serialized. Author strings are JSON literals;
@@ -2,7 +2,7 @@ import { RE2JS } from 're2js';
2
2
  import type { NamedDataGate, VerificationSpec } from './spec.js';
3
3
 
4
4
  export const NAMED_GATE_FAILURE_KINDS = [
5
- 'unknown_gate_kind', 'gate_pattern_invalid', 'gate_command_missing', 'gate_bound_invalid',
5
+ 'unknown_gate_kind', 'gate_pattern_invalid', 'gate_command_missing', 'gate_bound_invalid', 'gate_path_invalid',
6
6
  ] as const;
7
7
  export type NamedGateFailureKind = typeof NAMED_GATE_FAILURE_KINDS[number];
8
8
 
@@ -11,6 +11,7 @@ export const NAMED_GATE_KEYS: Record<NamedDataGate['type'], readonly string[]> =
11
11
  subprocess_gate: ['type', 'command', 'from_output'],
12
12
  word_count_bounds: ['type', 'min', 'max'],
13
13
  regex_match: ['type', 'pattern', 'in_output_at', 'flags'],
14
+ artifact_exists: ['type', 'path'],
14
15
  };
15
16
 
16
17
  export function isNamedGate(gate: VerificationSpec | undefined): gate is NamedDataGate {
@@ -45,6 +46,15 @@ export function namedGateErrors(gate: Record<string, unknown>, input: unknown, a
45
46
  errors.push(`${at}.command: gate_command_missing: expected a non-empty shell command`);
46
47
  }
47
48
  break;
49
+ case 'artifact_exists': {
50
+ const path = gate.path;
51
+ const segments = typeof path === 'string' ? path.split('/') : [];
52
+ if (typeof path !== 'string' || !path.trim() || path.includes('\0') || path.startsWith('/')
53
+ || path !== path.trim() || segments.some(segment => segment === '' || segment === '.' || segment === '..')) {
54
+ errors.push(`${at}.path: gate_path_invalid: expected a relative POSIX path without empty, "." or ".." segments`);
55
+ }
56
+ break;
57
+ }
48
58
  case 'word_count_bounds': {
49
59
  const { min, max } = gate;
50
60
  if ([min, max].some(n => n !== undefined && (!Number.isSafeInteger(n) || (n as number) < 0))