@relayflows/sdk 2.0.14 → 2.0.16

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 (98) hide show
  1. package/dist/agent-artifacts.d.ts +20 -0
  2. package/dist/agent-artifacts.d.ts.map +1 -0
  3. package/dist/agent-artifacts.js +81 -0
  4. package/dist/agent-artifacts.js.map +1 -0
  5. package/dist/authored-flow-executor.d.ts +33 -3
  6. package/dist/authored-flow-executor.d.ts.map +1 -1
  7. package/dist/authored-flow-executor.js +56 -10
  8. package/dist/authored-flow-executor.js.map +1 -1
  9. package/dist/authored-node-runner.d.ts.map +1 -1
  10. package/dist/authored-node-runner.js +11 -4
  11. package/dist/authored-node-runner.js.map +1 -1
  12. package/dist/authored-root.d.ts.map +1 -1
  13. package/dist/authored-root.js +2 -2
  14. package/dist/authored-root.js.map +1 -1
  15. package/dist/authored-step-output.d.ts +2 -0
  16. package/dist/authored-step-output.d.ts.map +1 -1
  17. package/dist/authored-step-output.js +5 -1
  18. package/dist/authored-step-output.js.map +1 -1
  19. package/dist/authored-worker-step.d.ts.map +1 -1
  20. package/dist/authored-worker-step.js +19 -1
  21. package/dist/authored-worker-step.js.map +1 -1
  22. package/dist/cli/check-triggers.d.ts.map +1 -1
  23. package/dist/cli/check-triggers.js +14 -3
  24. package/dist/cli/check-triggers.js.map +1 -1
  25. package/dist/cli/check.js +6 -1
  26. package/dist/cli/check.js.map +1 -1
  27. package/dist/cli/cloud-deploy.d.ts +30 -0
  28. package/dist/cli/cloud-deploy.d.ts.map +1 -0
  29. package/dist/cli/cloud-deploy.js +162 -0
  30. package/dist/cli/cloud-deploy.js.map +1 -0
  31. package/dist/cli/cloud-run.d.ts +3 -1
  32. package/dist/cli/cloud-run.d.ts.map +1 -1
  33. package/dist/cli/cloud-run.js +37 -5
  34. package/dist/cli/cloud-run.js.map +1 -1
  35. package/dist/cli/cloud-sync.d.ts +13 -0
  36. package/dist/cli/cloud-sync.d.ts.map +1 -0
  37. package/dist/cli/cloud-sync.js +39 -0
  38. package/dist/cli/cloud-sync.js.map +1 -0
  39. package/dist/cli/direct-run.d.ts.map +1 -1
  40. package/dist/cli/direct-run.js +2 -26
  41. package/dist/cli/direct-run.js.map +1 -1
  42. package/dist/cli/run.d.ts +20 -1
  43. package/dist/cli/run.d.ts.map +1 -1
  44. package/dist/cli/run.js +77 -15
  45. package/dist/cli/run.js.map +1 -1
  46. package/dist/cli.d.ts.map +1 -1
  47. package/dist/cli.js +82 -12
  48. package/dist/cli.js.map +1 -1
  49. package/dist/cloud-deploy.d.ts +70 -0
  50. package/dist/cloud-deploy.d.ts.map +1 -0
  51. package/dist/cloud-deploy.js +181 -0
  52. package/dist/cloud-deploy.js.map +1 -0
  53. package/dist/cloud-http.d.ts +37 -2
  54. package/dist/cloud-http.d.ts.map +1 -1
  55. package/dist/cloud-http.js +98 -8
  56. package/dist/cloud-http.js.map +1 -1
  57. package/dist/cloud-run.d.ts +23 -0
  58. package/dist/cloud-run.d.ts.map +1 -1
  59. package/dist/cloud-run.js +30 -1
  60. package/dist/cloud-run.js.map +1 -1
  61. package/dist/cloud-sync.d.ts +66 -0
  62. package/dist/cloud-sync.d.ts.map +1 -0
  63. package/dist/cloud-sync.js +287 -0
  64. package/dist/cloud-sync.js.map +1 -0
  65. package/dist/failure-kinds.d.ts +8 -1
  66. package/dist/failure-kinds.d.ts.map +1 -1
  67. package/dist/failure-kinds.js +8 -0
  68. package/dist/failure-kinds.js.map +1 -1
  69. package/dist/index.d.ts +2 -0
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +2 -0
  72. package/dist/index.js.map +1 -1
  73. package/dist/provider-trigger-contract.d.ts +27 -0
  74. package/dist/provider-trigger-contract.d.ts.map +1 -0
  75. package/dist/provider-trigger-contract.js +0 -0
  76. package/dist/provider-trigger-contract.js.map +1 -0
  77. package/package.json +2 -2
  78. package/src/agent-artifacts.ts +76 -0
  79. package/src/authored-flow-executor.ts +77 -15
  80. package/src/authored-node-runner.ts +11 -4
  81. package/src/authored-root.ts +2 -2
  82. package/src/authored-step-output.ts +6 -1
  83. package/src/authored-worker-step.ts +19 -1
  84. package/src/cli/check-triggers.ts +14 -5
  85. package/src/cli/check.ts +6 -1
  86. package/src/cli/cloud-deploy.ts +157 -0
  87. package/src/cli/cloud-run.ts +37 -6
  88. package/src/cli/cloud-sync.ts +37 -0
  89. package/src/cli/direct-run.ts +2 -25
  90. package/src/cli/run.ts +86 -16
  91. package/src/cli.ts +75 -12
  92. package/src/cloud-deploy.ts +236 -0
  93. package/src/cloud-http.ts +129 -9
  94. package/src/cloud-run.ts +47 -1
  95. package/src/cloud-sync.ts +315 -0
  96. package/src/failure-kinds.ts +8 -0
  97. package/src/index.ts +8 -0
  98. package/src/provider-trigger-contract.ts +0 -0
