@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.
- 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-executor.d.ts +33 -3
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +56 -10
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +11 -4
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +2 -2
- package/dist/authored-root.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 +19 -1
- 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 +14 -3
- package/dist/cli/check-triggers.js.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-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.d.ts.map +1 -1
- package/dist/cli/direct-run.js +2 -26
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +20 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +77 -15
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +82 -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 +181 -0
- package/dist/cloud-deploy.js.map +1 -0
- package/dist/cloud-http.d.ts +37 -2
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +98 -8
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-run.d.ts +23 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +30 -1
- package/dist/cloud-run.js.map +1 -1
- 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 +8 -1
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +8 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/provider-trigger-contract.d.ts +27 -0
- package/dist/provider-trigger-contract.d.ts.map +1 -0
- package/dist/provider-trigger-contract.js +0 -0
- package/dist/provider-trigger-contract.js.map +1 -0
- package/package.json +2 -2
- package/src/agent-artifacts.ts +76 -0
- package/src/authored-flow-executor.ts +77 -15
- package/src/authored-node-runner.ts +11 -4
- package/src/authored-root.ts +2 -2
- package/src/authored-step-output.ts +6 -1
- package/src/authored-worker-step.ts +19 -1
- package/src/cli/check-triggers.ts +14 -5
- package/src/cli/check.ts +6 -1
- package/src/cli/cloud-deploy.ts +157 -0
- package/src/cli/cloud-run.ts +37 -6
- package/src/cli/cloud-sync.ts +37 -0
- package/src/cli/direct-run.ts +2 -25
- package/src/cli/run.ts +86 -16
- package/src/cli.ts +75 -12
- package/src/cloud-deploy.ts +236 -0
- package/src/cloud-http.ts +129 -9
- package/src/cloud-run.ts +47 -1
- package/src/cloud-sync.ts +315 -0
- package/src/failure-kinds.ts +8 -0
- package/src/index.ts +8 -0
- 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'
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
57
|
-
headers: {
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|