agent-relay 3.2.18 → 3.2.22

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 (76) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +233 -55
  6. package/dist/src/cli/commands/cloud.d.ts +1 -9
  7. package/dist/src/cli/commands/cloud.d.ts.map +1 -1
  8. package/dist/src/cli/commands/cloud.js +326 -323
  9. package/dist/src/cli/commands/cloud.js.map +1 -1
  10. package/dist/src/cli/commands/connect.d.ts.map +1 -1
  11. package/dist/src/cli/commands/connect.js +6 -10
  12. package/dist/src/cli/commands/connect.js.map +1 -1
  13. package/package.json +16 -10
  14. package/packages/acp-bridge/package.json +2 -2
  15. package/packages/brand/README.md +36 -0
  16. package/packages/brand/brand.css +226 -0
  17. package/packages/brand/package.json +20 -0
  18. package/packages/cloud/dist/api-client.d.ts +33 -0
  19. package/packages/cloud/dist/api-client.d.ts.map +1 -0
  20. package/packages/cloud/dist/api-client.js +123 -0
  21. package/packages/cloud/dist/api-client.js.map +1 -0
  22. package/packages/cloud/dist/auth.d.ts +13 -0
  23. package/packages/cloud/dist/auth.d.ts.map +1 -0
  24. package/packages/cloud/dist/auth.js +248 -0
  25. package/packages/cloud/dist/auth.js.map +1 -0
  26. package/packages/cloud/dist/index.d.ts +5 -0
  27. package/packages/cloud/dist/index.d.ts.map +1 -0
  28. package/packages/cloud/dist/index.js +5 -0
  29. package/packages/cloud/dist/index.js.map +1 -0
  30. package/packages/cloud/dist/types.d.ts +73 -0
  31. package/packages/cloud/dist/types.d.ts.map +1 -0
  32. package/packages/cloud/dist/types.js +19 -0
  33. package/packages/cloud/dist/types.js.map +1 -0
  34. package/packages/cloud/dist/workflows.d.ts +34 -0
  35. package/packages/cloud/dist/workflows.d.ts.map +1 -0
  36. package/packages/cloud/dist/workflows.js +389 -0
  37. package/packages/cloud/dist/workflows.js.map +1 -0
  38. package/packages/cloud/package.json +44 -0
  39. package/packages/cloud/src/api-client.ts +169 -0
  40. package/packages/cloud/src/auth.ts +314 -0
  41. package/packages/cloud/src/index.ts +41 -0
  42. package/packages/cloud/src/types.ts +97 -0
  43. package/packages/cloud/src/workflows.ts +539 -0
  44. package/packages/cloud/tsconfig.json +21 -0
  45. package/packages/config/package.json +1 -1
  46. package/packages/hooks/package.json +4 -4
  47. package/packages/memory/package.json +2 -2
  48. package/packages/openclaw/package.json +2 -2
  49. package/packages/policy/package.json +2 -2
  50. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.d.ts +2 -0
  51. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.d.ts.map +1 -0
  52. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.js +62 -0
  53. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.js.map +1 -0
  54. package/packages/sdk/dist/workflows/cli.js +46 -2
  55. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  56. package/packages/sdk/dist/workflows/file-db.d.ts +2 -0
  57. package/packages/sdk/dist/workflows/file-db.d.ts.map +1 -1
  58. package/packages/sdk/dist/workflows/file-db.js +20 -3
  59. package/packages/sdk/dist/workflows/file-db.js.map +1 -1
  60. package/packages/sdk/dist/workflows/runner.d.ts +10 -1
  61. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  62. package/packages/sdk/dist/workflows/runner.js +233 -50
  63. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  64. package/packages/sdk/package.json +2 -2
  65. package/packages/sdk/src/__tests__/resume-fallback.test.ts +415 -0
  66. package/packages/sdk/src/__tests__/workflow-runner.test.ts +73 -2
  67. package/packages/sdk/src/workflows/__tests__/e2big-and-verify.test.ts +117 -0
  68. package/packages/sdk/src/workflows/cli.ts +53 -2
  69. package/packages/sdk/src/workflows/file-db.ts +22 -3
  70. package/packages/sdk/src/workflows/runner.ts +283 -49
  71. package/packages/sdk-py/pyproject.toml +1 -1
  72. package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserver.swift +2 -0
  73. package/packages/telemetry/package.json +1 -1
  74. package/packages/trajectory/package.json +2 -2
  75. package/packages/user-directory/package.json +2 -2
  76. package/packages/utils/package.json +2 -2