@@ -0,0 +1,236 @@
1
+ import { randomBytes, createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { loadAuthoredFlow } from './authored-flow-loader.js';
4
+ import {
5
+ CloudFlowError, cloudFetch, cloudRequest, isCloudRecord, type CloudConnectionOptions,
6
+ } from './cloud-http.js';
7
+
8
+ /**
9
+ * Hosted listener deployment: the CLI form of the agentrelay.com onboarding's
10
+ * deploy wizard. `POST /api/v1/flows/deploy` stores one self-contained
11
+ * authored source and creates a proactive listener whose watch rules match
12
+ * the chosen ticket sources on the workspace's relayfile projections. There
13
+ * is no webhook to register: the GitHub App installation (or Slack/Linear/
14
+ * Jira/Shortcut connection) is the ingress, and each matching ticket launches
15
+ * a run of the stored source with `{ approver, issue, event }` as its input,
16
+ * inside a fresh branch of the deployment's repository.
17
+ */
18
+
19
+ export const FLOW_TRIGGER_PROVIDERS = ['github', 'linear', 'jira', 'shortcut', 'slack'] as const;
20
+ export type FlowTriggerProvider = (typeof FLOW_TRIGGER_PROVIDERS)[number];
21
+
22
+ /** Settings Cloud's launcher prefilter reads per provider (`flow-trigger-sources.ts`). */
23
+ const PROVIDER_SETTINGS: Record<FlowTriggerProvider, readonly string[]> = {
24
+ github: ['repository', 'labels', 'contains'],
25
+ slack: ['channel', 'contains'],
26
+ linear: ['team', 'contains'],
27
+ jira: ['project', 'contains'],
28
+ shortcut: ['workspace', 'contains'],
29
+ };
30
+ const MAX_SOURCE_BYTES = 256_000;
31
+ const MAX_SETTING_LENGTH = 500;
32
+ const REPO_OWNER = /^[A-Za-z0-9-]{1,39}$/u;
33
+ const REPO_NAME = /^[A-Za-z0-9_.-]{1,100}$/u;
34
+
35
+ export interface FlowTriggerSource {
36
+ provider: FlowTriggerProvider;
37
+ settings: Record<string, string>;
38
+ }
39
+
40
+ export interface DeployToCloudInput {
41
+ path: string;
42
+ repository: { owner: string; name: string };
43
+ sources: FlowTriggerSource[];
44
+ /** The `f.human` approver handle every launched run receives as `input.approver`. */
45
+ approver: string;
46
+ /** Defaults to the flow's declared name. */
47
+ name?: string;
48
+ /** Coding-agent harnesses the flow uses; Cloud checks their credentials are connected. Default `["claude"]`. */
49
+ agents?: FlowAgentHarness[];
50
+ /** Save without activating: no connection checks, no listener until activated. */
51
+ draft?: boolean;
52
+ }
53
+
54
+ export const FLOW_AGENT_HARNESSES = ['claude', 'codex'] as const;
55
+ export type FlowAgentHarness = (typeof FLOW_AGENT_HARNESSES)[number];
56
+
57
+ export function parseAgentHarnesses(value: string): FlowAgentHarness[] {
58
+ const agents = value.split(',').map(a => a.trim()).filter(Boolean);
59
+ if (agents.length === 0 || agents.length > 2 || new Set(agents).size !== agents.length
60
+ || !agents.every(a => (FLOW_AGENT_HARNESSES as readonly string[]).includes(a))) {
61
+ throw new CloudFlowError('invalid_input', `--agents takes one or two of ${FLOW_AGENT_HARNESSES.join(', ')}, got "${value}".`);
62
+ }
63
+ return agents as FlowAgentHarness[];
64
+ }
65
+
66
+ export interface CloudDeployment {
67
+ agentId: string;
68
+ name: string;
69
+ status: string;
70
+ repository: { owner: string; name: string };
71
+ sources: FlowTriggerSource[];
72
+ sourceSha256: string;
73
+ }
74
+
75
+ export function parseRepository(value: string): { owner: string; name: string } {
76
+ const [owner, name, extra] = value.replace(/^https?:\/\/github\.com\//iu, '').replace(/\.git$/iu, '').split('/');
77
+ if (!owner || !name || extra !== undefined || !REPO_OWNER.test(owner) || !REPO_NAME.test(name)) {
78
+ throw new CloudFlowError('invalid_input', `Expected --repo <owner>/<name>, got "${value}".`);
79
+ }
80
+ return { owner, name };
81
+ }
82
+
83
+ /** `github`, `github:labels=agent,contains=urgent`, `slack:channel=#eng`. */
84
+ export function parseTriggerSource(value: string): FlowTriggerSource {
85
+ const colon = value.indexOf(':');
86
+ const provider = (colon === -1 ? value : value.slice(0, colon)).trim();
87
+ if (!(FLOW_TRIGGER_PROVIDERS as readonly string[]).includes(provider)) {
88
+ throw new CloudFlowError('invalid_input',
89
+ `Unknown trigger provider "${provider}"; expected one of ${FLOW_TRIGGER_PROVIDERS.join(', ')}.`);
90
+ }
91
+ const allowed = PROVIDER_SETTINGS[provider as FlowTriggerProvider];
92
+ const settings: Record<string, string> = {};
93
+ if (colon !== -1) {
94
+ for (const pair of value.slice(colon + 1).split(',')) {
95
+ const eq = pair.indexOf('=');
96
+ const key = (eq === -1 ? pair : pair.slice(0, eq)).trim();
97
+ const setting = eq === -1 ? '' : pair.slice(eq + 1).trim();
98
+ if (!allowed.includes(key)) {
99
+ throw new CloudFlowError('invalid_input',
100
+ `"${key}" is not a ${provider} trigger setting; ${provider} accepts ${allowed.join(', ')}.`);
101
+ }
102
+ if (!setting || setting.length > MAX_SETTING_LENGTH || key in settings) {
103
+ throw new CloudFlowError('invalid_input', `Trigger setting "${key}" must be given once with a non-empty value.`);
104
+ }
105
+ settings[key] = setting;
106
+ }
107
+ }
108
+ return { provider: provider as FlowTriggerProvider, settings };
109
+ }
110
+
111
+ export async function deployToCloud(
112
+ input: DeployToCloudInput, options: CloudConnectionOptions = {},
113
+ ): Promise<CloudDeployment> {
114
+ if (!/\.flow\.ts$/iu.test(input.path)) {
115
+ throw new CloudFlowError('unsupported_source', 'flows deploy takes one authored .flow.ts source.');
116
+ }
117
+ let bytes: Buffer;
118
+ try {
119
+ bytes = await readFile(input.path);
120
+ } catch (error) {
121
+ throw new CloudFlowError('invalid_input',
122
+ `Cannot read ${input.path}: ${(error as NodeJS.ErrnoException).code ?? (error as Error).message}.`);
123
+ }
124
+ const source = bytes.toString('utf8');
125
+ if (!bytes.length || Buffer.from(source, 'utf8').compare(bytes) !== 0) {
126
+ throw new CloudFlowError('invalid_input', 'Authored source must be nonempty, lossless UTF-8.');
127
+ }
128
+ if (bytes.length > MAX_SOURCE_BYTES) {
129
+ throw new CloudFlowError('invalid_input', `Authored source exceeds Cloud's ${MAX_SOURCE_BYTES}-byte deploy limit.`);
130
+ }
131
+ let loaded: Awaited<ReturnType<typeof loadAuthoredFlow>>;
132
+ let definition: ReturnType<typeof loaded.getDefinition>;
133
+ try {
134
+ loaded = await loadAuthoredFlow(input.path);
135
+ definition = loaded.getDefinition(loaded.handle);
136
+ } catch (error) {
137
+ if (error instanceof CloudFlowError) throw error;
138
+ // A source that does not load is an authoring problem, refused before HTTP.
139
+ throw new CloudFlowError('unsupported_source',
140
+ `${input.path} is not a loadable authored flow: ${error instanceof Error ? error.message : String(error)}`);
141
+ }
142
+ if (loaded.graph.length !== 1) {
143
+ throw new CloudFlowError('unsupported_source',
144
+ 'Cloud deploys one self-contained .flow.ts source without use dependencies.');
145
+ }
146
+ if (input.sources.length === 0 || input.sources.length > 10) {
147
+ throw new CloudFlowError('invalid_input', 'Give between one and ten --on trigger sources.');
148
+ }
149
+ if (new Set(input.sources.map(s => s.provider)).size !== input.sources.length) {
150
+ throw new CloudFlowError('invalid_input', 'Each trigger provider can be given once.');
151
+ }
152
+ const approver = input.approver.trim();
153
+ if (!approver) throw new CloudFlowError('invalid_input', '--approver must name who approves f.human questions.');
154
+ const name = (input.name ?? definition.name).trim();
155
+ if (!name || name.length > 100) throw new CloudFlowError('invalid_input', 'Deployment name must be 1-100 characters.');
156
+ // A GitHub source scoped to nothing would wake on every repository the
157
+ // installation covers; default it to the deployment's own repository.
158
+ const sources = input.sources.map(s => s.provider === 'github' && s.settings['repository'] === undefined
159
+ ? { ...s, settings: { ...s.settings, repository: `${input.repository.owner}/${input.repository.name}` } }
160
+ : s);
161
+ options.signal?.throwIfAborted();
162
+
163
+ const whoami = await cloudRequest('/api/v1/auth/whoami', options);
164
+ const workspace = isCloudRecord(whoami) && isCloudRecord(whoami.currentWorkspace) ? whoami.currentWorkspace : undefined;
165
+ if (workspace === undefined || typeof workspace.id !== 'string' || !workspace.id) {
166
+ throw new CloudFlowError('invalid_response', 'Cloud did not report a current workspace for this credential.');
167
+ }
168
+ const agents = input.agents ?? ['claude'];
169
+ const result = await cloudFetch('/api/v1/flows/deploy', options, { method: 'POST', detail: true, body: JSON.stringify({
170
+ workspaceId: workspace.id,
171
+ mode: input.draft ? 'draft' : 'activate',
172
+ name,
173
+ // The onboarding's workflow-shape label; the CLI deploys authored source as-is.
174
+ workflow: 'flows-cli',
175
+ source,
176
+ handoffId: `flows-cli-${randomBytes(8).toString('hex')}`,
177
+ inputs: { approver, agents },
178
+ repository: input.repository,
179
+ sources,
180
+ }) });
181
+ if (!isCloudRecord(result) || typeof result.agentId !== 'string' || typeof result.status !== 'string') {
182
+ throw new CloudFlowError('invalid_response', 'Cloud did not return a deployment.');
183
+ }
184
+ return {
185
+ agentId: result.agentId, name, status: result.status,
186
+ repository: input.repository, sources,
187
+ sourceSha256: createHash('sha256').update(bytes).digest('hex'),
188
+ };
189
+ }
190
+
191
+ export interface CloudDeploymentSummary {
192
+ agentId: string;
193
+ name: string;
194
+ status: string;
195
+ repository?: { owner: string; name: string };
196
+ sources: FlowTriggerSource[];
197
+ createdAt?: string;
198
+ updatedAt?: string;
199
+ }
200
+
201
+ export async function listCloudDeployments(options: CloudConnectionOptions = {}): Promise<CloudDeploymentSummary[]> {
202
+ const payload = await cloudRequest('/api/v1/agents/flow-deployments', options);
203
+ if (!isCloudRecord(payload) || !Array.isArray(payload.deployments)) {
204
+ throw new CloudFlowError('invalid_response', 'Cloud did not return a deployments list.');
205
+ }
206
+ return payload.deployments.map((row): CloudDeploymentSummary => {
207
+ if (!isCloudRecord(row) || typeof row.agentId !== 'string' || typeof row.name !== 'string') {
208
+ throw new CloudFlowError('invalid_response', 'Cloud returned a malformed deployment row.');
209
+ }
210
+ const repository = isCloudRecord(row.repository) && typeof row.repository.owner === 'string'
211
+ && typeof row.repository.name === 'string' ? { owner: row.repository.owner, name: row.repository.name } : undefined;
212
+ const sources = Array.isArray(row.sources) ? row.sources.flatMap((s): FlowTriggerSource[] =>
213
+ isCloudRecord(s) && typeof s.provider === 'string' && (FLOW_TRIGGER_PROVIDERS as readonly string[]).includes(s.provider)
214
+ ? [{ provider: s.provider as FlowTriggerProvider,
215
+ settings: Object.fromEntries(Object.entries(isCloudRecord(s.settings) ? s.settings : {})
216
+ .flatMap(([k, v]) => typeof v === 'string' ? [[k, v]] : typeof v === 'boolean' ? [[k, String(v)]] : [])) }]
217
+ : []) : [];
218
+ return {
219
+ agentId: row.agentId, name: row.name, status: typeof row.status === 'string' ? row.status : 'unknown',
220
+ ...(repository === undefined ? {} : { repository }), sources,
221
+ ...(typeof row.createdAt === 'string' ? { createdAt: row.createdAt } : {}),
222
+ ...(typeof row.updatedAt === 'string' ? { updatedAt: row.updatedAt } : {}),
223
+ };
224
+ });
225
+ }
226
+
227
+ const LISTENER_ID = /^[A-Za-z0-9_-]{1,128}$/u;
228
+
229
+ /** `DELETE /api/v1/flows/listeners/<id>`: the listener stops; past runs and their journals stay. */
230
+ export async function undeployFromCloud(agentId: string, options: CloudConnectionOptions = {}): Promise<void> {
231
+ if (!LISTENER_ID.test(agentId)) throw new CloudFlowError('invalid_input', `"${agentId}" is not a deployment id.`);
232
+ const result = await cloudFetch(`/api/v1/flows/listeners/${encodeURIComponent(agentId)}`, options, { method: 'DELETE', detail: true });
233
+ if (!isCloudRecord(result) || result.status !== 'deleted') {
234
+ throw new CloudFlowError('invalid_response', 'Cloud did not confirm the deletion.');
235
+ }
236
+ }
package/src/cloud-http.ts CHANGED
@@ -1,3 +1,7 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
1
5
  export interface CloudConnectionOptions {
2
6
  /** Cloud application base URL; defaults to https://agentrelay.com/cloud. */
3
7
  apiUrl?: string;
@@ -10,7 +14,9 @@ export interface CloudConnectionOptions {
10
14
 
11
15
  export class CloudFlowError extends Error {
12
16
  constructor(
13
- readonly code: 'configuration' | 'unsupported_source' | 'invalid_input' | 'invalid_response' | 'http_error' | 'transport_error' | 'transient_error',
17
+ readonly code: 'configuration' | 'unsupported_source' | 'invalid_input' | 'invalid_response' | 'http_error'
18
+ | 'transport_error' | 'transient_error' | 'unsupported_storage_backend' | 'sync_too_large' | 'sync_unsupported'
19
+ | 'patch_conflict',
14
20
  message: string,
15
21
  readonly status?: number,
16
22
  ) {
@@ -19,16 +25,66 @@ export class CloudFlowError extends Error {
19
25
  }
20
26
  }
21
27
 
28
+ /**
29
+ * The `agent-relay cloud login` credential store. Read only when neither the
30
+ * `token` option nor `FLOWS_CLOUD_TOKEN` is set, so an explicit credential
31
+ * always wins and this file can change shape without breaking a configured
32
+ * caller. Its `apiUrl` becomes the default base URL for the same reason: a
33
+ * login against one deployment must not send its token to another.
34
+ */
35
+ export function agentRelayCloudAuthPath(env: NodeJS.ProcessEnv = process.env): string {
36
+ return join(env['AGENT_RELAY_HOME'] ?? join(homedir(), '.agentworkforce/relay'), 'cloud-auth.json');
37
+ }
38
+
39
+ export interface AgentRelayCloudLogin {
40
+ apiUrl: string;
41
+ accessToken: string;
42
+ /** ISO-8601; the store carries it, so an expired login refuses with a real reason. */
43
+ accessTokenExpiresAt?: string;
44
+ }
45
+
46
+ export function readAgentRelayCloudLogin(
47
+ env: NodeJS.ProcessEnv = process.env,
48
+ read: (path: string) => string = path => readFileSync(path, 'utf8'),
49
+ ): AgentRelayCloudLogin | undefined {
50
+ let parsed: unknown;
51
+ try {
52
+ parsed = JSON.parse(read(agentRelayCloudAuthPath(env)));
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ if (!isCloudRecord(parsed) || typeof parsed.apiUrl !== 'string' || typeof parsed.accessToken !== 'string'
57
+ || !parsed.accessToken.trim()) return undefined;
58
+ return {
59
+ apiUrl: parsed.apiUrl, accessToken: parsed.accessToken,
60
+ ...(typeof parsed.accessTokenExpiresAt === 'string' ? { accessTokenExpiresAt: parsed.accessTokenExpiresAt } : {}),
61
+ };
62
+ }
63
+
22
64
  export function cloudConnection(options: CloudConnectionOptions): { baseUrl: string; token: string } {
23
- const rawToken = options.token ?? process.env['FLOWS_CLOUD_TOKEN'];
65
+ let rawToken = options.token ?? process.env['FLOWS_CLOUD_TOKEN'];
66
+ let loginApiUrl: string | undefined;
67
+ if (rawToken === undefined) {
68
+ const login = readAgentRelayCloudLogin();
69
+ if (login !== undefined) {
70
+ const expiresAt = login.accessTokenExpiresAt === undefined ? Number.NaN : Date.parse(login.accessTokenExpiresAt);
71
+ if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
72
+ throw new CloudFlowError('configuration',
73
+ 'The agent-relay cloud login has expired. Run `agent-relay cloud login` again, or set FLOWS_CLOUD_TOKEN.');
74
+ }
75
+ rawToken = login.accessToken;
76
+ loginApiUrl = login.apiUrl;
77
+ }
78
+ }
24
79
  const token = rawToken?.trim();
25
80
  if (!token || /[\r\n]/u.test(rawToken!) || /^(?:rk|ot)_live_/u.test(token)) {
26
81
  throw new CloudFlowError('configuration',
27
- 'Set FLOWS_CLOUD_TOKEN to a scoped Cloud API token (workflow:invoke:write and workflow:runs:read).');
82
+ 'Set FLOWS_CLOUD_TOKEN to a scoped Cloud API token (workflow:invoke:write and workflow:runs:read), '
83
+ + 'or sign in with `agent-relay cloud login`.');
28
84
  }
29
85
  let url: URL;
30
86
  try {
31
- url = new URL(options.apiUrl ?? process.env['FLOWS_CLOUD_URL'] ?? 'https://agentrelay.com/cloud');
87
+ url = new URL(options.apiUrl ?? process.env['FLOWS_CLOUD_URL'] ?? loginApiUrl ?? 'https://agentrelay.com/cloud');
32
88
  } catch {
33
89
  throw new CloudFlowError('configuration', 'FLOWS_CLOUD_URL must be an absolute Cloud application base URL.');
34
90
  }
@@ -36,26 +92,79 @@ export function cloudConnection(options: CloudConnectionOptions): { baseUrl: str
36
92
  || url.username || url.password || url.search || url.hash || !/^\/[A-Za-z0-9/_-]*$/u.test(url.pathname)) {
37
93
  throw new CloudFlowError('configuration', 'Cloud URL must use HTTPS and a plain base path.');
38
94
  }
39
- return { baseUrl: `${url.origin}${url.pathname.replace(/\/+$/u, '')}`, token };
95
+ const baseUrl = `${url.origin}${url.pathname.replace(/\/+$/u, '')}`;
96
+ // A login-store token is bound to the deployment that issued it. An explicit
97
+ // URL that names another deployment gets no token at all — set
98
+ // FLOWS_CLOUD_TOKEN for that deployment instead.
99
+ if (loginApiUrl !== undefined) {
100
+ let issued: string | undefined;
101
+ try {
102
+ const login = new URL(loginApiUrl);
103
+ issued = `${login.origin}${login.pathname.replace(/\/+$/u, '')}`;
104
+ } catch { issued = undefined; }
105
+ if (issued !== baseUrl) {
106
+ throw new CloudFlowError('configuration',
107
+ `The agent-relay cloud login was issued for ${loginApiUrl}, not ${baseUrl}. `
108
+ + 'Set FLOWS_CLOUD_TOKEN for that deployment, or unset FLOWS_CLOUD_URL to use the login.');
109
+ }
110
+ }
111
+ return { baseUrl, token };
40
112
  }
41
113
 
42
114
  export async function cloudRequest(
43
115
  path: string,
44
116
  options: CloudConnectionOptions,
45
117
  body?: unknown,
118
+ ): Promise<unknown> {
119
+ return cloudFetch(path, options, body === undefined
120
+ ? { method: 'GET' }
121
+ : { method: 'POST', body: JSON.stringify(body), contentType: 'application/json' });
122
+ }
123
+
124
+ export interface CloudFetchInit {
125
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
126
+ body?: string | Uint8Array;
127
+ contentType?: string;
128
+ /**
129
+ * A run-scoped token issued by Cloud for one upload (the `prepare` receipt's
130
+ * storage credential). Used instead of the configured token for that request
131
+ * only; it is never persisted or logged.
132
+ */
133
+ bearerToken?: string;
134
+ /**
135
+ * On a non-2xx response, read a `{ code, error }` refusal from the body and
136
+ * name it. Only those two string fields are ever surfaced, so a route that
137
+ * answers with structured refusals (the deploy routes) can explain itself
138
+ * without this client echoing arbitrary response bodies.
139
+ */
140
+ detail?: boolean;
141
+ }
142
+
143
+ /** One authenticated Cloud request. Every transport error is typed; no retries. */
144
+ export async function cloudFetch(
145
+ path: string,
146
+ options: CloudConnectionOptions,
147
+ init: CloudFetchInit,
46
148
  ): Promise<unknown> {
47
149
  const { baseUrl, token } = cloudConnection(options);
48
150
  const timeout = options.requestTimeoutMs ?? 30_000;
49
151
  if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647) {
50
152
  throw new CloudFlowError('configuration', 'requestTimeoutMs must be a positive 32-bit integer.');
51
153
  }
154
+ const bearer = init.bearerToken ?? token;
155
+ if (/[\r\n]/u.test(bearer) || !bearer.trim()) {
156
+ throw new CloudFlowError('invalid_response', 'Cloud issued an unusable storage credential.');
157
+ }
52
158
  const deadline = AbortSignal.timeout(timeout);
53
159
  let response: Response;
54
160
  try {
55
161
  response = await fetch(`${baseUrl}${path}`, {
56
- method: body === undefined ? 'GET' : 'POST',
57
- headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
58
- ...(body === undefined ? {} : { body: JSON.stringify(body) }),
162
+ method: init.method,
163
+ headers: {
164
+ authorization: `Bearer ${bearer}`,
165
+ 'content-type': init.contentType ?? 'application/json',
166
+ },
167
+ ...(init.body === undefined ? {} : { body: typeof init.body === 'string' ? init.body : new Blob([init.body]) }),
59
168
  signal: options.signal ? AbortSignal.any([options.signal, deadline]) : deadline,
60
169
  // A redirect must never carry the credential to a different origin.
61
170
  redirect: 'error',
@@ -66,7 +175,10 @@ export async function cloudRequest(
66
175
  }
67
176
  if (!response.ok) {
68
177
  // Do not echo server response bodies: they may contain credentials or source.
69
- throw new CloudFlowError('http_error', `Cloud request failed with HTTP ${response.status}.`, response.status);
178
+ const refusal = init.detail ? await structuredRefusal(response) : undefined;
179
+ throw new CloudFlowError('http_error', refusal === undefined
180
+ ? `Cloud request failed with HTTP ${response.status}.`
181
+ : `Cloud refused (${refusal.code}): ${refusal.error}`, response.status);
70
182
  }
71
183
  try {
72
184
  return await response.json();
@@ -77,6 +189,14 @@ export async function cloudRequest(
77
189
  }
78
190
  }
79
191
 
192
+ async function structuredRefusal(response: Response): Promise<{ code: string; error: string } | undefined> {
193
+ 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 };
198
+ }
199
+
80
200
  // Defensive path-segment constraint; accepting a new server ID format needs an SDK change.
81
201
  export function cloudRunId(value: unknown): string {
82
202
  if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/u.test(value)) {
package/src/cloud-run.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  CloudFlowError, cloudConnection, cloudRequest, cloudRunId, isCloudRecord,
14
14
  type CloudConnectionOptions,
15
15
  } from './cloud-http.js';
16
+ import { packWorkingTree, prepareCloudSync, uploadCloudCode } from './cloud-sync.js';
16
17
 
17
18
  export type CloudFlowSource = FlowSpec | { path: string };
18
19
  export interface RunInCloudOptions extends CloudConnectionOptions {
@@ -20,6 +21,21 @@ export interface RunInCloudOptions extends CloudConnectionOptions {
20
21
  workspaceId?: string;
21
22
  /** Exact JSON input. Required at runtime for authored .flow.ts; refused for declarative source. */
22
23
  input?: JsonValue;
24
+ /**
25
+ * Upload this working tree before submission so the hosted run executes
26
+ * inside it (v1's `--sync-code`). Cloud-API storage only; see cloud-sync.ts.
27
+ * The submitted flow still travels as source in the request body, so it
28
+ * must remain self-contained: sibling imports inside the tree are not
29
+ * resolved by the hosted runner.
30
+ */
31
+ syncCode?: { root: string };
32
+ /**
33
+ * Called immediately before the one non-idempotent request, the run
34
+ * submission. Everything before it (prepare, pack, upload) is safe to
35
+ * retry, so a caller classifying an interruption can tell "nothing was
36
+ * admitted" from "admission unknown".
37
+ */
38
+ onSubmit?: () => void;
23
39
  }
24
40
  export interface CloudRunReceipt {
25
41
  runId: string;
@@ -28,6 +44,8 @@ export interface CloudRunReceipt {
28
44
  specHash: string;
29
45
  /** Authenticated run API resource; this is not a public sharing URL. */
30
46
  apiUrl: string;
47
+ /** Present when a working tree was synced: what was uploaded, by count and size. */
48
+ synced?: { files: number; bytes: number; skippedLinks: string[] };
31
49
  }
32
50
  export interface CloudAuthoredAuthority {
33
51
  readonly schemaVersion: 1;
@@ -118,6 +136,26 @@ export async function runInCloud(
118
136
  authority: authored.authority,
119
137
  input: authoredInput,
120
138
  })).digest('hex');
139
+ // Sync before submission: `prepare` reserves the run ID and the upload lands
140
+ // under it, so the run request below names code Cloud already holds. A
141
+ // refused backend or failed upload therefore never leaves a launched run
142
+ // pointing at a tree that is not there.
143
+ let synced: { runId: string; codeKey: string; files: number; bytes: number; skippedLinks: string[] } | undefined;
144
+ if (options.syncCode !== undefined) {
145
+ const prepared = await prepareCloudSync(options);
146
+ options.signal?.throwIfAborted();
147
+ const packed = await packWorkingTree(options.syncCode.root);
148
+ try {
149
+ options.signal?.throwIfAborted();
150
+ await uploadCloudCode(prepared, packed, options);
151
+ } finally {
152
+ packed.dispose();
153
+ }
154
+ synced = { runId: prepared.runId, codeKey: prepared.codeKey, files: packed.files.length, bytes: packed.bytes,
155
+ skippedLinks: packed.skippedLinks };
156
+ }
157
+ options.signal?.throwIfAborted();
158
+ options.onSubmit?.();
121
159
  const result = await cloudRequest('/api/v1/workflows/run', options, {
122
160
  // JSON is a YAML subset. Sending canonical data preserves the exact spec
123
161
  // while using the server's existing YAML-to-config admission path.
@@ -127,12 +165,20 @@ export async function runInCloud(
127
165
  ...(authored === undefined ? {} : { authoredAuthority: authored.authority }),
128
166
  ...(authored === undefined ? {} : { inputs: authoredInput }),
129
167
  ...(options.workspaceId === undefined ? {} : { workspaceId: options.workspaceId }),
168
+ ...(synced === undefined ? {} : { runId: synced.runId, s3CodeKey: synced.codeKey }),
130
169
  });
131
170
  if (!isCloudRecord(result) || (result.status !== 'pending' && result.status !== 'running')) {
132
171
  throw new CloudFlowError('invalid_response', 'Cloud did not return an accepted run.');
133
172
  }
134
173
  const runId = cloudRunId(result.runId);
135
- return { runId, status: result.status, specHash: hash, apiUrl: `${baseUrl}/api/v1/workflows/runs/${runId}` };
174
+ if (synced !== undefined && runId !== synced.runId) {
175
+ throw new CloudFlowError('invalid_response',
176
+ `Cloud accepted run ${runId} but the synced code was uploaded for ${synced.runId}.`);
177
+ }
178
+ return {
179
+ runId, status: result.status, specHash: hash, apiUrl: `${baseUrl}/api/v1/workflows/runs/${runId}`,
180
+ ...(synced === undefined ? {} : { synced: { files: synced.files, bytes: synced.bytes, skippedLinks: synced.skippedLinks } }),
181
+ };
136
182
  }
137
183
 
138
184
  export async function getCloudFlowRun(