@relayflows/sdk 2.0.15 → 2.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-artifacts.d.ts +20 -0
- package/dist/agent-artifacts.d.ts.map +1 -0
- package/dist/agent-artifacts.js +81 -0
- package/dist/agent-artifacts.js.map +1 -0
- package/dist/authored-flow-error.d.ts +1 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts +3 -3
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +93 -7
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-lifecycle.d.ts +10 -0
- package/dist/authored-flow-lifecycle.d.ts.map +1 -1
- package/dist/authored-flow-lifecycle.js +7 -0
- package/dist/authored-flow-lifecycle.js.map +1 -1
- package/dist/authored-flow-operation.d.ts +11 -0
- package/dist/authored-flow-operation.d.ts.map +1 -1
- package/dist/authored-flow-operation.js +32 -7
- package/dist/authored-flow-operation.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +37 -14
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-step-output.d.ts +2 -0
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +5 -1
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +15 -8
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/check-triggers.d.ts.map +1 -1
- package/dist/cli/check-triggers.js +10 -0
- package/dist/cli/check-triggers.js.map +1 -1
- package/dist/cli/check.d.ts +14 -0
- package/dist/cli/check.d.ts.map +1 -1
- package/dist/cli/check.js +6 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/cloud-deploy.d.ts +30 -0
- package/dist/cli/cloud-deploy.d.ts.map +1 -0
- package/dist/cli/cloud-deploy.js +162 -0
- package/dist/cli/cloud-deploy.js.map +1 -0
- package/dist/cli/cloud-run.d.ts +3 -1
- package/dist/cli/cloud-run.d.ts.map +1 -1
- package/dist/cli/cloud-run.js +37 -5
- package/dist/cli/cloud-run.js.map +1 -1
- package/dist/cli/cloud-schedule.d.ts +29 -0
- package/dist/cli/cloud-schedule.d.ts.map +1 -0
- package/dist/cli/cloud-schedule.js +131 -0
- package/dist/cli/cloud-schedule.js.map +1 -0
- package/dist/cli/cloud-sync.d.ts +13 -0
- package/dist/cli/cloud-sync.d.ts.map +1 -0
- package/dist/cli/cloud-sync.js +39 -0
- package/dist/cli/cloud-sync.js.map +1 -0
- package/dist/cli/direct-run.js +1 -1
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +17 -4
- package/dist/cli/run.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +117 -12
- package/dist/cli.js.map +1 -1
- package/dist/cloud-deploy.d.ts +70 -0
- package/dist/cloud-deploy.d.ts.map +1 -0
- package/dist/cloud-deploy.js +192 -0
- package/dist/cloud-deploy.js.map +1 -0
- package/dist/cloud-http.d.ts +52 -2
- package/dist/cloud-http.d.ts.map +1 -1
- package/dist/cloud-http.js +135 -9
- package/dist/cloud-http.js.map +1 -1
- package/dist/cloud-run.d.ts +53 -0
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +97 -25
- package/dist/cloud-run.js.map +1 -1
- package/dist/cloud-schedule.d.ts +48 -0
- package/dist/cloud-schedule.d.ts.map +1 -0
- package/dist/cloud-schedule.js +125 -0
- package/dist/cloud-schedule.js.map +1 -0
- package/dist/cloud-sync.d.ts +66 -0
- package/dist/cloud-sync.d.ts.map +1 -0
- package/dist/cloud-sync.js +287 -0
- package/dist/cloud-sync.js.map +1 -0
- package/dist/failure-kinds.d.ts +10 -3
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +10 -0
- package/dist/failure-kinds.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/named-gate-lowering.js +9 -0
- package/dist/named-gate-lowering.js.map +1 -1
- package/dist/named-gates.d.ts +1 -1
- package/dist/named-gates.d.ts.map +1 -1
- package/dist/named-gates.js +11 -1
- package/dist/named-gates.js.map +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +155 -9
- package/dist/preflight.js.map +1 -1
- package/dist/schedule-trigger.d.ts +35 -0
- package/dist/schedule-trigger.d.ts.map +1 -0
- package/dist/schedule-trigger.js +53 -0
- package/dist/schedule-trigger.js.map +1 -0
- package/dist/spec.d.ts +11 -1
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/trigger-executor.js +1 -1
- package/dist/trigger-executor.js.map +1 -1
- package/dist/validate.js +1 -1
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +10 -0
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +51 -22
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +9 -0
- package/dist/worker.js.map +1 -1
- package/dist/wrapper-session.d.ts +3 -1
- package/dist/wrapper-session.d.ts.map +1 -1
- package/dist/wrapper-session.js +6 -3
- package/dist/wrapper-session.js.map +1 -1
- package/package.json +3 -3
- package/src/agent-artifacts.ts +76 -0
- package/src/authored-flow-error.ts +1 -0
- package/src/authored-flow-executor.ts +95 -8
- package/src/authored-flow-lifecycle.ts +7 -0
- package/src/authored-flow-operation.ts +35 -10
- package/src/authored-node-runner.ts +35 -13
- package/src/authored-step-output.ts +6 -1
- package/src/authored-worker-step.ts +16 -9
- package/src/cli/check-triggers.ts +9 -0
- package/src/cli/check.ts +21 -1
- package/src/cli/cloud-deploy.ts +157 -0
- package/src/cli/cloud-run.ts +37 -6
- package/src/cli/cloud-schedule.ts +135 -0
- package/src/cli/cloud-sync.ts +37 -0
- package/src/cli/direct-run.ts +1 -1
- package/src/cli/run.ts +18 -5
- package/src/cli.ts +107 -12
- package/src/cloud-deploy.ts +247 -0
- package/src/cloud-http.ts +173 -9
- package/src/cloud-run.ts +143 -24
- package/src/cloud-schedule.ts +158 -0
- package/src/cloud-sync.ts +315 -0
- package/src/failure-kinds.ts +10 -0
- package/src/index.ts +14 -0
- package/src/named-gate-lowering.ts +9 -0
- package/src/named-gates.ts +11 -1
- package/src/preflight.ts +132 -9
- package/src/schedule-trigger.ts +73 -0
- package/src/spec.ts +12 -1
- package/src/trigger-executor.ts +1 -1
- package/src/validate.ts +1 -1
- package/src/worker-cli.ts +41 -0
- package/src/worker.ts +9 -0
- package/src/wrapper-session.ts +5 -1
|
@@ -0,0 +1,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'
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
57
|
-
headers: {
|
|
58
|
-
|
|
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
|
-
|
|
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)) {
|