@relayflows/sdk 2.0.15 → 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.
- package/dist/agent-artifacts.d.ts +20 -0
- package/dist/agent-artifacts.d.ts.map +1 -0
- package/dist/agent-artifacts.js +81 -0
- package/dist/agent-artifacts.js.map +1 -0
- package/dist/authored-flow-error.d.ts +1 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts +3 -3
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +93 -7
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-lifecycle.d.ts +10 -0
- package/dist/authored-flow-lifecycle.d.ts.map +1 -1
- package/dist/authored-flow-lifecycle.js +7 -0
- package/dist/authored-flow-lifecycle.js.map +1 -1
- package/dist/authored-flow-operation.d.ts +11 -0
- package/dist/authored-flow-operation.d.ts.map +1 -1
- package/dist/authored-flow-operation.js +32 -7
- package/dist/authored-flow-operation.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +37 -14
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-step-output.d.ts +2 -0
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +5 -1
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +15 -8
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +10 -0
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +14 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +6 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-deploy.d.ts +30 -0
- package/dist/cli/cloud-deploy.d.ts.map +1 -0
- package/dist/cli/cloud-deploy.js +162 -0
- package/dist/cli/cloud-deploy.js.map +1 -0
- package/dist/cli/cloud-run.d.ts +3 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +37 -5
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/cloud-schedule.d.ts +29 -0
- package/dist/cli/cloud-schedule.d.ts.map +1 -0
- package/dist/cli/cloud-schedule.js +131 -0
- package/dist/cli/cloud-schedule.js.map +1 -0
- package/dist/cli/cloud-sync.d.ts +13 -0
- package/dist/cli/cloud-sync.d.ts.map +1 -0
- package/dist/cli/cloud-sync.js +39 -0
- package/dist/cli/cloud-sync.js.map +1 -0
- package/dist/cli/direct-run.js +1 -1
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +17 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +117 -12
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +70 -0
- package/dist/cloud-deploy.d.ts.map +1 -0
- package/dist/cloud-deploy.js +192 -0
- package/dist/cloud-deploy.js.map +1 -0
- package/dist/cloud-http.d.ts +52 -2
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +135 -9
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-run.d.ts +53 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +97 -25
- package/dist/cloud-run.js.map +1 -1
- package/dist/cloud-schedule.d.ts +48 -0
- package/dist/cloud-schedule.d.ts.map +1 -0
- package/dist/cloud-schedule.js +125 -0
- package/dist/cloud-schedule.js.map +1 -0
- package/dist/cloud-sync.d.ts +66 -0
- package/dist/cloud-sync.d.ts.map +1 -0
- package/dist/cloud-sync.js +287 -0
- package/dist/cloud-sync.js.map +1 -0
- package/dist/failure-kinds.d.ts +10 -3
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +10 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/named-gate-lowering.js +9 -0
- package/dist/named-gate-lowering.js.map +1 -1
- package/dist/named-gates.d.ts +1 -1
- package/dist/named-gates.d.ts.map +1 -1
- package/dist/named-gates.js +11 -1
- package/dist/named-gates.js.map +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +155 -9
- package/dist/preflight.js.map +1 -1
- package/dist/schedule-trigger.d.ts +35 -0
- package/dist/schedule-trigger.d.ts.map +1 -0
- package/dist/schedule-trigger.js +53 -0
- package/dist/schedule-trigger.js.map +1 -0
- package/dist/spec.d.ts +11 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/trigger-executor.js +1 -1
- package/dist/trigger-executor.js.map +1 -1
- package/dist/validate.js +1 -1
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +10 -0
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +51 -22
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +9 -0
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-session.d.ts +3 -1
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +6 -3
- package/dist/wrapper-session.js.map +1 -1
- package/package.json +3 -3
- package/src/agent-artifacts.ts +76 -0
- package/src/authored-flow-error.ts +1 -0
- package/src/authored-flow-executor.ts +95 -8
- package/src/authored-flow-lifecycle.ts +7 -0
- package/src/authored-flow-operation.ts +35 -10
- package/src/authored-node-runner.ts +35 -13
- package/src/authored-step-output.ts +6 -1
- package/src/authored-worker-step.ts +16 -9
- package/src/cli/check-triggers.ts +9 -0
- package/src/cli/check.ts +21 -1
- package/src/cli/cloud-deploy.ts +157 -0
- package/src/cli/cloud-run.ts +37 -6
- package/src/cli/cloud-schedule.ts +135 -0
- package/src/cli/cloud-sync.ts +37 -0
- package/src/cli/direct-run.ts +1 -1
- package/src/cli/run.ts +18 -5
- package/src/cli.ts +107 -12
- package/src/cloud-deploy.ts +247 -0
- package/src/cloud-http.ts +173 -9
- package/src/cloud-run.ts +143 -24
- package/src/cloud-schedule.ts +158 -0
- package/src/cloud-sync.ts +315 -0
- package/src/failure-kinds.ts +10 -0
- package/src/index.ts +14 -0
- package/src/named-gate-lowering.ts +9 -0
- package/src/named-gates.ts +11 -1
- package/src/preflight.ts +132 -9
- package/src/schedule-trigger.ts +73 -0
- package/src/spec.ts +12 -1
- package/src/trigger-executor.ts +1 -1
- package/src/validate.ts +1 -1
- package/src/worker-cli.ts +41 -0
- package/src/worker.ts +9 -0
- package/src/wrapper-session.ts +5 -1
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync, lstatSync, mkdtempSync, readdirSync, readlinkSync, rmSync } from 'node:fs';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { Readable } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { createGzip } from 'node:zlib';
|
|
9
|
+
import {
|
|
10
|
+
CloudFlowError, cloudFetch, cloudRequest, cloudRunId, isCloudRecord, type CloudConnectionOptions,
|
|
11
|
+
} from './cloud-http.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Code sync for hosted v2 runs, Cloud-API transport only.
|
|
15
|
+
*
|
|
16
|
+
* v1's `--sync-code` had two transports: an S3 client with STS credentials and
|
|
17
|
+
* the Cloud API's own storage route. v2 runs on Cloudflare, so this module
|
|
18
|
+
* speaks only the second: `prepare` must answer with a `cloud-api` storage
|
|
19
|
+
* backend, the tarball is `PUT` to `/workflows/runs/<id>/storage/<key>`, and
|
|
20
|
+
* the sandbox's post-run patch is read from `/workflows/runs/<id>/patch`. A
|
|
21
|
+
* `prepare` that hands back anything else is refused before any upload — the
|
|
22
|
+
* SDK never carries an AWS dependency and never sends code to a bucket it did
|
|
23
|
+
* not get through the Cloud API.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Uncompressed bytes. Matches the sandbox's extraction budget with headroom. */
|
|
27
|
+
export const MAX_SYNC_BYTES = 256 * 1024 * 1024;
|
|
28
|
+
const ALWAYS_SKIPPED = new Set(['.git', 'node_modules']);
|
|
29
|
+
const CODE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
|
30
|
+
|
|
31
|
+
export interface PreparedCloudSync {
|
|
32
|
+
runId: string;
|
|
33
|
+
codeKey: string;
|
|
34
|
+
/** Run-scoped upload credential from `prepare`; falls back to the session token. */
|
|
35
|
+
uploadToken?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** `POST /api/v1/workflows/prepare`, refusing every non-Cloud-API storage backend. */
|
|
39
|
+
export async function prepareCloudSync(options: CloudConnectionOptions): Promise<PreparedCloudSync> {
|
|
40
|
+
const receipt = await cloudFetch('/api/v1/workflows/prepare', options, { method: 'POST' });
|
|
41
|
+
if (!isCloudRecord(receipt)) throw new CloudFlowError('invalid_response', 'Cloud prepare returned a non-object.');
|
|
42
|
+
const storage = isCloudRecord(receipt.workflowStorage) ? receipt.workflowStorage : undefined;
|
|
43
|
+
const credentials = isCloudRecord(receipt.s3Credentials) ? receipt.s3Credentials : undefined;
|
|
44
|
+
const backend = storage?.backend ?? credentials?.backend;
|
|
45
|
+
if (backend !== 'cloud-api') {
|
|
46
|
+
throw new CloudFlowError('unsupported_storage_backend',
|
|
47
|
+
'Cloud offered a workflow storage backend other than the Cloud API (R2). '
|
|
48
|
+
+ 'flows v2 syncs code only through the Cloud API; it never uploads to AWS directly.');
|
|
49
|
+
}
|
|
50
|
+
const codeKey = typeof receipt.s3CodeKey === 'string' ? receipt.s3CodeKey : '';
|
|
51
|
+
if (!CODE_KEY.test(codeKey)) throw new CloudFlowError('invalid_response', 'Cloud prepare returned an unusable code key.');
|
|
52
|
+
const uploadToken = credentials?.cloudApiAccessToken;
|
|
53
|
+
if (uploadToken !== undefined && typeof uploadToken !== 'string') {
|
|
54
|
+
throw new CloudFlowError('invalid_response', 'Cloud prepare returned an unusable storage credential.');
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
runId: cloudRunId(receipt.runId), codeKey,
|
|
58
|
+
...(uploadToken === undefined ? {} : { uploadToken }),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface PackedTree {
|
|
63
|
+
/** The gzip'd ustar, spooled to a temporary file; `dispose()` removes it. */
|
|
64
|
+
archivePath: string;
|
|
65
|
+
/** Archive members, `root`-relative POSIX paths, sorted. */
|
|
66
|
+
files: string[];
|
|
67
|
+
/** Uncompressed byte total across regular files. */
|
|
68
|
+
bytes: number;
|
|
69
|
+
/** Symlinks left out because their target resolves outside `root`. */
|
|
70
|
+
skippedLinks: string[];
|
|
71
|
+
dispose(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* gzip'd ustar of the working tree at `root`, streamed to a temporary file so
|
|
76
|
+
* the peak footprint is one file plus the compressor's window, never the
|
|
77
|
+
* whole tree. Inside a Git checkout the member list is `git ls-files --cached
|
|
78
|
+
* --others --exclude-standard`, so `.gitignore` governs and untracked-but-
|
|
79
|
+
* not-ignored files ride along, as they do in v1. Outside Git, every regular
|
|
80
|
+
* file and in-tree symlink except `.git`/`node_modules`. A Git failure other
|
|
81
|
+
* than "not a repository" is refused rather than silently widened to the walk:
|
|
82
|
+
* an ignored `.env` must never reach Cloud because `git` was missing or the
|
|
83
|
+
* index was locked. Executable bits survive; symlinks whose target leaves the
|
|
84
|
+
* tree are dropped and reported, since on the host they would point at
|
|
85
|
+
* whatever happens to live at that path. Ordering and mtimes are
|
|
86
|
+
* deterministic, so the same tree packs to the same bytes.
|
|
87
|
+
*/
|
|
88
|
+
export async function packWorkingTree(root: string): Promise<PackedTree> {
|
|
89
|
+
const absoluteRoot = resolve(root);
|
|
90
|
+
const files = listTreeFiles(absoluteRoot);
|
|
91
|
+
const spool = mkdtempSync(join(tmpdir(), 'flows-sync-'));
|
|
92
|
+
const archivePath = join(spool, 'code.tar.gz');
|
|
93
|
+
const dispose = (): void => rmSync(spool, { recursive: true, force: true });
|
|
94
|
+
const members: string[] = [];
|
|
95
|
+
const skippedLinks: string[] = [];
|
|
96
|
+
let bytes = 0;
|
|
97
|
+
async function* entries(): AsyncGenerator<Buffer | NodeJS.ReadableStream> {
|
|
98
|
+
for (const path of files) {
|
|
99
|
+
const absolute = join(absoluteRoot, ...path.split('/'));
|
|
100
|
+
const stat = lstatSync(absolute);
|
|
101
|
+
if (stat.isSymbolicLink()) {
|
|
102
|
+
const target = readlinkSync(absolute);
|
|
103
|
+
const relativeTarget = relative(absoluteRoot, resolve(dirname(absolute), target));
|
|
104
|
+
if (isAbsolute(target) || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`)) {
|
|
105
|
+
skippedLinks.push(path);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
members.push(path);
|
|
109
|
+
yield ustarHeader(path, 0, '2', 0o777, target);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!stat.isFile()) continue;
|
|
113
|
+
bytes += stat.size;
|
|
114
|
+
if (bytes > MAX_SYNC_BYTES) {
|
|
115
|
+
throw new CloudFlowError('sync_too_large',
|
|
116
|
+
`The working tree exceeds the ${MAX_SYNC_BYTES}-byte code sync limit; narrow it with .gitignore.`);
|
|
117
|
+
}
|
|
118
|
+
members.push(path);
|
|
119
|
+
yield ustarHeader(path, stat.size, '0', stat.mode & 0o111 ? 0o755 : 0o644);
|
|
120
|
+
yield createReadStream(absolute);
|
|
121
|
+
const padding = (512 - (stat.size % 512)) % 512;
|
|
122
|
+
if (padding) yield Buffer.alloc(padding);
|
|
123
|
+
}
|
|
124
|
+
yield Buffer.alloc(1024);
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
await pipeline(Readable.from(flatten(entries())), createGzip({ level: 6 }), createWriteStream(archivePath));
|
|
128
|
+
} catch (error) {
|
|
129
|
+
dispose();
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
return { archivePath, files: members, bytes, skippedLinks, dispose };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Yield file streams chunk by chunk so `Readable.from` never buffers a whole file. */
|
|
136
|
+
async function* flatten(source: AsyncGenerator<Buffer | NodeJS.ReadableStream>): AsyncGenerator<Buffer> {
|
|
137
|
+
for await (const part of source) {
|
|
138
|
+
if (Buffer.isBuffer(part)) { yield part; continue; }
|
|
139
|
+
for await (const chunk of part as AsyncIterable<Buffer>) yield chunk;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The nearest `.git` entry at `root` or any ancestor — the checkout a .gitignore would belong to. */
|
|
144
|
+
function nearestGitEntry(root: string): string | undefined {
|
|
145
|
+
let directory = root;
|
|
146
|
+
while (true) {
|
|
147
|
+
if (existsSync(join(directory, '.git'))) return join(directory, '.git');
|
|
148
|
+
const parent = dirname(directory);
|
|
149
|
+
if (parent === directory) return undefined;
|
|
150
|
+
directory = parent;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function listTreeFiles(root: string): string[] {
|
|
155
|
+
const inside = spawnSync('git', ['-C', root, 'rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' });
|
|
156
|
+
if (inside.error !== undefined) {
|
|
157
|
+
// Without git there is no way to honour a .gitignore, so a tree that has
|
|
158
|
+
// one anywhere above it cannot be described honestly; a tree with none is
|
|
159
|
+
// a plain directory and can be walked.
|
|
160
|
+
const entry = nearestGitEntry(root);
|
|
161
|
+
if (entry !== undefined) {
|
|
162
|
+
throw new CloudFlowError('sync_unsupported',
|
|
163
|
+
`git is not available (${summarize(inside.error.message)}) but ${entry} exists; `
|
|
164
|
+
+ 'refusing to upload a checkout without its .gitignore.');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const notARepository = inside.error !== undefined
|
|
168
|
+
|| (inside.status !== 0 && /not a git repository/iu.test(inside.stderr));
|
|
169
|
+
let candidates: string[];
|
|
170
|
+
if (notARepository) {
|
|
171
|
+
// A `.git` that git itself cannot read — here or in a parent — is a broken
|
|
172
|
+
// checkout, not a plain directory: its .gitignore was meant to apply, so
|
|
173
|
+
// walking would upload exactly what it excluded.
|
|
174
|
+
const entry = nearestGitEntry(root);
|
|
175
|
+
if (entry !== undefined) {
|
|
176
|
+
throw new CloudFlowError('sync_unsupported',
|
|
177
|
+
`${entry} exists but git cannot read it (${summarize(inside.stderr ?? inside.error?.message)}); `
|
|
178
|
+
+ 'refusing to upload a checkout without its .gitignore.');
|
|
179
|
+
}
|
|
180
|
+
candidates = [];
|
|
181
|
+
walk(root, root, candidates);
|
|
182
|
+
} else {
|
|
183
|
+
if (inside.status !== 0 || inside.stdout.trim() !== 'true') {
|
|
184
|
+
throw new CloudFlowError('sync_unsupported',
|
|
185
|
+
`git could not describe ${root} (${summarize(inside.stderr)}); refusing to guess which files to upload.`);
|
|
186
|
+
}
|
|
187
|
+
const git = spawnSync('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'],
|
|
188
|
+
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
|
189
|
+
if (git.status !== 0) {
|
|
190
|
+
throw new CloudFlowError('sync_unsupported',
|
|
191
|
+
`git ls-files failed in ${root} (${summarize(git.stderr)}); refusing to upload without .gitignore.`);
|
|
192
|
+
}
|
|
193
|
+
candidates = git.stdout.split('\0').filter(Boolean);
|
|
194
|
+
}
|
|
195
|
+
const files = new Set<string>();
|
|
196
|
+
for (const candidate of candidates) {
|
|
197
|
+
const path = candidate.split(sep).join('/');
|
|
198
|
+
const segments = path.split('/');
|
|
199
|
+
if (segments.some(segment => ALWAYS_SKIPPED.has(segment) || segment === '..' || segment === '')) continue;
|
|
200
|
+
let stat;
|
|
201
|
+
try { stat = lstatSync(join(root, ...segments)); } catch { continue; }
|
|
202
|
+
if (stat.isFile() || stat.isSymbolicLink()) files.add(path);
|
|
203
|
+
}
|
|
204
|
+
return [...files].sort();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function summarize(stderr: string | undefined): string {
|
|
208
|
+
const line = (stderr ?? '').split('\n').map(l => l.trim()).find(Boolean) ?? 'no diagnostic';
|
|
209
|
+
return line.length > 160 ? `${line.slice(0, 157)}...` : line;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function walk(root: string, current: string, out: string[]): void {
|
|
213
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
214
|
+
if (ALWAYS_SKIPPED.has(entry.name)) continue;
|
|
215
|
+
const path = join(current, entry.name);
|
|
216
|
+
if (entry.isDirectory()) walk(root, path, out);
|
|
217
|
+
else if (entry.isFile() || entry.isSymbolicLink()) out.push(relative(root, path));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** POSIX ustar header. Names beyond the 100+155 split are refused, not truncated. */
|
|
222
|
+
function ustarHeader(path: string, size: number, type: '0' | '2', mode: number, link = ''): Buffer {
|
|
223
|
+
let name = path;
|
|
224
|
+
let prefix = '';
|
|
225
|
+
if (Buffer.byteLength(name) > 100) {
|
|
226
|
+
const cut = name.lastIndexOf('/', 154);
|
|
227
|
+
if (cut <= 0 || Buffer.byteLength(name.slice(cut + 1)) > 100 || Buffer.byteLength(name.slice(0, cut)) > 155) {
|
|
228
|
+
throw new CloudFlowError('sync_unsupported', `Path "${path}" is too long for the code sync archive.`);
|
|
229
|
+
}
|
|
230
|
+
prefix = name.slice(0, cut);
|
|
231
|
+
name = name.slice(cut + 1);
|
|
232
|
+
}
|
|
233
|
+
if (Buffer.byteLength(link) > 100) {
|
|
234
|
+
throw new CloudFlowError('sync_unsupported', `Symlink target of "${path}" is too long for the code sync archive.`);
|
|
235
|
+
}
|
|
236
|
+
const header = Buffer.alloc(512);
|
|
237
|
+
header.write(name, 0, 100);
|
|
238
|
+
header.write(mode.toString(8).padStart(7, '0'), 100, 8);
|
|
239
|
+
header.write('0000000', 108, 8);
|
|
240
|
+
header.write('0000000', 116, 8);
|
|
241
|
+
header.write(size.toString(8).padStart(11, '0'), 124, 12);
|
|
242
|
+
header.write('00000000000', 136, 12);
|
|
243
|
+
header.write(' ', 148, 8);
|
|
244
|
+
header.write(type, 156, 1);
|
|
245
|
+
header.write(link, 157, 100);
|
|
246
|
+
header.write('ustar\0', 257, 6);
|
|
247
|
+
header.write('00', 263, 2);
|
|
248
|
+
header.write(prefix, 345, 155);
|
|
249
|
+
let checksum = 0;
|
|
250
|
+
for (const byte of header) checksum += byte;
|
|
251
|
+
header.write(checksum.toString(8).padStart(6, '0') + '\0 ', 148, 8);
|
|
252
|
+
return header;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** `PUT` the archive through the Cloud API; the run-scoped credential wins when present. */
|
|
256
|
+
export async function uploadCloudCode(
|
|
257
|
+
prepared: PreparedCloudSync, packed: PackedTree, options: CloudConnectionOptions,
|
|
258
|
+
): Promise<void> {
|
|
259
|
+
// The request needs a sized body, so the compressed archive is read back
|
|
260
|
+
// once; that is the one copy that has to exist, and it is the small one.
|
|
261
|
+
const tarball = await readFile(packed.archivePath);
|
|
262
|
+
await cloudFetch(
|
|
263
|
+
`/api/v1/workflows/runs/${encodeURIComponent(prepared.runId)}/storage/${encodeURIComponent(prepared.codeKey)}`,
|
|
264
|
+
{ ...options, requestTimeoutMs: options.requestTimeoutMs ?? 300_000 },
|
|
265
|
+
{ method: 'PUT', body: tarball, contentType: 'application/gzip',
|
|
266
|
+
...(prepared.uploadToken === undefined ? {} : { bearerToken: prepared.uploadToken }) },
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface CloudPatch {
|
|
271
|
+
patch: string;
|
|
272
|
+
hasChanges: boolean;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The sandbox's post-run diff. Multi-path runs carry several patches and are refused here. */
|
|
276
|
+
export async function downloadCloudPatch(runId: string, options: CloudConnectionOptions): Promise<CloudPatch> {
|
|
277
|
+
const payload = await cloudRequest(`/api/v1/workflows/runs/${encodeURIComponent(cloudRunId(runId))}/patch`, options);
|
|
278
|
+
if (!isCloudRecord(payload)) throw new CloudFlowError('invalid_response', 'Cloud patch response was not an object.');
|
|
279
|
+
if (isCloudRecord(payload.patches)) {
|
|
280
|
+
const names = Object.keys(payload.patches);
|
|
281
|
+
throw new CloudFlowError('sync_unsupported',
|
|
282
|
+
`Run ${runId} produced ${names.length} path-scoped patches (${names.join(', ')}); flows sync applies single-tree runs only.`);
|
|
283
|
+
}
|
|
284
|
+
if (typeof payload.patch !== 'string' || typeof payload.hasChanges !== 'boolean') {
|
|
285
|
+
throw new CloudFlowError('invalid_response', 'Cloud patch response is missing patch or hasChanges.');
|
|
286
|
+
}
|
|
287
|
+
return { patch: payload.patch, hasChanges: payload.hasChanges };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Every path a unified diff touches, deletions included, in order of first appearance. */
|
|
291
|
+
export function patchedPaths(patch: string): string[] {
|
|
292
|
+
const paths: string[] = [];
|
|
293
|
+
for (const match of patch.matchAll(/^diff --git a\/(.+?) b\/(.+)$/gmu)) {
|
|
294
|
+
for (const path of [match[1]!, match[2]!]) if (!paths.includes(path)) paths.push(path);
|
|
295
|
+
}
|
|
296
|
+
return paths;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* `git apply --check` then `git apply`; a conflict leaves the tree untouched.
|
|
301
|
+
* The patch lands in the working tree uncommitted, so what the run changed is
|
|
302
|
+
* reviewed with `git diff` before anything is kept — the same contract as v1.
|
|
303
|
+
*/
|
|
304
|
+
export function applyCloudPatch(root: string, patch: string): void {
|
|
305
|
+
const args = ['-C', resolve(root), 'apply', '--whitespace=nowarn'];
|
|
306
|
+
const check = spawnSync('git', [...args, '--check'], { input: patch, encoding: 'utf8' });
|
|
307
|
+
if (check.status !== 0) {
|
|
308
|
+
throw new CloudFlowError('patch_conflict',
|
|
309
|
+
`The patch does not apply cleanly to ${resolve(root)}:\n${check.stderr.trim()}`);
|
|
310
|
+
}
|
|
311
|
+
const apply = spawnSync('git', args, { input: patch, encoding: 'utf8' });
|
|
312
|
+
if (apply.status !== 0) {
|
|
313
|
+
throw new CloudFlowError('patch_conflict', `git apply failed:\n${apply.stderr.trim()}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
package/src/failure-kinds.ts
CHANGED
|
@@ -85,6 +85,13 @@ export const PREFLIGHT_WARNING_KINDS = [
|
|
|
85
85
|
* serving and this invocation was told not to start one" (`--no-spawn`), while
|
|
86
86
|
* a spawn that was attempted and did not produce a serving daemon names which
|
|
87
87
|
* step failed. All of them are still exit 2 — refused before a journal write.
|
|
88
|
+
*
|
|
89
|
+
* Despite the name, this list is the run-outcome diagnostic vocabulary rather
|
|
90
|
+
* than failures alone. `run_parked` has always sat here and exits 3 under
|
|
91
|
+
* severity `parked`; `run_declined` joins it, exiting 0 under severity
|
|
92
|
+
* `declined`. The mismatch between the constant's name and its non-failure
|
|
93
|
+
* members is pre-existing — renaming it is a separate change, and neither this
|
|
94
|
+
* list nor `RunDiagnostic` is exported from the package index.
|
|
88
95
|
*/
|
|
89
96
|
export const RUN_FAILURE_KINDS = [
|
|
90
97
|
'bucket_unconfigured',
|
|
@@ -102,7 +109,10 @@ export const RUN_FAILURE_KINDS = [
|
|
|
102
109
|
'relayflowd_not_found',
|
|
103
110
|
'protocol_error',
|
|
104
111
|
'run_parked',
|
|
112
|
+
'run_declined',
|
|
105
113
|
'run_unavailable',
|
|
114
|
+
/** A predicate `.gate(fn)` judged false; the verdict is journaled as `<step>.gate`. */
|
|
115
|
+
'gate_failed',
|
|
106
116
|
] as const;
|
|
107
117
|
|
|
108
118
|
/**
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,19 @@ export {
|
|
|
63
63
|
runInCloud, getCloudFlowRun, waitForCloudFlowRun,
|
|
64
64
|
type CloudFlowSource, type RunInCloudOptions, type CloudRunReceipt, type CloudRunState,
|
|
65
65
|
} from './cloud-run.js';
|
|
66
|
+
export {
|
|
67
|
+
downloadCloudPatch, applyCloudPatch, packWorkingTree, patchedPaths, MAX_SYNC_BYTES,
|
|
68
|
+
type CloudPatch, type PackedTree,
|
|
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';
|
|
75
|
+
export {
|
|
76
|
+
deployToCloud, listCloudDeployments, undeployFromCloud, parseRepository, parseTriggerSource, FLOW_TRIGGER_PROVIDERS,
|
|
77
|
+
type DeployToCloudInput, type CloudDeployment, type CloudDeploymentSummary, type FlowTriggerSource, type FlowTriggerProvider,
|
|
78
|
+
} from './cloud-deploy.js';
|
|
66
79
|
|
|
67
80
|
export { canonicalize, specHash } from './canonical.js';
|
|
68
81
|
export {
|
|
@@ -225,3 +238,4 @@ export { createFlow, type CreateFlowOptions, type CreatedFlow } from './create-f
|
|
|
225
238
|
|
|
226
239
|
export { renderProgress, type ProgressEvent } from './progress.js';
|
|
227
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;
|
package/src/named-gates.ts
CHANGED
|
@@ -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))
|
package/src/preflight.ts
CHANGED
|
@@ -133,7 +133,9 @@ export function preflightWebhookTriggers(
|
|
|
133
133
|
triggers: readonly TriggerSource[],
|
|
134
134
|
executors: readonly string[],
|
|
135
135
|
): PreflightRefusal[] {
|
|
136
|
-
|
|
136
|
+
// Schedule sources are driven by the CLI's own tick runner, not an inbox
|
|
137
|
+
// executor, so they need no flows.json registration.
|
|
138
|
+
return [...new Set(triggers.filter(trigger => trigger.kind === 'webhook').map(trigger => trigger.name))]
|
|
137
139
|
.filter(name => !executors.includes(name))
|
|
138
140
|
.map(name => ({
|
|
139
141
|
severity: 'refusal',
|
|
@@ -584,8 +586,8 @@ function warnOnUnprovableEffects(
|
|
|
584
586
|
diagnostics: PreflightDiagnostic[],
|
|
585
587
|
): void {
|
|
586
588
|
if (step.type !== 'deterministic') return;
|
|
587
|
-
const
|
|
588
|
-
if (
|
|
589
|
+
const first = firstCommandWordDetailed(step.command);
|
|
590
|
+
if (first === undefined) {
|
|
589
591
|
diagnostics.push({
|
|
590
592
|
severity: 'warning',
|
|
591
593
|
kind: 'command_unprovable',
|
|
@@ -594,6 +596,19 @@ function warnOnUnprovableEffects(
|
|
|
594
596
|
});
|
|
595
597
|
return;
|
|
596
598
|
}
|
|
599
|
+
if (first.shell) {
|
|
600
|
+
// `set -e`, `if …`, `cd …`: the shell supplies these, so there is nothing
|
|
601
|
+
// to resolve on PATH — and nothing to refuse. What they go on to run is
|
|
602
|
+
// the rest of the script, whose effects preflight never claimed to prove.
|
|
603
|
+
diagnostics.push({
|
|
604
|
+
severity: 'warning',
|
|
605
|
+
kind: 'unprovable_effects',
|
|
606
|
+
stepId: step.id,
|
|
607
|
+
message: `Step "${step.id}" starts with the shell ${first.kind} "${first.word}", whose effects cannot be proven before execution.`,
|
|
608
|
+
});
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const binary = first.word;
|
|
597
612
|
let exists: boolean;
|
|
598
613
|
try {
|
|
599
614
|
exists = probes.command(binary);
|
|
@@ -628,7 +643,115 @@ function warnOnUnprovableEffects(
|
|
|
628
643
|
});
|
|
629
644
|
}
|
|
630
645
|
|
|
646
|
+
/** POSIX special builtins and reserved words: the shell supplies them, PATH never does. */
|
|
647
|
+
const SHELL_SPECIAL_BUILTINS = new Set([
|
|
648
|
+
'.', ':', 'break', 'continue', 'eval', 'exec', 'exit', 'export', 'readonly', 'return', 'set',
|
|
649
|
+
'shift', 'times', 'trap', 'unset',
|
|
650
|
+
// Regular builtins that no sane flow ships as an executable.
|
|
651
|
+
'cd', 'alias', 'unalias', 'local', 'source', 'wait', 'umask', 'ulimit', 'read', 'command', 'type',
|
|
652
|
+
]);
|
|
653
|
+
const SHELL_RESERVED_WORDS = new Set([
|
|
654
|
+
'if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'in',
|
|
655
|
+
'function', 'select', 'time', '{', '}', '(', ')', '!', '[[', ']]',
|
|
656
|
+
]);
|
|
657
|
+
|
|
658
|
+
interface FirstCommandWord {
|
|
659
|
+
word: string;
|
|
660
|
+
/** True when the shell itself provides the word, so there is nothing to look up. */
|
|
661
|
+
shell: boolean;
|
|
662
|
+
kind: 'command' | 'builtin' | 'reserved word';
|
|
663
|
+
}
|
|
664
|
+
|
|
631
665
|
function firstCommandWord(command: string): string | undefined {
|
|
666
|
+
return firstCommandWordDetailed(command)?.word;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function firstCommandWordDetailed(command: string): FirstCommandWord | undefined {
|
|
670
|
+
// A lightweight lexical pass — not a shell parser. It walks the script once,
|
|
671
|
+
// quote-aware: comments outside quotes are dropped, quoted text is opaque
|
|
672
|
+
// (an apostrophe in `# don't` or a `<<EOF` inside `VALUE='<<EOF'` is data),
|
|
673
|
+
// and the script is split into simple-command segments on unquoted `;`,
|
|
674
|
+
// `&&`, `||`, `|` and newlines. The first segment that is not only
|
|
675
|
+
// assignments and redirections holds the command word (cloud#3777).
|
|
676
|
+
//
|
|
677
|
+
// A heredoc opener or a quote left open at the end of the script means the
|
|
678
|
+
// following text is data the shell never executes; if no command word was
|
|
679
|
+
// found before it, the answer is "cannot be proven", not "missing".
|
|
680
|
+
const segments = lexSimpleCommands(command);
|
|
681
|
+
if (segments === undefined) return undefined;
|
|
682
|
+
for (const segment of segments) {
|
|
683
|
+
const remainder = stripShellPrefixes(segment);
|
|
684
|
+
if (remainder === '') continue;
|
|
685
|
+
const match = remainder.match(/^(?:"([^"]*)"|'([^']*)'|([^\s]+))/);
|
|
686
|
+
const word = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
687
|
+
if (word === undefined || word === '') continue;
|
|
688
|
+
if (SHELL_SPECIAL_BUILTINS.has(word)) return { word, shell: true, kind: 'builtin' };
|
|
689
|
+
if (SHELL_RESERVED_WORDS.has(word)) return { word, shell: true, kind: 'reserved word' };
|
|
690
|
+
return { word, shell: false, kind: 'command' };
|
|
691
|
+
}
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Split a script into simple-command segments, comments removed, quotes kept.
|
|
697
|
+
* Returns undefined when a heredoc or an unclosed quote begins before any
|
|
698
|
+
* segment could be completed past it — the rest is data.
|
|
699
|
+
*/
|
|
700
|
+
function lexSimpleCommands(script: string): string[] | undefined {
|
|
701
|
+
const segments: string[] = [];
|
|
702
|
+
let current = '';
|
|
703
|
+
let quote: string | undefined;
|
|
704
|
+
let heredoc = false;
|
|
705
|
+
const flush = (): void => {
|
|
706
|
+
if (current.trim() !== '') segments.push(current.trim());
|
|
707
|
+
current = '';
|
|
708
|
+
};
|
|
709
|
+
for (let i = 0; i < script.length; i += 1) {
|
|
710
|
+
const char = script[i]!;
|
|
711
|
+
if (quote !== undefined) {
|
|
712
|
+
current += char;
|
|
713
|
+
if (char === '\\' && quote === '"' && i + 1 < script.length) { current += script[++i]; continue; }
|
|
714
|
+
if (char === quote) quote = undefined;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (char === '\\' && i + 1 < script.length) { current += char + script[++i]; continue; }
|
|
718
|
+
if (char === '"' || char === "'") { quote = char; current += char; continue; }
|
|
719
|
+
if (char === '#' && (current === '' || /\s$/.test(current))) {
|
|
720
|
+
// Comment to end of line, outside quotes only.
|
|
721
|
+
while (i + 1 < script.length && script[i + 1] !== '\n') i += 1;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (char === '<' && script[i + 1] === '<') {
|
|
725
|
+
// A heredoc: everything after this line is its body, so the current
|
|
726
|
+
// segment is the last one the shell reads as a command.
|
|
727
|
+
heredoc = true;
|
|
728
|
+
while (i + 1 < script.length && script[i + 1] !== '\n') i += 1;
|
|
729
|
+
flush();
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
if (char === '\n' || char === ';') { flush(); continue; }
|
|
733
|
+
if ((char === '&' && script[i + 1] === '&') || (char === '|' && script[i + 1] === '|')) { flush(); i += 1; continue; }
|
|
734
|
+
if (char === '|') { flush(); continue; }
|
|
735
|
+
if (char === '&') {
|
|
736
|
+
// `&` is a boundary only as a background operator. In `2>&1`, `>&2`,
|
|
737
|
+
// `<&0` and `&>file` it is part of a redirection: the `&` after a `>`
|
|
738
|
+
// or `<` (with an optional fd number before that), or the `&` that
|
|
739
|
+
// starts `&>`.
|
|
740
|
+
const afterRedirect = /[<>]\s*$/.test(current);
|
|
741
|
+
if (afterRedirect || script[i + 1] === '>') { current += char; continue; }
|
|
742
|
+
flush();
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
current += char;
|
|
746
|
+
}
|
|
747
|
+
if (!heredoc && quote === undefined) flush();
|
|
748
|
+
// Whatever was accumulated when a heredoc or an open quote cut the scan is
|
|
749
|
+
// not a complete command; only the segments closed before it count.
|
|
750
|
+
if (segments.length === 0 && (heredoc || quote !== undefined)) return undefined;
|
|
751
|
+
return segments;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function stripShellPrefixes(segment: string): string {
|
|
632
755
|
// Skip the shell prefixes that can legally precede the command word.
|
|
633
756
|
//
|
|
634
757
|
// Review caught this on PR #47: the new path-like refusal keys on the first
|
|
@@ -638,16 +761,16 @@ function firstCommandWord(command: string): string | undefined {
|
|
|
638
761
|
// which is exactly the "refusing would reject valid flows" failure the warn
|
|
639
762
|
// behaviour exists to avoid.
|
|
640
763
|
//
|
|
641
|
-
// An assignment is NAME=value with a shell-legal name
|
|
642
|
-
// with < or > (optionally with a leading fd
|
|
643
|
-
|
|
764
|
+
// An assignment is NAME=value with a shell-legal name and an opaque quoted
|
|
765
|
+
// value; a redirection starts with < or > (optionally with a leading fd
|
|
766
|
+
// number). Neither is the command. Segments never contain `;`/newlines.
|
|
767
|
+
let rest = segment;
|
|
644
768
|
for (;;) {
|
|
645
|
-
const prefix = rest.match(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)|[0-9]*[<>]{1,2}\s*[^\s]+)
|
|
769
|
+
const prefix = rest.match(/^(?:[A-Za-z_][A-Za-z0-9_]*=(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]*)|(?:[0-9]*[<>]{1,2}&?|&>>?)\s*[^\s]+)(?:\s+|$)/);
|
|
646
770
|
if (prefix === null) break;
|
|
647
771
|
rest = rest.slice(prefix[0].length);
|
|
648
772
|
}
|
|
649
|
-
|
|
650
|
-
return match?.[1] ?? match?.[2] ?? match?.[3];
|
|
773
|
+
return rest.trim();
|
|
651
774
|
}
|
|
652
775
|
|
|
653
776
|
function probeNamedGate(step: StepSpec, probes: PreflightProbes, diagnostics: PreflightDiagnostic[]): void {
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TICK_EVENT_TYPE, TICK_DEDUPE_KEY_TEMPLATE,
|
|
3
|
+
} from './tick-source.js';
|
|
4
|
+
import { cronMaxGapMs, parseCron, scheduleIdFor, type ScheduleTriggerSource } from '@relayflows/surface';
|
|
5
|
+
import type { TriggerSpec } from './spec.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The executor name a schedule subscription is registered under. It is the
|
|
9
|
+
* name `flows tick start` submits through, and `testdata/tick-heartbeat.flow.yaml`
|
|
10
|
+
* binds by hand; unlike a provider inbox it needs no `flows.json` entry, because
|
|
11
|
+
* the tick source ships with the CLI itself.
|
|
12
|
+
*/
|
|
13
|
+
export const SCHEDULE_EXECUTOR = 'flows.tick';
|
|
14
|
+
|
|
15
|
+
/** Three missed slots before the kernel's liveness sweep journals `subscription.stale`. */
|
|
16
|
+
export const SCHEDULE_STALE_SLOTS = 3;
|
|
17
|
+
|
|
18
|
+
export interface ScheduleLowering {
|
|
19
|
+
readonly scheduleId: string;
|
|
20
|
+
/** The `flows tick start --interval-ms` value, when the schedule is exactly a tick grid. */
|
|
21
|
+
readonly intervalMs?: number;
|
|
22
|
+
/** The `flows tick start --epoch-ms` value: the grid's phase past the Unix epoch. Present with `intervalMs`. */
|
|
23
|
+
readonly epochMs?: number;
|
|
24
|
+
readonly cron?: string;
|
|
25
|
+
readonly tz?: string;
|
|
26
|
+
/** The silence budget the subscription declares, in milliseconds. */
|
|
27
|
+
readonly staleAfterMs?: number;
|
|
28
|
+
/** Why the local tick runner cannot drive this schedule, when it cannot. */
|
|
29
|
+
readonly localUnsupported?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function scheduleLowering(flowName: string, source: ScheduleTriggerSource): ScheduleLowering {
|
|
33
|
+
const scheduleId = scheduleIdFor(flowName, source);
|
|
34
|
+
const base = {
|
|
35
|
+
scheduleId,
|
|
36
|
+
...(source.cron === undefined ? {} : { cron: source.cron }),
|
|
37
|
+
...(source.tz === undefined ? {} : { tz: source.tz }),
|
|
38
|
+
};
|
|
39
|
+
if (source.intervalMs === undefined || source.epochMs === undefined) {
|
|
40
|
+
// Not a grid. The kernel's default silence budget is five minutes, which
|
|
41
|
+
// a daily cron would trip every day at 09:05 — so the budget is declared
|
|
42
|
+
// from the cron's own longest quiet period instead of left to default.
|
|
43
|
+
const gap = source.cron === undefined ? undefined : cronMaxGapMs(parseCron(source.cron));
|
|
44
|
+
return {
|
|
45
|
+
...base,
|
|
46
|
+
...(gap === undefined ? {} : { staleAfterMs: gap * SCHEDULE_STALE_SLOTS }),
|
|
47
|
+
localUnsupported: `cron "${source.cron}"${source.tz === undefined ? '' : ` in ${source.tz}`} is not a UTC tick grid `
|
|
48
|
+
+ '(a fixed period dividing the hour or day, one fixed phase); the local tick runner cannot reproduce it. '
|
|
49
|
+
+ 'Run it on Cloud with `flows schedule`, or declare schedule.every(...) for local runs.',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { ...base, intervalMs: source.intervalMs, epochMs: source.epochMs, staleAfterMs: source.intervalMs * SCHEDULE_STALE_SLOTS };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Lower a `schedule.*` declaration to the `flows.tick` subscription the tick
|
|
57
|
+
* source drives: one event type, a pattern pinned to this flow's schedule id,
|
|
58
|
+
* the shared dedupe key so a re-delivered slot spawns one run, and a silence
|
|
59
|
+
* budget of three slots — three intervals for a grid, three times the cron's
|
|
60
|
+
* longest quiet period otherwise — so a dead source is journaled rather than
|
|
61
|
+
* silent, and a weekly cron is not declared stale on Monday at 09:05.
|
|
62
|
+
*/
|
|
63
|
+
export function scheduleTriggerSpec(id: string, flowName: string, source: ScheduleTriggerSource): TriggerSpec {
|
|
64
|
+
const lowering = scheduleLowering(flowName, source);
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
executor: SCHEDULE_EXECUTOR,
|
|
68
|
+
eventType: TICK_EVENT_TYPE,
|
|
69
|
+
pattern: { schedule_id: lowering.scheduleId },
|
|
70
|
+
dedupeKeyTemplate: TICK_DEDUPE_KEY_TEMPLATE,
|
|
71
|
+
...(lowering.staleAfterMs === undefined ? {} : { staleAfterMs: lowering.staleAfterMs }),
|
|
72
|
+
};
|
|
73
|
+
}
|