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