@relayflows/sdk 2.0.16 → 2.0.17

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 (121) hide show
  1. package/dist/authored-flow-error.d.ts +1 -1
  2. package/dist/authored-flow-error.d.ts.map +1 -1
  3. package/dist/authored-flow-error.js.map +1 -1
  4. package/dist/authored-flow-executor.d.ts.map +1 -1
  5. package/dist/authored-flow-executor.js +86 -0
  6. package/dist/authored-flow-executor.js.map +1 -1
  7. package/dist/authored-flow-lifecycle.d.ts +10 -0
  8. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  9. package/dist/authored-flow-lifecycle.js +7 -0
  10. package/dist/authored-flow-lifecycle.js.map +1 -1
  11. package/dist/authored-flow-operation.d.ts +11 -0
  12. package/dist/authored-flow-operation.d.ts.map +1 -1
  13. package/dist/authored-flow-operation.js +32 -7
  14. package/dist/authored-flow-operation.js.map +1 -1
  15. package/dist/authored-node-runner.d.ts.map +1 -1
  16. package/dist/authored-node-runner.js +37 -14
  17. package/dist/authored-node-runner.js.map +1 -1
  18. package/dist/authored-worker-step.d.ts.map +1 -1
  19. package/dist/authored-worker-step.js +14 -25
  20. package/dist/authored-worker-step.js.map +1 -1
  21. package/dist/cli/check-triggers.d.ts.map +1 -1
  22. package/dist/cli/check-triggers.js +10 -0
  23. package/dist/cli/check-triggers.js.map +1 -1
  24. package/dist/cli/check.d.ts +14 -0
  25. package/dist/cli/check.d.ts.map +1 -1
  26. package/dist/cli/check.js.map +1 -1
  27. package/dist/cli/cloud-schedule.d.ts +29 -0
  28. package/dist/cli/cloud-schedule.d.ts.map +1 -0
  29. package/dist/cli/cloud-schedule.js +131 -0
  30. package/dist/cli/cloud-schedule.js.map +1 -0
  31. package/dist/cli/direct-run.js +1 -1
  32. package/dist/cli/direct-run.js.map +1 -1
  33. package/dist/cli/run.d.ts.map +1 -1
  34. package/dist/cli/run.js +5 -3
  35. package/dist/cli/run.js.map +1 -1
  36. package/dist/cli.d.ts.map +1 -1
  37. package/dist/cli.js +35 -0
  38. package/dist/cli.js.map +1 -1
  39. package/dist/cloud-deploy.d.ts.map +1 -1
  40. package/dist/cloud-deploy.js +12 -1
  41. package/dist/cloud-deploy.js.map +1 -1
  42. package/dist/cloud-http.d.ts +16 -1
  43. package/dist/cloud-http.d.ts.map +1 -1
  44. package/dist/cloud-http.js +44 -8
  45. package/dist/cloud-http.js.map +1 -1
  46. package/dist/cloud-run.d.ts +30 -0
  47. package/dist/cloud-run.d.ts.map +1 -1
  48. package/dist/cloud-run.js +70 -27
  49. package/dist/cloud-run.js.map +1 -1
  50. package/dist/cloud-schedule.d.ts +48 -0
  51. package/dist/cloud-schedule.d.ts.map +1 -0
  52. package/dist/cloud-schedule.js +125 -0
  53. package/dist/cloud-schedule.js.map +1 -0
  54. package/dist/failure-kinds.d.ts +3 -3
  55. package/dist/failure-kinds.d.ts.map +1 -1
  56. package/dist/failure-kinds.js +2 -0
  57. package/dist/failure-kinds.js.map +1 -1
  58. package/dist/index.d.ts +3 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +3 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/named-gate-lowering.js +9 -0
  63. package/dist/named-gate-lowering.js.map +1 -1
  64. package/dist/named-gates.d.ts +1 -1
  65. package/dist/named-gates.d.ts.map +1 -1
  66. package/dist/named-gates.js +11 -1
  67. package/dist/named-gates.js.map +1 -1
  68. package/dist/preflight.d.ts.map +1 -1
  69. package/dist/preflight.js +155 -9
  70. package/dist/preflight.js.map +1 -1
  71. package/dist/schedule-trigger.d.ts +35 -0
  72. package/dist/schedule-trigger.d.ts.map +1 -0
  73. package/dist/schedule-trigger.js +53 -0
  74. package/dist/schedule-trigger.js.map +1 -0
  75. package/dist/spec.d.ts +11 -1
  76. package/dist/spec.d.ts.map +1 -1
  77. package/dist/spec.js.map +1 -1
  78. package/dist/trigger-executor.js +1 -1
  79. package/dist/trigger-executor.js.map +1 -1
  80. package/dist/validate.js +1 -1
  81. package/dist/validate.js.map +1 -1
  82. package/dist/worker-cli.d.ts +10 -0
  83. package/dist/worker-cli.d.ts.map +1 -1
  84. package/dist/worker-cli.js +51 -22
  85. package/dist/worker-cli.js.map +1 -1
  86. package/dist/worker.d.ts.map +1 -1
  87. package/dist/worker.js +9 -0
  88. package/dist/worker.js.map +1 -1
  89. package/dist/wrapper-session.d.ts +3 -1
  90. package/dist/wrapper-session.d.ts.map +1 -1
  91. package/dist/wrapper-session.js +6 -3
  92. package/dist/wrapper-session.js.map +1 -1
  93. package/package.json +3 -3
  94. package/src/authored-flow-error.ts +1 -0
  95. package/src/authored-flow-executor.ts +87 -0
  96. package/src/authored-flow-lifecycle.ts +7 -0
  97. package/src/authored-flow-operation.ts +35 -10
  98. package/src/authored-node-runner.ts +35 -13
  99. package/src/authored-worker-step.ts +15 -26
  100. package/src/cli/check-triggers.ts +9 -0
  101. package/src/cli/check.ts +15 -0
  102. package/src/cli/cloud-schedule.ts +135 -0
  103. package/src/cli/direct-run.ts +1 -1
  104. package/src/cli/run.ts +5 -3
  105. package/src/cli.ts +32 -0
  106. package/src/cloud-deploy.ts +12 -1
  107. package/src/cloud-http.ts +51 -7
  108. package/src/cloud-run.ts +99 -26
  109. package/src/cloud-schedule.ts +158 -0
  110. package/src/failure-kinds.ts +2 -0
  111. package/src/index.ts +6 -0
  112. package/src/named-gate-lowering.ts +9 -0
  113. package/src/named-gates.ts +11 -1
  114. package/src/preflight.ts +132 -9
  115. package/src/schedule-trigger.ts +73 -0
  116. package/src/spec.ts +12 -1
  117. package/src/trigger-executor.ts +1 -1
  118. package/src/validate.ts +1 -1
  119. package/src/worker-cli.ts +41 -0
  120. package/src/worker.ts +9 -0
  121. package/src/wrapper-session.ts +5 -1