@@ -1,390 +1,393 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import readline from 'node:readline';
5
- import { execFile } from 'node:child_process';
6
- import { promisify } from 'node:util';
7
- import { randomBytes } from 'node:crypto';
8
- import { formatTableRow } from '../lib/formatting.js';
9
- import { createCloudApiClient, } from '../lib/cloud-client.js';
10
- const DEFAULT_CLOUD_URL = process.env.AGENT_RELAY_CLOUD_URL || 'https://agent-relay.com';
11
- const execFileAsync = promisify(execFile);
4
+ import { InvalidArgumentError } from 'commander';
5
+ import { CLI_AUTH_CONFIG } from '@agent-relay/config/cli-auth-config';
6
+ import { ensureAuthenticated, authorizedApiFetch, readStoredAuth, clearStoredAuth, defaultApiUrl, AUTH_FILE_PATH, REFRESH_WINDOW_MS, runWorkflow, getRunStatus, getRunLogs, syncWorkflowPatch, } from '@agent-relay/cloud';
7
+ import { runInteractiveSession } from '../lib/ssh-interactive.js';
8
+ // ── Helpers ──────────────────────────────────────────────────────────────────
9
+ const color = {
10
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
11
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
12
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
13
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
14
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
15
+ };
12
16
  function defaultExit(code) {
13
17
  process.exit(code);
14
18
  }
15
- async function defaultPrompt(question) {
16
- const rl = readline.createInterface({
17
- input: process.stdin,
18
- output: process.stdout,
19
- });
20
- return await new Promise((resolve) => {
21
- rl.question(question, (answer) => {
22
- rl.close();
23
- resolve(answer.trim());
24
- });
25
- });
26
- }
27
- async function defaultOpenExternal(url) {
28
- if (process.platform === 'darwin') {
29
- await execFileAsync('open', [url]);
30
- return;
31
- }
32
- if (process.platform === 'win32') {
33
- await execFileAsync('cmd', ['/c', 'start', '', url]);
34
- return;
35
- }
36
- await execFileAsync('xdg-open', [url]);
37
- }
38
- function createDefaultApiClient() {
39
- return createCloudApiClient();
40
- }
41
19
  function withDefaults(overrides = {}) {
42
20
  return {
43
- createApiClient: createDefaultApiClient,
44
- getDataDir: () => process.env.AGENT_RELAY_DATA_DIR || path.join(os.homedir(), '.local', 'share', 'agent-relay'),
45
- getHostname: () => os.hostname(),
46
- randomHex: (bytes) => randomBytes(bytes).toString('hex'),
47
- now: () => new Date(),
48
- openExternal: defaultOpenExternal,
49
- prompt: defaultPrompt,
50
21
  log: (...args) => console.log(...args),
51
22
  error: (...args) => console.error(...args),
52
23
  exit: defaultExit,
53
24
  ...overrides,
54
25
  };
55
26
  }
56
- function readConfigFile(configPath) {
57
- if (!fs.existsSync(configPath)) {
58
- return undefined;
27
+ const PROVIDER_ALIASES = {
28
+ claude: 'anthropic',
29
+ codex: 'openai',
30
+ gemini: 'google',
31
+ };
32
+ const PROVIDER_HELP_TEXT = Object.keys(CLI_AUTH_CONFIG)
33
+ .sort()
34
+ .map((id) => {
35
+ const alias = Object.entries(PROVIDER_ALIASES).find(([, target]) => target === id);
36
+ return alias ? `${id} (alias: ${alias[0]})` : id;
37
+ })
38
+ .join(', ');
39
+ function normalizeProvider(providerArg) {
40
+ const providerInput = providerArg.toLowerCase().trim();
41
+ return PROVIDER_ALIASES[providerInput] || providerInput;
42
+ }
43
+ function parsePositiveInteger(value) {
44
+ const parsed = Number.parseInt(value, 10);
45
+ if (!Number.isInteger(parsed) || parsed <= 0) {
46
+ throw new InvalidArgumentError('Expected a positive integer.');
59
47
  }
60
- const raw = fs.readFileSync(configPath, 'utf-8');
61
- return JSON.parse(raw);
48
+ return parsed;
62
49
  }
63
- function stripApiSuffix(cloudUrl) {
64
- return cloudUrl.replace(/\/api\/?$/, '');
50
+ function parseNonNegativeInteger(value) {
51
+ const parsed = Number.parseInt(value, 10);
52
+ if (!Number.isInteger(parsed) || parsed < 0) {
53
+ throw new InvalidArgumentError('Expected a non-negative integer.');
54
+ }
55
+ return parsed;
65
56
  }
66
- function getPaths(dataDir) {
67
- return {
68
- machineIdPath: path.join(dataDir, 'machine-id'),
69
- configPath: path.join(dataDir, 'cloud-config.json'),
70
- tempCodePath: path.join(dataDir, '.link-code'),
71
- credentialsPath: path.join(dataDir, 'cloud-credentials.json'),
72
- };
57
+ function parseWorkflowFileType(value) {
58
+ if (value === 'yaml' || value === 'ts' || value === 'py') {
59
+ return value;
60
+ }
61
+ throw new InvalidArgumentError('Expected workflow type to be one of: yaml, ts, py');
62
+ }
63
+ function sleep(ms) {
64
+ return new Promise((resolve) => setTimeout(resolve, ms));
73
65
  }
74
- function ensureLinked(configPath, deps) {
75
- const config = readConfigFile(configPath);
76
- if (!config) {
77
- deps.error('Not linked to cloud. Run `agent-relay cloud link` first.');
78
- deps.exit(1);
66
+ async function getErrorDetails(response) {
67
+ let body;
68
+ try {
69
+ body = await response.text();
70
+ }
71
+ catch {
72
+ return response.statusText;
73
+ }
74
+ if (!body)
75
+ return response.statusText;
76
+ try {
77
+ const json = JSON.parse(body);
78
+ return json.error || json.message || response.statusText;
79
+ }
80
+ catch {
81
+ return body;
79
82
  }
80
- return config;
81
83
  }
84
+ // ── Command registration ─────────────────────────────────────────────────────
82
85
  export function registerCloudCommands(program, overrides = {}) {
83
86
  const deps = withDefaults(overrides);
84
87
  const cloudCommand = program
85
88
  .command('cloud')
86
- .description('Cloud account and sync commands')
87
- .addHelpText('afterAll', '\nBREAKING CHANGE: daemon compatibility was removed. Cloud integrations must use /api/brokers/* and brokerId/brokerName.');
89
+ .description('Cloud account, provider auth, and workflow commands');
90
+ // ── login ──────────────────────────────────────────────────────────────────
88
91
  cloudCommand
89
- .command('link')
90
- .description('Link this machine to your Agent Relay Cloud account')
91
- .option('--name <name>', 'Name for this machine')
92
- .option('--cloud-url <url>', 'Cloud API URL', DEFAULT_CLOUD_URL)
92
+ .command('login')
93
+ .description('Authenticate with Agent Relay Cloud via browser')
94
+ .option('--api-url <url>', 'Cloud API base URL')
95
+ .option('--force', 'Force re-authentication even if already logged in')
93
96
  .action(async (options) => {
94
- const cloudUrl = options.cloudUrl;
95
- const machineName = options.name || deps.getHostname();
96
- const dataDir = deps.getDataDir();
97
- const { machineIdPath, configPath, tempCodePath } = getPaths(dataDir);
98
- let machineId;
99
- if (fs.existsSync(machineIdPath)) {
100
- machineId = fs.readFileSync(machineIdPath, 'utf-8').trim();
97
+ const apiUrl = options.apiUrl || defaultApiUrl();
98
+ if (!options.force) {
99
+ const existing = await readStoredAuth();
100
+ if (existing && existing.apiUrl === apiUrl) {
101
+ const expiresAt = Date.parse(existing.accessTokenExpiresAt);
102
+ if (!Number.isNaN(expiresAt) && expiresAt - Date.now() > REFRESH_WINDOW_MS) {
103
+ deps.log(`Already logged in to ${existing.apiUrl}`);
104
+ return;
105
+ }
106
+ }
101
107
  }
102
- else {
103
- machineId = `${deps.getHostname()}-${deps.randomHex(8)}`;
104
- fs.mkdirSync(dataDir, { recursive: true });
105
- fs.writeFileSync(machineIdPath, machineId);
108
+ await ensureAuthenticated(apiUrl, { force: options.force });
109
+ });
110
+ // ── logout ─────────────────────────────────────────────────────────────────
111
+ cloudCommand
112
+ .command('logout')
113
+ .description('Clear stored cloud credentials')
114
+ .action(async () => {
115
+ const auth = await readStoredAuth();
116
+ if (!auth) {
117
+ deps.log('Not logged in.');
118
+ return;
106
119
  }
107
- deps.log('');
108
- deps.log('Agent Relay Cloud - Link Machine');
109
- deps.log('');
110
- deps.log(`Machine: ${machineName}`);
111
- deps.log(`ID: ${machineId}`);
112
- deps.log('');
113
- const tempCode = deps.randomHex(16);
114
- fs.writeFileSync(tempCodePath, tempCode);
115
- const authUrl = `${stripApiSuffix(cloudUrl)}/cloud/link?code=${tempCode}` +
116
- `&machine=${encodeURIComponent(machineId)}&name=${encodeURIComponent(machineName)}`;
117
- deps.log('Open this URL in your browser to authenticate:');
118
- deps.log('');
119
- deps.log(` ${authUrl}`);
120
- deps.log('');
121
120
  try {
122
- await deps.openExternal(authUrl);
123
- deps.log('(Browser opened automatically)');
121
+ const revokeUrl = new URL('api/v1/auth/token/revoke', auth.apiUrl.endsWith('/') ? auth.apiUrl : `${auth.apiUrl}/`);
122
+ await fetch(revokeUrl, {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify({ token: auth.refreshToken }),
126
+ });
124
127
  }
125
128
  catch {
126
- deps.log('(Copy the URL above and paste it in your browser)');
129
+ // best-effort revoke
127
130
  }
128
- deps.log('');
129
- deps.log('After authenticating, paste your API key here:');
130
- const apiKey = (await deps.prompt('API Key: ')).trim();
131
- if (!apiKey || !apiKey.startsWith('ar_live_')) {
132
- deps.error('');
133
- deps.error('Invalid API key format. Expected ar_live_...');
134
- deps.exit(1);
131
+ await clearStoredAuth();
132
+ deps.log('Logged out.');
133
+ });
134
+ // ── whoami ─────────────────────────────────────────────────────────────────
135
+ cloudCommand
136
+ .command('whoami')
137
+ .description('Show current authentication status')
138
+ .option('--api-url <url>', 'Cloud API base URL')
139
+ .action(async (options) => {
140
+ const apiUrl = options.apiUrl || defaultApiUrl();
141
+ const auth = await ensureAuthenticated(apiUrl);
142
+ const { response } = await authorizedApiFetch(auth, '/api/v1/auth/whoami', {
143
+ method: 'GET',
144
+ });
145
+ const payload = (await response.json().catch(() => null));
146
+ if (!response.ok || !payload?.authenticated) {
147
+ throw new Error(payload?.error || 'Failed to resolve auth status');
148
+ }
149
+ deps.log(`API URL: ${auth.apiUrl}`);
150
+ deps.log(`Auth source: ${payload.source}`);
151
+ deps.log(`Subject type: ${payload.subjectType ?? 'session'}`);
152
+ deps.log(`User: ${payload.user.name || '(no name)'}${payload.user.email ? ` <${payload.user.email}>` : ''}`);
153
+ deps.log(`Organization: ${payload.currentOrganization.name}`);
154
+ deps.log(`Workspace: ${payload.currentWorkspace.name}`);
155
+ deps.log(`Scopes: ${payload.scopes.length > 0 ? payload.scopes.join(', ') : '(none)'}`);
156
+ deps.log(`Token file: ${AUTH_FILE_PATH}`);
157
+ });
158
+ // ── connect ────────────────────────────────────────────────────────────────
159
+ cloudCommand
160
+ .command('connect')
161
+ .description('Connect a provider via interactive SSH session')
162
+ .argument('<provider>', `Provider to connect (${PROVIDER_HELP_TEXT})`)
163
+ .option('--api-url <url>', 'Cloud API base URL')
164
+ .option('--language <language>', 'Sandbox language/image', 'typescript')
165
+ .option('--timeout <seconds>', 'Connection timeout in seconds', parsePositiveInteger, 300)
166
+ .action(async (providerArg, options) => {
167
+ const timeoutMs = options.timeout * 1000;
168
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
169
+ throw new Error('This command requires an interactive terminal (TTY).');
170
+ }
171
+ const provider = normalizeProvider(providerArg);
172
+ const providerConfig = CLI_AUTH_CONFIG[provider];
173
+ if (!providerConfig) {
174
+ const known = Object.keys(CLI_AUTH_CONFIG).sort();
175
+ throw new Error(`Unknown provider: ${providerArg}. Supported providers: ${known.join(', ')}`);
176
+ }
177
+ const apiUrl = options.apiUrl || defaultApiUrl();
178
+ const io = {
179
+ log: deps.log,
180
+ error: deps.error,
181
+ };
182
+ io.log('');
183
+ io.log(color.cyan('═══════════════════════════════════════════════════'));
184
+ io.log(color.cyan(' Provider Authentication (Daytona Connect)'));
185
+ io.log(color.cyan('═══════════════════════════════════════════════════'));
186
+ io.log('');
187
+ io.log(`Provider: ${providerConfig.displayName} (${provider})`);
188
+ io.log(`Language: ${color.dim(options.language)}`);
189
+ io.log(color.dim(`Cloud: ${apiUrl}`));
190
+ io.log('');
191
+ io.log('Requesting sandbox from cloud...');
192
+ let auth = await ensureAuthenticated(apiUrl);
193
+ const { response: createResponse, auth: refreshedAuth } = await authorizedApiFetch(auth, '/api/v1/cli/auth', {
194
+ method: 'POST',
195
+ body: JSON.stringify({ provider, language: options.language }),
196
+ });
197
+ auth = refreshedAuth;
198
+ const start = (await createResponse.json().catch(() => null));
199
+ if (!createResponse.ok || !start?.sessionId) {
200
+ const detail = start?.error || start?.message || `${createResponse.status} ${createResponse.statusText}`;
201
+ throw new Error(detail);
202
+ }
203
+ const sshPort = typeof start.ssh?.port === 'string'
204
+ ? Number.parseInt(start.ssh.port, 10)
205
+ : start.ssh?.port;
206
+ if (!start.ssh?.host || !sshPort || !start.ssh.user || !start.ssh.password) {
207
+ throw new Error('Cloud returned invalid SSH session details.');
135
208
  }
136
- deps.log('');
137
- deps.log('Verifying API key...');
209
+ io.log(color.green('✓ Sandbox ready'));
210
+ io.log(color.dim(` SSH: ${start.ssh.user}@${start.ssh.host}:${sshPort}`));
211
+ io.log('');
212
+ io.log(color.yellow('Connecting via SSH...'));
213
+ io.log(color.dim(` Running: ${start.remoteCommand}`));
214
+ io.log('');
215
+ let sessionResult;
138
216
  try {
139
- const client = deps.createApiClient();
140
- await client.verifyApiKey({ cloudUrl, apiKey });
141
- const config = {
142
- apiKey,
143
- cloudUrl,
144
- machineId,
145
- machineName,
146
- linkedAt: deps.now().toISOString(),
147
- };
148
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
149
- fs.chmodSync(configPath, 0o600);
150
- if (fs.existsSync(tempCodePath)) {
151
- fs.unlinkSync(tempCodePath);
152
- }
153
- deps.log('');
154
- deps.log('Machine linked successfully!');
155
- deps.log('');
156
- deps.log('Your broker will now sync with Agent Relay Cloud.');
157
- deps.log('Run `agent-relay up` to start with cloud sync enabled.');
158
- deps.log('');
217
+ sessionResult = await runInteractiveSession({
218
+ ssh: {
219
+ host: start.ssh.host,
220
+ port: sshPort,
221
+ user: start.ssh.user,
222
+ password: start.ssh.password,
223
+ },
224
+ remoteCommand: start.remoteCommand,
225
+ successPatterns: providerConfig.successPatterns || [],
226
+ errorPatterns: providerConfig.errorPatterns || [],
227
+ timeoutMs,
228
+ io,
229
+ });
159
230
  }
160
- catch (err) {
161
- const message = err instanceof Error ? err.message : String(err);
162
- deps.error(`Failed to connect to cloud: ${message}`);
163
- deps.exit(1);
231
+ catch (error) {
232
+ throw new Error(`Failed to connect via SSH: ${error instanceof Error ? error.message : String(error)}`);
233
+ }
234
+ io.log('');
235
+ const success = sessionResult.authDetected;
236
+ io.log('Finalizing authentication with cloud...');
237
+ const { response: completeResponse } = await authorizedApiFetch(auth, '/api/v1/cli/auth/complete', {
238
+ method: 'POST',
239
+ body: JSON.stringify({ sessionId: start.sessionId, success }),
240
+ });
241
+ if (!completeResponse.ok) {
242
+ throw new Error(await getErrorDetails(completeResponse));
164
243
  }
244
+ if (!success) {
245
+ const exitCode = sessionResult.exitCode;
246
+ if (typeof exitCode === 'number' && exitCode !== 0) {
247
+ io.error(color.red(`Remote auth command exited with code ${exitCode}.`));
248
+ }
249
+ if (sessionResult.exitCode === 127) {
250
+ io.log(color.yellow(`The ${providerConfig.displayName} CLI ("${providerConfig.command}") is not installed on the sandbox.`));
251
+ io.log(color.dim('Check the sandbox snapshot includes the required CLI tools.'));
252
+ }
253
+ throw new Error(`Provider auth for ${provider} did not complete successfully`);
254
+ }
255
+ io.log('');
256
+ io.log(color.green('═══════════════════════════════════════════════════'));
257
+ io.log(color.green(' Authentication Complete!'));
258
+ io.log(color.green('═══════════════════════════════════════════════════'));
259
+ io.log('');
260
+ io.log(`${providerConfig.displayName} credentials are now stored and encrypted.`);
261
+ io.log(color.dim('Your workflows will automatically use these credentials.'));
262
+ io.log('');
165
263
  });
264
+ // ── run ────────────────────────────────────────────────────────────────────
166
265
  cloudCommand
167
- .command('unlink')
168
- .description('Unlink this machine from Agent Relay Cloud')
169
- .action(async () => {
170
- const dataDir = deps.getDataDir();
171
- const { configPath } = getPaths(dataDir);
172
- if (!fs.existsSync(configPath)) {
173
- deps.log('This machine is not linked to Agent Relay Cloud.');
266
+ .command('run')
267
+ .description('Submit a workflow run')
268
+ .argument('<workflow>', 'Workflow file path or inline workflow content')
269
+ .option('--api-url <url>', 'Cloud API base URL')
270
+ .option('--file-type <type>', 'Workflow type: yaml, ts, or py', parseWorkflowFileType)
271
+ .option('--sync-code', 'Upload the current working directory before running')
272
+ .option('--no-sync-code', 'Skip uploading the current working directory')
273
+ .option('--json', 'Print raw JSON response', false)
274
+ .action(async (workflow, options) => {
275
+ const result = await runWorkflow(workflow, options);
276
+ if (options.json) {
277
+ deps.log(JSON.stringify(result, null, 2));
174
278
  return;
175
279
  }
176
- const config = readConfigFile(configPath);
177
- fs.unlinkSync(configPath);
178
- deps.log('');
179
- deps.log('Machine unlinked from Agent Relay Cloud');
180
- deps.log('');
181
- deps.log(`Machine ID: ${config?.machineId || 'unknown'}`);
182
- deps.log(`Was linked since: ${config?.linkedAt || 'unknown'}`);
183
- deps.log('');
184
- deps.log('Note: The API key has been removed locally. To fully revoke access,');
185
- deps.log('visit your Agent Relay Cloud dashboard and remove this machine.');
186
- deps.log('');
280
+ deps.log(`Run created: ${result.runId}`);
281
+ if (typeof result.sandboxId === 'string') {
282
+ deps.log(`Sandbox: ${result.sandboxId}`);
283
+ }
284
+ deps.log(`Status: ${result.status}`);
285
+ deps.log(`\nView logs: agent-relay cloud logs ${result.runId} --follow`);
286
+ deps.log(`Sync code: agent-relay cloud sync ${result.runId}`);
187
287
  });
288
+ // ── status ─────────────────────────────────────────────────────────────────
188
289
  cloudCommand
189
290
  .command('status')
190
- .description('Show cloud sync status')
191
- .action(async () => {
192
- const dataDir = deps.getDataDir();
193
- const { configPath } = getPaths(dataDir);
194
- const config = readConfigFile(configPath);
195
- if (!config) {
196
- deps.log('');
197
- deps.log('Cloud sync: Not configured');
198
- deps.log('');
199
- deps.log('Run `agent-relay cloud link` to connect to Agent Relay Cloud.');
200
- deps.log('');
291
+ .description('Fetch workflow run status')
292
+ .argument('<runId>', 'Workflow run id')
293
+ .option('--api-url <url>', 'Cloud API base URL')
294
+ .option('--json', 'Print raw JSON response', false)
295
+ .action(async (runId, options) => {
296
+ const result = await getRunStatus(runId, options);
297
+ if (options.json) {
298
+ deps.log(JSON.stringify(result, null, 2));
201
299
  return;
202
300
  }
203
- deps.log('');
204
- deps.log('Cloud sync: Enabled');
205
- deps.log('');
206
- deps.log(` Machine: ${config.machineName}`);
207
- deps.log(` ID: ${config.machineId}`);
208
- deps.log(` Cloud URL: ${config.cloudUrl}`);
209
- deps.log(` Linked: ${new Date(config.linkedAt).toLocaleString()}`);
210
- deps.log('');
211
- try {
212
- const client = deps.createApiClient();
213
- const online = await client.checkConnection({
214
- cloudUrl: config.cloudUrl,
215
- apiKey: config.apiKey,
216
- });
217
- deps.log(` Cloud connection: ${online ? 'Online' : 'Error (API key may be invalid)'}`);
218
- }
219
- catch (err) {
220
- const message = err instanceof Error ? err.message : String(err);
221
- deps.log(` Cloud connection: Offline (${message})`);
222
- }
223
- deps.log('');
224
- });
225
- cloudCommand
226
- .command('sync')
227
- .description('Manually sync credentials from cloud')
228
- .action(async () => {
229
- const dataDir = deps.getDataDir();
230
- const { configPath, credentialsPath } = getPaths(dataDir);
231
- const config = ensureLinked(configPath, deps);
232
- deps.log('Syncing credentials from cloud...');
233
- try {
234
- const client = deps.createApiClient();
235
- const credentials = await client.syncCredentials({
236
- cloudUrl: config.cloudUrl,
237
- apiKey: config.apiKey,
238
- });
239
- deps.log('');
240
- deps.log(`Synced ${credentials.length} provider credentials:`);
241
- for (const credential of credentials) {
242
- deps.log(` - ${credential.provider}`);
243
- }
244
- fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2));
245
- fs.chmodSync(credentialsPath, 0o600);
246
- deps.log('');
247
- deps.log('Credentials synced successfully');
248
- deps.log('');
301
+ deps.log(`Run: ${result.runId ?? runId}`);
302
+ deps.log(`Status: ${result.status ?? 'unknown'}`);
303
+ if (typeof result.sandboxId === 'string') {
304
+ deps.log(`Sandbox: ${result.sandboxId}`);
249
305
  }
250
- catch (err) {
251
- const message = err instanceof Error ? err.message : String(err);
252
- deps.error(`Failed to sync: ${message}`);
253
- deps.exit(1);
306
+ if (typeof result.updatedAt === 'string') {
307
+ deps.log(`Updated: ${result.updatedAt}`);
254
308
  }
255
309
  });
310
+ // ── logs ───────────────────────────────────────────────────────────────────
256
311
  cloudCommand
257
- .command('agents')
258
- .description('List agents across all linked machines')
259
- .option('--json', 'Output as JSON')
260
- .action(async (options) => {
261
- const dataDir = deps.getDataDir();
262
- const { configPath } = getPaths(dataDir);
263
- const config = ensureLinked(configPath, deps);
264
- try {
265
- const client = deps.createApiClient();
266
- const agents = await client.listAgents({
267
- cloudUrl: config.cloudUrl,
268
- apiKey: config.apiKey,
312
+ .command('logs')
313
+ .description('Read workflow run logs')
314
+ .argument('<runId>', 'Workflow run id')
315
+ .option('--api-url <url>', 'Cloud API base URL')
316
+ .option('--follow', 'Poll until the run is done', false)
317
+ .option('--poll-interval <seconds>', 'Polling interval while following', parsePositiveInteger, 2)
318
+ .option('--offset <bytes>', 'Start reading logs from a byte offset', parseNonNegativeInteger, 0)
319
+ .option('--agent <name>', 'Read logs for a specific agent')
320
+ .option('--sandbox-id <sandboxId>', 'Read logs for a specific step sandbox')
321
+ .option('--json', 'Print raw JSON responses', false)
322
+ .action(async (runId, options) => {
323
+ let offset = options.offset ?? 0;
324
+ const sandboxId = options.agent ?? options.sandboxId;
325
+ while (true) {
326
+ const result = await getRunLogs(runId, {
327
+ apiUrl: options.apiUrl,
328
+ offset,
329
+ sandboxId,
269
330
  });
270
331
  if (options.json) {
271
- deps.log(JSON.stringify(agents, null, 2));
272
- return;
332
+ deps.log(JSON.stringify(result, null, 2));
273
333
  }
274
- if (!agents.length) {
275
- deps.log('No agents found across linked machines.');
276
- deps.log('Make sure brokers are running on linked machines.');
277
- return;
334
+ else if (result.content) {
335
+ process.stdout.write(result.content);
278
336
  }
279
- deps.log('');
280
- deps.log('Agents across all linked machines:');
281
- deps.log('');
282
- deps.log('NAME STATUS BROKER MACHINE');
283
- deps.log('─'.repeat(65));
284
- const byBroker = new Map();
285
- for (const agent of agents) {
286
- const current = byBroker.get(agent.brokerName) || [];
287
- current.push(agent);
288
- byBroker.set(agent.brokerName, current);
337
+ offset = result.offset;
338
+ if (!options.follow || result.done) {
339
+ break;
289
340
  }
290
- for (const [brokerName, brokerAgents] of byBroker.entries()) {
291
- for (const agent of brokerAgents) {
292
- const machine = (agent.machineId || '').substring(0, 20);
293
- deps.log(formatTableRow([
294
- { value: agent.name, width: 15 },
295
- { value: agent.status, width: 8 },
296
- { value: brokerName, width: 18 },
297
- { value: machine },
298
- ]));
299
- }
300
- }
301
- deps.log('');
302
- deps.log(`Total: ${agents.length} agents on ${byBroker.size} machines`);
303
- deps.log('');
304
- }
305
- catch (err) {
306
- const message = err instanceof Error ? err.message : String(err);
307
- deps.error(`Failed to fetch agents: ${message}`);
308
- deps.exit(1);
341
+ await sleep((options.pollInterval ?? 2) * 1000);
309
342
  }
310
343
  });
344
+ // ── sync ───────────────────────────────────────────────────────────────────
311
345
  cloudCommand
312
- .command('send')
313
- .description('Send a message to an agent on any linked machine')
314
- .argument('<agent>', 'Target agent name')
315
- .argument('<message>', 'Message to send')
316
- .option('--from <name>', 'Sender name', '__cli_sender__')
317
- .action(async (agent, message, options) => {
318
- const dataDir = deps.getDataDir();
319
- const { configPath } = getPaths(dataDir);
320
- const config = ensureLinked(configPath, deps);
321
- deps.log(`Sending message to ${agent}...`);
322
- try {
323
- const client = deps.createApiClient();
324
- const allAgents = await client.listAgents({
325
- cloudUrl: config.cloudUrl,
326
- apiKey: config.apiKey,
327
- });
328
- const targetAgent = allAgents.find((candidate) => candidate.name === agent);
329
- if (!targetAgent) {
330
- deps.error(`Agent "${agent}" not found.`);
331
- deps.log('Available agents:');
332
- for (const availableAgent of allAgents) {
333
- deps.log(` - ${availableAgent.name} (on ${availableAgent.brokerName})`);
334
- }
335
- deps.exit(1);
336
- return;
337
- }
338
- await client.sendMessage({
339
- cloudUrl: config.cloudUrl,
340
- apiKey: config.apiKey,
341
- targetBrokerId: targetAgent.brokerId,
342
- targetAgent: agent,
343
- from: options.from,
344
- content: message,
345
- });
346
- deps.log('');
347
- deps.log(`Message sent to ${agent} on ${targetAgent.brokerName}`);
348
- deps.log('');
346
+ .command('sync')
347
+ .description('Download and apply code changes from a completed workflow run')
348
+ .argument('<runId>', 'Workflow run id')
349
+ .option('--api-url <url>', 'Cloud API base URL')
350
+ .option('--dir <path>', 'Local directory to apply the patch to', '.')
351
+ .option('--dry-run', 'Download and display the patch without applying', false)
352
+ .action(async (runId, options) => {
353
+ const targetDir = path.resolve(options.dir ?? '.');
354
+ deps.log(`Fetching patch for run ${runId}...`);
355
+ const result = await syncWorkflowPatch(runId, { apiUrl: options.apiUrl });
356
+ if (!result.hasChanges) {
357
+ deps.log('No changes to sync — the workflow did not modify any files.');
358
+ return;
349
359
  }
350
- catch (err) {
351
- const messageText = err instanceof Error ? err.message : String(err);
352
- deps.error(`Failed to send message: ${messageText}`);
353
- deps.exit(1);
360
+ if (options.dryRun) {
361
+ deps.log('\n--- Patch (dry run) ---');
362
+ process.stdout.write(result.patch);
363
+ deps.log('\n--- End patch ---');
364
+ return;
354
365
  }
355
- });
356
- cloudCommand
357
- .command('brokers')
358
- .description('List all linked broker instances')
359
- .option('--json', 'Output as JSON')
360
- .action(async (options) => {
361
- const dataDir = deps.getDataDir();
362
- const { configPath } = getPaths(dataDir);
363
- const config = ensureLinked(configPath, deps);
366
+ const { execSync } = await import('node:child_process');
367
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloud-sync-'));
368
+ const tmpPatch = path.join(tmpDir, 'changes.patch');
369
+ fs.writeFileSync(tmpPatch, result.patch, { mode: 0o600 });
364
370
  try {
365
- if (options.json) {
366
- deps.log(JSON.stringify([{
367
- machineName: config.machineName,
368
- machineId: config.machineId,
369
- cloudUrl: config.cloudUrl,
370
- linkedAt: config.linkedAt,
371
- }], null, 2));
372
- return;
371
+ const stat = execSync(`git apply --stat "${tmpPatch}"`, {
372
+ cwd: targetDir,
373
+ encoding: 'utf-8',
374
+ stdio: ['pipe', 'pipe', 'pipe'],
375
+ });
376
+ if (stat.trim()) {
377
+ deps.log('\nFiles changed by agent:');
378
+ deps.log(stat);
373
379
  }
374
- deps.log('');
375
- deps.log('Linked Broker:');
376
- deps.log('');
377
- deps.log(` Machine: ${config.machineName}`);
378
- deps.log(` ID: ${config.machineId}`);
379
- deps.log(` Cloud: ${config.cloudUrl}`);
380
- deps.log(` Linked: ${new Date(config.linkedAt).toLocaleString()}`);
381
- deps.log('');
382
- deps.log('Note: To see all linked brokers, visit your cloud dashboard.');
383
- deps.log('');
380
+ execSync(`git apply "${tmpPatch}"`, {
381
+ cwd: targetDir,
382
+ encoding: 'utf-8',
383
+ stdio: ['pipe', 'pipe', 'pipe'],
384
+ });
385
+ deps.log('Patch applied successfully.');
384
386
  }
385
387
  catch (err) {
386
388
  const message = err instanceof Error ? err.message : String(err);
387
- deps.error(`Failed: ${message}`);
389
+ deps.error(`Failed to apply patch: ${message}`);
390
+ deps.error(`Patch saved to: ${tmpPatch}`);
388
391
  deps.exit(1);
389
392
  }
390
393
  });