package/src/cloud-http.ts CHANGED
@@ -12,6 +12,15 @@ export interface CloudConnectionOptions {
12
12
  requestTimeoutMs?: number;
13
13
  }
14
14
 
15
+ /** A refusal Cloud answered as JSON: only these fields are ever read. */
16
+ export interface CloudRefusal {
17
+ code: string;
18
+ error: string;
19
+ /** Version-only descriptions, when a route names what it wanted vs. got. */
20
+ expected?: { packageName?: string; version?: string };
21
+ received?: { packageName?: string; version?: string };
22
+ }
23
+
15
24
  export class CloudFlowError extends Error {
16
25
  constructor(
17
26
  readonly code: 'configuration' | 'unsupported_source' | 'invalid_input' | 'invalid_response' | 'http_error'
@@ -19,6 +28,7 @@ export class CloudFlowError extends Error {
19
28
  | 'patch_conflict',
20
29
  message: string,
21
30
  readonly status?: number,
31
+ readonly refusal?: CloudRefusal,
22
32
  ) {
23
33
  super(message);
24
34
  this.name = 'CloudFlowError';
@@ -175,10 +185,20 @@ export async function cloudFetch(
175
185
  }
176
186
  if (!response.ok) {
177
187
  // Do not echo server response bodies: they may contain credentials or source.
178
- const refusal = init.detail ? await structuredRefusal(response) : undefined;
188
+ // Reading the body is itself a transport step: a cancellation or timeout
189
+ // there keeps its own classification instead of becoming an http_error.
190
+ let refusal: CloudRefusal | undefined;
191
+ if (init.detail) {
192
+ try {
193
+ refusal = await structuredRefusal(response);
194
+ } catch (error) {
195
+ options.signal?.throwIfAborted();
196
+ throw transportError(deadline.aborted ? deadline.reason : error);
197
+ }
198
+ }
179
199
  throw new CloudFlowError('http_error', refusal === undefined
180
200
  ? `Cloud request failed with HTTP ${response.status}.`
181
- : `Cloud refused (${refusal.code}): ${refusal.error}`, response.status);
201
+ : `Cloud refused (${refusal.code}): ${refusal.error}`, response.status, refusal);
182
202
  }
183
203
  try {
184
204
  return await response.json();
@@ -189,12 +209,36 @@ export async function cloudFetch(
189
209
  }
190
210
  }
191
211
 
192
- async function structuredRefusal(response: Response): Promise<{ code: string; error: string } | undefined> {
212
+ const REFUSAL_CODE = /^[a-z0-9_]{1,64}$/u;
213
+
214
+ /**
215
+ * Routes answer either `{ code, error }` or a bare `{ error: "<code>" }`;
216
+ * both are read, and an optional `expected`/`received` pair is reduced to
217
+ * package name and version. Nothing else in the body is looked at.
218
+ */
219
+ async function structuredRefusal(response: Response): Promise<CloudRefusal | undefined> {
193
220
  let body: unknown;
194
- try { body = await response.json(); } catch { return undefined; }
195
- if (!isCloudRecord(body) || typeof body.code !== 'string' || typeof body.error !== 'string') return undefined;
196
- if (!/^[a-z0-9_]{1,64}$/u.test(body.code) || body.error.length > 500) return undefined;
197
- return { code: body.code, error: body.error };
221
+ try {
222
+ body = await response.json();
223
+ } catch (error) {
224
+ // Only a body that is not JSON is "no structured refusal"; an aborted or
225
+ // timed-out read is a transport failure and propagates.
226
+ if (error instanceof SyntaxError) return undefined;
227
+ throw error;
228
+ }
229
+ if (!isCloudRecord(body) || typeof body.error !== 'string' || body.error.length > 500) return undefined;
230
+ const code = typeof body.code === 'string' ? body.code : body.error;
231
+ if (!REFUSAL_CODE.test(code)) return undefined;
232
+ const versionOnly = (value: unknown): CloudRefusal['expected'] => {
233
+ if (!isCloudRecord(value)) return undefined;
234
+ const out: { packageName?: string; version?: string } = {};
235
+ if (typeof value.packageName === 'string' && value.packageName.length <= 100) out.packageName = value.packageName;
236
+ if (typeof value.version === 'string' && /^[0-9A-Za-z.+-]{1,64}$/u.test(value.version)) out.version = value.version;
237
+ return out;
238
+ };
239
+ const expected = versionOnly(body.expected);
240
+ const received = versionOnly(body.received);
241
+ return { code, error: body.error, ...(expected ? { expected } : {}), ...(received ? { received } : {}) };
198
242
  }
199
243
 
200
244
  // Defensive path-segment constraint; accepting a new server ID format needs an SDK change.
package/src/cloud-run.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { RUN_COMPLETION_REASONS } from '@relayflows/surface';
1
+ import { RUN_COMPLETION_REASONS, type ScheduleTriggerSource } from '@relayflows/surface';
2
2
  import type { RunCompletionReason } from './protocol.js';
3
3
  import { readFile } from 'node:fs/promises';
4
4
  import { createHash } from 'node:crypto';
@@ -10,7 +10,7 @@ import type { FlowSpec } from './spec.js';
10
10
  import { snapshotJsonValue, type JsonValue } from './json-value.js';
11
11
  import { loadAuthoredFlow, type SurfaceModuleAuthority } from './authored-flow-loader.js';
12
12
  import {
13
- CloudFlowError, cloudConnection, cloudRequest, cloudRunId, isCloudRecord,
13
+ CloudFlowError, cloudConnection, cloudFetch, cloudRequest, cloudRunId, isCloudRecord,
14
14
  type CloudConnectionOptions,
15
15
  } from './cloud-http.js';
16
16
  import { packWorkingTree, prepareCloudSync, uploadCloudCode } from './cloud-sync.js';
@@ -53,6 +53,17 @@ export interface CloudAuthoredAuthority {
53
53
  readonly byteLength: number;
54
54
  readonly surface: SurfaceModuleAuthority;
55
55
  }
56
+ /** The request-body fields a submission contributes; identical for a run and a schedule. */
57
+ export function cloudSubmissionBody(submission: CloudSubmission): Record<string, unknown> {
58
+ return {
59
+ workflow: submission.workflow,
60
+ fileType: submission.fileType,
61
+ relayflowVersion: 'v2',
62
+ ...(submission.authoredAuthority === undefined ? {} : { authoredAuthority: submission.authoredAuthority }),
63
+ ...(submission.inputPresent ? { inputs: submission.inputs } : {}),
64
+ };
65
+ }
66
+
56
67
  export type CloudRunState =
57
68
  | { runId: string; status: 'pending' | 'launching' | 'running' }
58
69
  | { runId: string; status: 'completed' | 'failed' | 'cancelled'; completionReason: RunCompletionReason };
@@ -62,13 +73,31 @@ export type CloudRunState =
62
73
  * happens here; Cloud owns provisioning and the Rust engine owns execution.
63
74
  * Returns acceptance, not completion. No local daemon, CLI probe, or node up.
64
75
  */
65
- export async function runInCloud(
76
+ /**
77
+ * What a Cloud submission carries, before any request: the exact source or
78
+ * canonical spec, the pinned authority for authored source, the input, and a
79
+ * local correlation hash. Shared by `runInCloud` and `scheduleInCloud`, so a
80
+ * schedule stores exactly what a run would send.
81
+ */
82
+ export interface CloudSubmission {
83
+ readonly workflow: string;
84
+ readonly fileType: 'yaml' | 'ts';
85
+ readonly authoredAuthority?: CloudAuthoredAuthority;
86
+ readonly inputs?: JsonValue;
87
+ readonly inputPresent: boolean;
88
+ readonly specHash: string;
89
+ /** The authored flow's declared name, or the spec name. */
90
+ readonly name: string;
91
+ /** Authored `schedule.*` handlers, for `flows schedule` to pick up. */
92
+ readonly schedules: readonly ScheduleTriggerSource[];
93
+ }
94
+
95
+ export async function prepareCloudSubmission(
66
96
  flow: CloudFlowSource,
67
- options: RunInCloudOptions = {},
68
- ): Promise<CloudRunReceipt> {
69
- const { baseUrl } = cloudConnection(options);
97
+ options: { input?: JsonValue; signal?: AbortSignal } = {},
98
+ ): Promise<CloudSubmission> {
70
99
  let spec: FlowSpec | undefined;
71
- let authored: { source: string; authority: CloudAuthoredAuthority } | undefined;
100
+ let authored: { source: string; authority: CloudAuthoredAuthority; name: string; schedules: ScheduleTriggerSource[] } | undefined;
72
101
  const inputPresent = Object.prototype.hasOwnProperty.call(options, 'input');
73
102
  let authoredInput: JsonValue | undefined;
74
103
  try {
@@ -80,14 +109,27 @@ export async function runInCloud(
80
109
  if (!bytes.length || Buffer.from(source, 'utf8').compare(bytes) !== 0) {
81
110
  throw new CloudFlowError('invalid_input', 'Authored source must be nonempty, lossless UTF-8.');
82
111
  }
83
- const loaded = await loadAuthoredFlow(flow.path);
84
- loaded.getDefinition(loaded.handle);
112
+ let loaded: Awaited<ReturnType<typeof loadAuthoredFlow>>;
113
+ let definition: ReturnType<typeof loaded.getDefinition>;
114
+ try {
115
+ loaded = await loadAuthoredFlow(flow.path);
116
+ definition = loaded.getDefinition(loaded.handle);
117
+ } catch (error) {
118
+ // An authored source that does not load is an authoring problem, and
119
+ // the most common one is `@relayflows/surface` not being resolvable
120
+ // from the flow's directory. Name it; do not call it a spec problem.
121
+ throw new CloudFlowError('unsupported_source',
122
+ `${flow.path} is not a loadable authored flow: ${error instanceof Error ? error.message : String(error)}. `
123
+ + 'Run `flows check` on it from the same directory.');
124
+ }
85
125
  if (loaded.graph.length !== 1) {
86
126
  throw new CloudFlowError('unsupported_source',
87
127
  'Cloud authored submission currently accepts one self-contained .flow.ts source without use dependencies.');
88
128
  }
89
129
  authored = {
90
130
  source,
131
+ name: definition.name,
132
+ schedules: definition.handlers.flatMap(h => h.trigger.kind === 'schedule' ? [h.trigger] : []),
91
133
  authority: Object.freeze({
92
134
  schemaVersion: 1,
93
135
  sourceSha256: createHash('sha256').update(bytes).digest('hex'),
@@ -130,12 +172,32 @@ export async function runInCloud(
130
172
  throw new CloudFlowError('invalid_input', 'Cannot read or compile the declarative flow. Check the file path and YAML/JSON spec.');
131
173
  }
132
174
  options.signal?.throwIfAborted();
133
- const hash = authored === undefined
134
- ? specHash(toKernelSpec(spec!))
135
- : createHash('sha256').update(canonicalize({
136
- authority: authored.authority,
137
- input: authoredInput,
138
- })).digest('hex');
175
+ if (authored === undefined) {
176
+ const kernel = toKernelSpec(spec!);
177
+ // JSON is a YAML subset. Sending canonical data preserves the exact spec
178
+ // while using the server's existing YAML-to-config admission path.
179
+ return { workflow: canonicalize(spec), fileType: 'yaml', inputPresent: false, specHash: specHash(kernel),
180
+ name: spec!.name ?? "flow", schedules: [] };
181
+ }
182
+ return {
183
+ workflow: authored.source, fileType: 'ts', authoredAuthority: authored.authority,
184
+ inputs: authoredInput, inputPresent: true, name: authored.name, schedules: authored.schedules,
185
+ specHash: createHash('sha256').update(canonicalize({ authority: authored.authority, input: authoredInput })).digest('hex'),
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Submit a declarative flow to Cloud's pinned v2 runtime. Compilation only
191
+ * happens here; Cloud owns provisioning and the Rust engine owns execution.
192
+ * Returns acceptance, not completion. No local daemon, CLI probe, or node up.
193
+ */
194
+ export async function runInCloud(
195
+ flow: CloudFlowSource,
196
+ options: RunInCloudOptions = {},
197
+ ): Promise<CloudRunReceipt> {
198
+ const { baseUrl } = cloudConnection(options);
199
+ const submission = await prepareCloudSubmission(flow, options);
200
+ const hash = submission.specHash;
139
201
  // Sync before submission: `prepare` reserves the run ID and the upload lands
140
202
  // under it, so the run request below names code Cloud already holds. A
141
203
  // refused backend or failed upload therefore never leaves a launched run
@@ -156,17 +218,28 @@ export async function runInCloud(
156
218
  }
157
219
  options.signal?.throwIfAborted();
158
220
  options.onSubmit?.();
159
- const result = await cloudRequest('/api/v1/workflows/run', options, {
160
- // JSON is a YAML subset. Sending canonical data preserves the exact spec
161
- // while using the server's existing YAML-to-config admission path.
162
- workflow: authored?.source ?? canonicalize(spec),
163
- fileType: authored === undefined ? 'yaml' : 'ts',
164
- relayflowVersion: 'v2',
165
- ...(authored === undefined ? {} : { authoredAuthority: authored.authority }),
166
- ...(authored === undefined ? {} : { inputs: authoredInput }),
167
- ...(options.workspaceId === undefined ? {} : { workspaceId: options.workspaceId }),
168
- ...(synced === undefined ? {} : { runId: synced.runId, s3CodeKey: synced.codeKey }),
169
- });
221
+ let result: unknown;
222
+ try {
223
+ result = await cloudFetch('/api/v1/workflows/run', options, { method: 'POST', detail: true, body: JSON.stringify({
224
+ ...cloudSubmissionBody(submission),
225
+ ...(options.workspaceId === undefined ? {} : { workspaceId: options.workspaceId }),
226
+ ...(synced === undefined ? {} : { runId: synced.runId, s3CodeKey: synced.codeKey }),
227
+ }) });
228
+ } catch (error) {
229
+ // Cloud accepts exactly one authored Surface (the one its sandbox runs).
230
+ // A CLI on another release is refused with this code; say which side is
231
+ // which instead of leaving the user with a bare 400 (flows#461).
232
+ if (error instanceof CloudFlowError && submission.authoredAuthority !== undefined
233
+ && error.refusal?.code === 'relayflow_v2_authored_authority_invalid') {
234
+ const expected = error.refusal.expected?.version;
235
+ throw new CloudFlowError('unsupported_source',
236
+ `Cloud refused this authored flow's Surface: it runs @relayflows/surface ${expected ?? '(version not reported)'} `
237
+ + `and this CLI authored against ${submission.authoredAuthority.surface.version}. `
238
+ + 'Install the matching relayflows release, or deploy the flow with `flows deploy`, which uses Cloud\'s own Surface.',
239
+ error.status, error.refusal);
240
+ }
241
+ throw error;
242
+ }
170
243
  if (!isCloudRecord(result) || (result.status !== 'pending' && result.status !== 'running')) {
171
244
  throw new CloudFlowError('invalid_response', 'Cloud did not return an accepted run.');
172
245
  }
@@ -0,0 +1,158 @@
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
+ export async function scheduleInCloud(
73
+ input: ScheduleInCloudInput, options: CloudConnectionOptions = {},
74
+ ): Promise<CloudSchedule> {
75
+ if (input.cron !== undefined && input.every !== undefined) {
76
+ throw new CloudFlowError('invalid_input', 'Give --cron or --every, not both.');
77
+ }
78
+ const submission = await prepareCloudSubmission(input.flow, {
79
+ ...(Object.prototype.hasOwnProperty.call(input, 'input') ? { input: input.input } : {}),
80
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
81
+ });
82
+ let cron: string;
83
+ let tz = input.tz;
84
+ if (input.cron !== undefined) {
85
+ try { parseCron(input.cron); } catch (error) {
86
+ throw new CloudFlowError('invalid_input', `--cron: ${error instanceof Error ? error.message : String(error)}`);
87
+ }
88
+ cron = input.cron.trim();
89
+ } else if (input.every !== undefined) {
90
+ cron = everyToCron(input.every);
91
+ } else {
92
+ if (submission.schedules.length !== 1) {
93
+ throw new CloudFlowError('invalid_input', submission.schedules.length === 0
94
+ ? 'The flow declares no schedule.* handler; give --cron or --every.'
95
+ : 'The flow declares several schedules; say which with --cron or --every.');
96
+ }
97
+ const declared = declaredScheduleCron(submission.schedules[0]!);
98
+ cron = declared.cron;
99
+ tz ??= declared.tz;
100
+ }
101
+ const timezone = tz === undefined ? 'UTC' : assertTimeZone(tz);
102
+ const name = (input.name ?? submission.name).trim();
103
+ if (!name) throw new CloudFlowError('invalid_input', 'Schedule name must not be empty.');
104
+ options.signal?.throwIfAborted();
105
+ const result = await cloudFetch('/api/v1/workflows/schedules', options, {
106
+ method: 'POST', detail: true,
107
+ body: JSON.stringify({
108
+ name,
109
+ schedule_type: 'cron',
110
+ cron_expression: cron,
111
+ timezone,
112
+ workflowRequest: cloudSubmissionBody(submission),
113
+ }),
114
+ });
115
+ const record = isCloudRecord(result) && isCloudRecord(result.schedule) ? result.schedule : undefined;
116
+ if (record === undefined) throw new CloudFlowError('invalid_response', 'Cloud did not return a schedule.');
117
+ return toCloudSchedule(record);
118
+ }
119
+
120
+ function toCloudSchedule(row: Record<string, unknown>): CloudSchedule {
121
+ if (typeof row.id !== 'string' || !SCHEDULE_ID.test(row.id) || typeof row.name !== 'string') {
122
+ throw new CloudFlowError('invalid_response', 'Cloud returned a malformed schedule.');
123
+ }
124
+ const optional = (key: string): Record<string, string> =>
125
+ typeof row[key] === 'string' ? { [key]: row[key] as string } : {};
126
+ return {
127
+ id: row.id, name: row.name,
128
+ cronExpression: typeof row.cronExpression === 'string' ? row.cronExpression : '',
129
+ timezone: typeof row.timezone === 'string' ? row.timezone : 'UTC',
130
+ status: typeof row.status === 'string' ? row.status : 'unknown',
131
+ ...optional('createdAt'), ...optional('lastTriggeredAt'), ...optional('lastTriggeredRunId'), ...optional('lastTriggerStatus'),
132
+ };
133
+ }
134
+
135
+ export async function listCloudSchedules(options: CloudConnectionOptions = {}): Promise<CloudSchedule[]> {
136
+ const payload = await cloudRequest('/api/v1/workflows/schedules', options);
137
+ if (!isCloudRecord(payload) || !Array.isArray(payload.schedules)) {
138
+ throw new CloudFlowError('invalid_response', 'Cloud did not return a schedules list.');
139
+ }
140
+ return payload.schedules.map(row => {
141
+ if (!isCloudRecord(row)) throw new CloudFlowError('invalid_response', 'Cloud returned a malformed schedule row.');
142
+ return toCloudSchedule(row);
143
+ });
144
+ }
145
+
146
+ export async function unscheduleInCloud(scheduleId: string, options: CloudConnectionOptions = {}): Promise<void> {
147
+ if (!SCHEDULE_ID.test(scheduleId)) throw new CloudFlowError('invalid_input', `"${scheduleId}" is not a schedule id.`);
148
+ const result = await cloudFetch(`/api/v1/workflows/schedules/${encodeURIComponent(scheduleId)}`, options,
149
+ { method: 'DELETE', detail: true });
150
+ if (!isCloudRecord(result) || result.deleted !== true) {
151
+ throw new CloudFlowError('invalid_response', 'Cloud did not confirm the deletion.');
152
+ }
153
+ }
154
+
155
+ /** Exposed for previews: the tick grid a UTC cron amounts to, if it is exactly one. */
156
+ export function cronIntervalMs(cron: string): number | undefined {
157
+ return cronGrid(parseCron(cron))?.intervalMs;
158
+ }
@@ -111,6 +111,8 @@ 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',
114
116
  ] as const;
115
117
 
116
118
  /**
package/src/index.ts CHANGED
@@ -67,6 +67,11 @@ 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,
@@ -233,3 +238,4 @@ export { createFlow, type CreateFlowOptions, type CreatedFlow } from './create-f
233
238
 
234
239
  export { renderProgress, type ProgressEvent } from './progress.js';
235
240
  export { webhookTriggerSpec } from './trigger-executor.js';
241
+ export { scheduleTriggerSpec, scheduleLowering, SCHEDULE_EXECUTOR, type ScheduleLowering } from './schedule-trigger.js';
@@ -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))