@agentworkforce/deploy 0.0.0 → 3.0.3
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/bundle.js +19 -2
- package/dist/bundle.js.map +1 -1
- package/dist/bundle.test.js +40 -0
- package/dist/bundle.test.js.map +1 -1
- package/dist/deploy.d.ts.map +1 -1
- package/dist/deploy.js +52 -26
- package/dist/deploy.js.map +1 -1
- package/dist/deploy.test.js +126 -10
- package/dist/deploy.test.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +57 -3
- package/dist/index.js.map +1 -1
- package/dist/login.d.ts +30 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +263 -0
- package/dist/login.js.map +1 -1
- package/dist/modes/cloud.d.ts +13 -14
- package/dist/modes/cloud.d.ts.map +1 -1
- package/dist/modes/cloud.js +717 -15
- package/dist/modes/cloud.js.map +1 -1
- package/dist/modes/cloud.test.d.ts +2 -0
- package/dist/modes/cloud.test.d.ts.map +1 -0
- package/dist/modes/cloud.test.js +506 -0
- package/dist/modes/cloud.test.js.map +1 -0
- package/dist/modes/dev.d.ts.map +1 -1
- package/dist/modes/dev.js +2 -0
- package/dist/modes/dev.js.map +1 -1
- package/dist/modes/input-values.test.d.ts +2 -0
- package/dist/modes/input-values.test.d.ts.map +1 -0
- package/dist/modes/input-values.test.js +242 -0
- package/dist/modes/input-values.test.js.map +1 -0
- package/dist/modes/sandbox.d.ts +1 -1
- package/dist/modes/sandbox.d.ts.map +1 -1
- package/dist/modes/sandbox.js +4 -2
- package/dist/modes/sandbox.js.map +1 -1
- package/dist/runtime-context.d.ts +3 -0
- package/dist/runtime-context.d.ts.map +1 -0
- package/dist/runtime-context.js +40 -0
- package/dist/runtime-context.js.map +1 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +8 -8
package/dist/modes/cloud.js
CHANGED
|
@@ -1,21 +1,723 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { platform } from 'node:os';
|
|
6
|
+
import { resolveWorkspaceToken } from '../login.js';
|
|
7
|
+
const DEFAULT_CLOUD_URL = 'https://agentrelay.com';
|
|
8
|
+
const BUILD_YOUR_OWN_CLOUD_DOCS_URL = 'https://docs.agentworkforce.com/deploy/build-your-own-cloud';
|
|
9
|
+
const USER_AGENT = 'workforce-deploy';
|
|
10
|
+
const MAX_ATTEMPTS = 3;
|
|
11
|
+
const POLL_TIMEOUT_MS = 60_000;
|
|
12
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
1
13
|
/**
|
|
2
|
-
*
|
|
3
|
-
* cloud
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `--mode cloud` returns a clean error that points users at the working
|
|
8
|
-
* modes (`--mode sandbox` and `--mode dev`).
|
|
9
|
-
*
|
|
10
|
-
* When the endpoint ships, the implementation flow is:
|
|
11
|
-
* 1. POST persona.json + agent.bundle.mjs + runner.mjs as multipart.
|
|
12
|
-
* 2. Receive `{ deploymentId, statusUrl }`.
|
|
13
|
-
* 3. Poll `statusUrl` until the cloud reports `running`.
|
|
14
|
-
* 4. Return a handle whose `stop()` calls DELETE on the deployment.
|
|
14
|
+
* Cloud-hosted deploy mode. Uploads the deploy-ready persona bundle to a
|
|
15
|
+
* workforce-compatible cloud endpoint. The implementation is intentionally
|
|
16
|
+
* OSS-generic: callers may point at any compatible runtime with
|
|
17
|
+
* `--cloud-url`, `WORKFORCE_CLOUD_URL`, or `persona.cloud.deployUrl`; the
|
|
18
|
+
* production AgentRelay URL is only the final default.
|
|
15
19
|
*/
|
|
16
20
|
export const cloudLauncher = {
|
|
17
|
-
async launch(
|
|
18
|
-
|
|
21
|
+
async launch(input) {
|
|
22
|
+
const cloudUrl = resolveCloudUrl(input);
|
|
23
|
+
const noPrompt = isNoPrompt(input);
|
|
24
|
+
const auth = input.workspaceToken
|
|
25
|
+
? { token: input.workspaceToken }
|
|
26
|
+
: await resolveWorkspaceToken({
|
|
27
|
+
workspace: input.workspace,
|
|
28
|
+
cloudUrl,
|
|
29
|
+
io: input.io,
|
|
30
|
+
noPrompt
|
|
31
|
+
});
|
|
32
|
+
await ensureHarnessReady({
|
|
33
|
+
cloudUrl,
|
|
34
|
+
workspaceId: input.workspace,
|
|
35
|
+
token: auth.token,
|
|
36
|
+
persona: input.persona,
|
|
37
|
+
io: input.io,
|
|
38
|
+
noPrompt,
|
|
39
|
+
harnessSource: input.harnessSource,
|
|
40
|
+
byokKey: input.byokKey
|
|
41
|
+
});
|
|
42
|
+
await ensureCloudIntegrations({
|
|
43
|
+
cloudUrl,
|
|
44
|
+
workspaceId: input.workspace,
|
|
45
|
+
token: auth.token,
|
|
46
|
+
persona: input.persona,
|
|
47
|
+
io: input.io,
|
|
48
|
+
noPrompt
|
|
49
|
+
});
|
|
50
|
+
const existingPersona = await handleExistingPersona({
|
|
51
|
+
cloudUrl,
|
|
52
|
+
workspaceId: input.workspace,
|
|
53
|
+
token: auth.token,
|
|
54
|
+
personaId: input.persona.id,
|
|
55
|
+
io: input.io,
|
|
56
|
+
noPrompt,
|
|
57
|
+
onExists: input.onExists
|
|
58
|
+
});
|
|
59
|
+
if (existingPersona.cancelled) {
|
|
60
|
+
return {
|
|
61
|
+
id: existingPersona.agentId,
|
|
62
|
+
agentId: existingPersona.agentId,
|
|
63
|
+
deploymentId: 'cancelled',
|
|
64
|
+
status: 'cancelled',
|
|
65
|
+
async stop() {
|
|
66
|
+
/* no-op: user chose not to change the existing hosted persona. */
|
|
67
|
+
},
|
|
68
|
+
done: Promise.resolve({ code: 0 })
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
const endpoint = `${cloudUrl}/api/v1/workspaces/${encodeURIComponent(input.workspace)}/deployments`;
|
|
72
|
+
const body = JSON.stringify({
|
|
73
|
+
persona: input.persona,
|
|
74
|
+
bundle: {
|
|
75
|
+
runner: await readFile(input.bundle.runnerPath, 'utf8'),
|
|
76
|
+
agent: await readFile(input.bundle.bundlePath, 'utf8'),
|
|
77
|
+
packageJson: JSON.parse(await readFile(input.bundle.packageJsonPath, 'utf8'))
|
|
78
|
+
},
|
|
79
|
+
inputs: input.inputs ?? readInputsOverride()
|
|
80
|
+
});
|
|
81
|
+
input.io.info(`cloud: deploying persona bundle to ${cloudUrl}`);
|
|
82
|
+
const deployBody = await requestJsonWithRetry(endpoint, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: jsonHeaders(auth.token),
|
|
85
|
+
body
|
|
86
|
+
}, { action: 'cloud deploy' });
|
|
87
|
+
const agentId = expectString(deployBody.agentId, 'agentId');
|
|
88
|
+
const deploymentId = expectString(deployBody.deploymentId, 'deploymentId');
|
|
89
|
+
const initialStatus = expectStatus(deployBody.status);
|
|
90
|
+
input.io.info(`cloud: deployment ${deploymentId} created for agent ${agentId}`);
|
|
91
|
+
let stopping = false;
|
|
92
|
+
const done = (async () => {
|
|
93
|
+
if (initialStatus === 'active')
|
|
94
|
+
return { code: 0 };
|
|
95
|
+
if (initialStatus === 'failed')
|
|
96
|
+
return { code: 1 };
|
|
97
|
+
try {
|
|
98
|
+
const finalStatus = await pollAgentStatus({
|
|
99
|
+
cloudUrl,
|
|
100
|
+
workspaceId: input.workspace,
|
|
101
|
+
agentId,
|
|
102
|
+
token: auth.token,
|
|
103
|
+
io: input.io,
|
|
104
|
+
onLog: input.onLog
|
|
105
|
+
});
|
|
106
|
+
return { code: finalStatus === 'active' ? 0 : 1 };
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (!stopping) {
|
|
110
|
+
input.io.error(`cloud: status polling failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
111
|
+
}
|
|
112
|
+
return { code: 1 };
|
|
113
|
+
}
|
|
114
|
+
})();
|
|
115
|
+
const stop = async () => {
|
|
116
|
+
if (stopping)
|
|
117
|
+
return;
|
|
118
|
+
stopping = true;
|
|
119
|
+
await deleteAgent({
|
|
120
|
+
cloudUrl,
|
|
121
|
+
workspaceId: input.workspace,
|
|
122
|
+
agentId,
|
|
123
|
+
token: auth.token,
|
|
124
|
+
action: 'cloud stop'
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
return {
|
|
128
|
+
id: agentId,
|
|
129
|
+
agentId,
|
|
130
|
+
deploymentId,
|
|
131
|
+
status: initialStatus,
|
|
132
|
+
stop,
|
|
133
|
+
done
|
|
134
|
+
};
|
|
19
135
|
}
|
|
20
136
|
};
|
|
137
|
+
function resolveCloudUrl(input) {
|
|
138
|
+
const fromInput = input.cloudUrl?.trim();
|
|
139
|
+
const fromEnv = process.env.WORKFORCE_DEPLOY_CLOUD_URL?.trim()
|
|
140
|
+
|| process.env.WORKFORCE_CLOUD_URL?.trim();
|
|
141
|
+
const fromPersona = readPersonaCloudDeployUrl(input.persona);
|
|
142
|
+
const raw = fromInput || fromEnv || fromPersona || DEFAULT_CLOUD_URL;
|
|
143
|
+
const resolved = normalizeCloudUrl(raw);
|
|
144
|
+
if (resolved !== DEFAULT_CLOUD_URL) {
|
|
145
|
+
input.io.info(`cloud: using custom cloud URL ${resolved}. Build your own cloud docs: ${BUILD_YOUR_OWN_CLOUD_DOCS_URL}`);
|
|
146
|
+
}
|
|
147
|
+
return resolved;
|
|
148
|
+
}
|
|
149
|
+
function isNoPrompt(input) {
|
|
150
|
+
if (input.noPrompt)
|
|
151
|
+
return true;
|
|
152
|
+
const raw = process.env.WORKFORCE_DEPLOY_NO_PROMPT?.trim().toLowerCase();
|
|
153
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
154
|
+
}
|
|
155
|
+
async function ensureHarnessReady(args) {
|
|
156
|
+
const source = await resolveHarnessSource(args);
|
|
157
|
+
const modelProvider = deriveModelProvider(args.persona);
|
|
158
|
+
if (source === 'plan') {
|
|
159
|
+
await saveProviderCredential({
|
|
160
|
+
cloudUrl: args.cloudUrl,
|
|
161
|
+
token: args.token,
|
|
162
|
+
modelProvider,
|
|
163
|
+
authType: 'relay_managed'
|
|
164
|
+
});
|
|
165
|
+
args.io.info(`cloud: using workforce plan credentials for ${args.persona.harness}`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (source === 'byok') {
|
|
169
|
+
const key = await resolveByokKey(args);
|
|
170
|
+
await saveProviderCredential({
|
|
171
|
+
cloudUrl: args.cloudUrl,
|
|
172
|
+
token: args.token,
|
|
173
|
+
modelProvider,
|
|
174
|
+
authType: 'byo_api_key',
|
|
175
|
+
apiKey: key
|
|
176
|
+
});
|
|
177
|
+
args.io.info(`cloud: using BYOK credentials for ${args.persona.harness}`);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
await ensureHarnessOauth(args);
|
|
181
|
+
}
|
|
182
|
+
async function resolveHarnessSource(args) {
|
|
183
|
+
if (args.harnessSource)
|
|
184
|
+
return args.harnessSource;
|
|
185
|
+
const fromEnv = process.env.WORKFORCE_DEPLOY_HARNESS_SOURCE?.trim();
|
|
186
|
+
if (fromEnv)
|
|
187
|
+
return expectHarnessSource(fromEnv);
|
|
188
|
+
const available = await isHarnessOauthConnected(args);
|
|
189
|
+
if (available)
|
|
190
|
+
return 'oauth';
|
|
191
|
+
if (args.noPrompt) {
|
|
192
|
+
throw new Error(`cloud: ${args.persona.harness} credentials are not connected. Re-run with --harness-source plan|byok|oauth, set WORKFORCE_DEPLOY_HARNESS_SOURCE, or run without --no-prompt.`);
|
|
193
|
+
}
|
|
194
|
+
const answer = await args.io.prompt(`${args.persona.harness} credentials are not connected. Choose harness source (plan/byok/oauth)`, { defaultValue: 'plan' });
|
|
195
|
+
return expectHarnessSource(answer);
|
|
196
|
+
}
|
|
197
|
+
async function isHarnessOauthConnected(args) {
|
|
198
|
+
const url = `${args.cloudUrl}/api/v1/users/me/provider_credentials?model_provider=${encodeURIComponent(deriveModelProvider(args.persona))}`;
|
|
199
|
+
const res = await fetch(url, {
|
|
200
|
+
method: 'GET',
|
|
201
|
+
headers: {
|
|
202
|
+
authorization: `Bearer ${args.token}`,
|
|
203
|
+
'user-agent': USER_AGENT
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
if (res.status === 404 || res.status === 405)
|
|
207
|
+
return false;
|
|
208
|
+
if (res.status === 401) {
|
|
209
|
+
throw new Error('cloud harness check failed: unauthorized. Run `workforce login` and retry.');
|
|
210
|
+
}
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
throw new Error(`cloud harness check failed: ${res.status} ${await responseExcerpt(res)}`);
|
|
213
|
+
}
|
|
214
|
+
const body = (await res.json());
|
|
215
|
+
return providerCredentialsReady(body);
|
|
216
|
+
}
|
|
217
|
+
async function resolveByokKey(args) {
|
|
218
|
+
if (args.byokKey?.trim())
|
|
219
|
+
return args.byokKey.trim();
|
|
220
|
+
const fromEnv = process.env.WORKFORCE_DEPLOY_BYOK_KEY?.trim();
|
|
221
|
+
if (fromEnv)
|
|
222
|
+
return fromEnv;
|
|
223
|
+
if (args.noPrompt) {
|
|
224
|
+
throw new Error(`cloud: --harness-source byok requires --byok-key or WORKFORCE_DEPLOY_BYOK_KEY for ${args.persona.harness}`);
|
|
225
|
+
}
|
|
226
|
+
const answer = await args.io.prompt(`API key for ${args.persona.harness}`);
|
|
227
|
+
if (!answer.trim()) {
|
|
228
|
+
throw new Error(`cloud: missing BYOK API key for ${args.persona.harness}`);
|
|
229
|
+
}
|
|
230
|
+
return answer.trim();
|
|
231
|
+
}
|
|
232
|
+
async function ensureHarnessOauth(args) {
|
|
233
|
+
if (await isHarnessOauthConnected(args)) {
|
|
234
|
+
args.io.info(`cloud: ${args.persona.harness} credentials already connected`);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (args.noPrompt) {
|
|
238
|
+
throw new Error(`cloud: ${args.persona.harness} OAuth credentials are not connected. Run without --no-prompt or choose --harness-source plan/byok.`);
|
|
239
|
+
}
|
|
240
|
+
const ok = await args.io.confirm(`Connect ${args.persona.harness} credentials now? (opens browser)`, { defaultValue: true });
|
|
241
|
+
if (!ok) {
|
|
242
|
+
throw new Error(`cloud: ${args.persona.harness} credentials are required for deploy`);
|
|
243
|
+
}
|
|
244
|
+
const modelProvider = deriveModelProvider(args.persona);
|
|
245
|
+
const startUrl = `${args.cloudUrl}/api/v1/users/me/provider_credentials/auth-session`;
|
|
246
|
+
const body = await requestJsonWithRetry(startUrl, {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
headers: jsonHeaders(args.token),
|
|
249
|
+
body: JSON.stringify({
|
|
250
|
+
model_provider: modelProvider,
|
|
251
|
+
provider: args.persona.harness,
|
|
252
|
+
language: 'typescript'
|
|
253
|
+
})
|
|
254
|
+
}, { action: 'cloud harness OAuth start' });
|
|
255
|
+
const connectUrl = readFirstString(body, ['connectLink', 'authUrl', 'url', 'sandboxUrl']);
|
|
256
|
+
if (connectUrl) {
|
|
257
|
+
args.io.info(`cloud: open ${connectUrl} to finish ${args.persona.harness} OAuth`);
|
|
258
|
+
tryOpenBrowser(connectUrl);
|
|
259
|
+
}
|
|
260
|
+
await pollUntil(() => isHarnessOauthConnected(args), `timed out waiting for ${args.persona.harness} OAuth credentials`);
|
|
261
|
+
args.io.info(`cloud: ${args.persona.harness} credentials connected`);
|
|
262
|
+
}
|
|
263
|
+
async function ensureCloudIntegrations(args) {
|
|
264
|
+
const providers = Object.keys(args.persona.integrations ?? {});
|
|
265
|
+
for (const provider of providers) {
|
|
266
|
+
const ready = await isIntegrationReady({ ...args, provider });
|
|
267
|
+
if (ready) {
|
|
268
|
+
args.io.info(`cloud: integrations.${provider} ready`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (args.noPrompt) {
|
|
272
|
+
throw new Error(`cloud: integrations.${provider} is not connected. Run without --no-prompt or connect it before deploying.`);
|
|
273
|
+
}
|
|
274
|
+
const ok = await args.io.confirm(`Connect ${provider} in workforce cloud now? (opens browser)`, { defaultValue: true });
|
|
275
|
+
if (!ok) {
|
|
276
|
+
throw new Error(`cloud: integrations.${provider} is required for deploy`);
|
|
277
|
+
}
|
|
278
|
+
await connectIntegration({ ...args, provider });
|
|
279
|
+
await pollUntil(() => isIntegrationReady({ ...args, provider }), `timed out waiting for integrations.${provider} to become ready`);
|
|
280
|
+
args.io.info(`cloud: integrations.${provider} connected`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function isIntegrationReady(args) {
|
|
284
|
+
const url = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent(args.workspaceId)}/integrations?provider=${encodeURIComponent(args.provider)}`;
|
|
285
|
+
const res = await fetch(url, {
|
|
286
|
+
method: 'GET',
|
|
287
|
+
headers: {
|
|
288
|
+
authorization: `Bearer ${args.token}`,
|
|
289
|
+
'user-agent': USER_AGENT
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
if (res.status === 401) {
|
|
293
|
+
throw new Error('cloud integration check failed: unauthorized. Run `workforce login` and retry.');
|
|
294
|
+
}
|
|
295
|
+
if (res.status === 404)
|
|
296
|
+
return false;
|
|
297
|
+
if (!res.ok) {
|
|
298
|
+
throw new Error(`cloud integration check failed: ${res.status} ${await responseExcerpt(res)}`);
|
|
299
|
+
}
|
|
300
|
+
const body = (await res.json());
|
|
301
|
+
return integrationReady(body, args.provider);
|
|
302
|
+
}
|
|
303
|
+
async function connectIntegration(args) {
|
|
304
|
+
await waitForOAuthCallback({
|
|
305
|
+
action: `integrations.${args.provider}`,
|
|
306
|
+
io: args.io,
|
|
307
|
+
buildUrl(returnTo) {
|
|
308
|
+
const url = new URL('/integrations', args.cloudUrl);
|
|
309
|
+
url.searchParams.set('provider', args.provider);
|
|
310
|
+
url.searchParams.set('workspace', args.workspaceId);
|
|
311
|
+
url.searchParams.set('return_to', returnTo);
|
|
312
|
+
return url.toString();
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
async function handleExistingPersona(args) {
|
|
317
|
+
const existing = await findExistingAgent(args);
|
|
318
|
+
if (!existing)
|
|
319
|
+
return { cancelled: false };
|
|
320
|
+
const choice = await resolveOnExists(args);
|
|
321
|
+
if (choice === 'cancel') {
|
|
322
|
+
args.io.info(`cloud: deploy cancelled because persona ${args.personaId} already exists`);
|
|
323
|
+
return { cancelled: true, agentId: existing.id };
|
|
324
|
+
}
|
|
325
|
+
if (choice === 'update') {
|
|
326
|
+
args.io.info(`cloud: updating existing persona ${args.personaId}`);
|
|
327
|
+
return { cancelled: false };
|
|
328
|
+
}
|
|
329
|
+
args.io.info(`cloud: destroying existing persona ${args.personaId} before deploy`);
|
|
330
|
+
await deleteAgent({
|
|
331
|
+
cloudUrl: args.cloudUrl,
|
|
332
|
+
workspaceId: args.workspaceId,
|
|
333
|
+
token: args.token,
|
|
334
|
+
agentId: existing.id,
|
|
335
|
+
action: 'cloud existing persona destroy'
|
|
336
|
+
});
|
|
337
|
+
return { cancelled: false };
|
|
338
|
+
}
|
|
339
|
+
async function findExistingAgent(args) {
|
|
340
|
+
const url = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent(args.workspaceId)}/agents?persona_slug=${encodeURIComponent(args.personaId)}`;
|
|
341
|
+
const res = await fetch(url, {
|
|
342
|
+
method: 'GET',
|
|
343
|
+
headers: {
|
|
344
|
+
authorization: `Bearer ${args.token}`,
|
|
345
|
+
'user-agent': USER_AGENT
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
if (res.status === 404 || res.status === 405)
|
|
349
|
+
return null;
|
|
350
|
+
if (res.status === 401) {
|
|
351
|
+
throw new Error('cloud existing persona check failed: unauthorized. Run `workforce login` and retry.');
|
|
352
|
+
}
|
|
353
|
+
if (!res.ok) {
|
|
354
|
+
throw new Error(`cloud existing persona check failed: ${res.status} ${await responseExcerpt(res)}`);
|
|
355
|
+
}
|
|
356
|
+
return parseExistingAgent((await res.json()));
|
|
357
|
+
}
|
|
358
|
+
async function resolveOnExists(args) {
|
|
359
|
+
if (args.onExists)
|
|
360
|
+
return args.onExists;
|
|
361
|
+
const fromEnv = process.env.WORKFORCE_DEPLOY_ON_EXISTS?.trim();
|
|
362
|
+
if (fromEnv)
|
|
363
|
+
return expectOnExistsChoice(fromEnv);
|
|
364
|
+
if (args.noPrompt) {
|
|
365
|
+
return 'cancel';
|
|
366
|
+
}
|
|
367
|
+
const answer = await args.io.prompt(`Persona ${args.personaId} already exists. Choose update, destroy, or cancel`, { defaultValue: 'cancel' });
|
|
368
|
+
return expectOnExistsChoice(answer);
|
|
369
|
+
}
|
|
370
|
+
async function deleteAgent(args) {
|
|
371
|
+
const destroyUrl = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent(args.workspaceId)}/agents/${encodeURIComponent(args.agentId)}/destroy`;
|
|
372
|
+
const res = await fetch(destroyUrl, {
|
|
373
|
+
method: 'POST',
|
|
374
|
+
headers: {
|
|
375
|
+
authorization: `Bearer ${args.token}`,
|
|
376
|
+
'user-agent': USER_AGENT
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
if (res.status === 401) {
|
|
380
|
+
throw new Error(`${args.action} failed: unauthorized. Run \`workforce login\` and retry.`);
|
|
381
|
+
}
|
|
382
|
+
if (res.status === 404 || res.status === 405) {
|
|
383
|
+
throw new Error(`${args.action} failed: destroy not yet wired; cancel and run with --force-replace later.`);
|
|
384
|
+
}
|
|
385
|
+
if (!res.ok) {
|
|
386
|
+
throw new Error(`${args.action} failed: ${res.status} ${await responseExcerpt(res)}`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function parseExistingAgent(body) {
|
|
390
|
+
const direct = parseAgentLike(body.agent);
|
|
391
|
+
if (direct)
|
|
392
|
+
return direct;
|
|
393
|
+
if (Array.isArray(body.agents)) {
|
|
394
|
+
for (const agent of body.agents) {
|
|
395
|
+
const parsed = parseAgentLike(agent);
|
|
396
|
+
if (parsed)
|
|
397
|
+
return parsed;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
function parseAgentLike(value) {
|
|
403
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
404
|
+
return null;
|
|
405
|
+
const record = value;
|
|
406
|
+
if (typeof record.id !== 'string' || !record.id.trim())
|
|
407
|
+
return null;
|
|
408
|
+
return {
|
|
409
|
+
id: record.id,
|
|
410
|
+
...(typeof record.status === 'string' ? { status: record.status } : {})
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
async function saveProviderCredential(args) {
|
|
414
|
+
await requestJsonWithRetry(`${args.cloudUrl}/api/v1/users/me/provider_credentials`, {
|
|
415
|
+
method: 'POST',
|
|
416
|
+
headers: jsonHeaders(args.token),
|
|
417
|
+
body: JSON.stringify({
|
|
418
|
+
model_provider: args.modelProvider,
|
|
419
|
+
auth_type: args.authType,
|
|
420
|
+
...(args.apiKey ? { api_key: args.apiKey } : {})
|
|
421
|
+
})
|
|
422
|
+
}, { action: 'cloud provider credentials save' });
|
|
423
|
+
}
|
|
424
|
+
function deriveModelProvider(persona) {
|
|
425
|
+
const model = typeof persona.model === 'string' ? persona.model.trim() : '';
|
|
426
|
+
const [provider] = model.split(/[/:]/, 1);
|
|
427
|
+
if (provider?.trim())
|
|
428
|
+
return provider.trim();
|
|
429
|
+
return persona.harness;
|
|
430
|
+
}
|
|
431
|
+
function providerCredentialsReady(body) {
|
|
432
|
+
const candidates = [
|
|
433
|
+
body.credential,
|
|
434
|
+
...(Array.isArray(body.credentials) ? body.credentials : []),
|
|
435
|
+
...(Array.isArray(body.providerCredentials) ? body.providerCredentials : []),
|
|
436
|
+
body
|
|
437
|
+
];
|
|
438
|
+
return candidates.some((candidate) => {
|
|
439
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
|
|
440
|
+
return false;
|
|
441
|
+
const record = candidate;
|
|
442
|
+
return record.connected === true
|
|
443
|
+
|| record.status === 'connected'
|
|
444
|
+
|| record.status === 'active'
|
|
445
|
+
|| Boolean(record.credentialStoredAt)
|
|
446
|
+
|| Boolean(record.createdAt)
|
|
447
|
+
|| typeof record.id === 'string';
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function integrationReady(body, provider) {
|
|
451
|
+
const candidates = [
|
|
452
|
+
...(Array.isArray(body.integrations) ? body.integrations : []),
|
|
453
|
+
body
|
|
454
|
+
];
|
|
455
|
+
return candidates.some((candidate) => {
|
|
456
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate))
|
|
457
|
+
return false;
|
|
458
|
+
const record = candidate;
|
|
459
|
+
const recordProvider = typeof record.provider === 'string' ? record.provider : provider;
|
|
460
|
+
if (recordProvider !== provider)
|
|
461
|
+
return false;
|
|
462
|
+
return record.ready === true
|
|
463
|
+
|| record.state === 'ready'
|
|
464
|
+
|| record.state === 'connected'
|
|
465
|
+
|| typeof record.connectionId === 'string'
|
|
466
|
+
|| typeof record.currentConnectionId === 'string';
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
async function waitForOAuthCallback(args) {
|
|
470
|
+
const state = randomUUID();
|
|
471
|
+
await new Promise((resolve, reject) => {
|
|
472
|
+
let settled = false;
|
|
473
|
+
const timeout = setTimeout(() => {
|
|
474
|
+
settleError(new Error(`timed out waiting for ${args.action} OAuth callback`));
|
|
475
|
+
}, pollTimeoutMs()).unref();
|
|
476
|
+
const server = createServer((request, response) => {
|
|
477
|
+
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
478
|
+
if (requestUrl.pathname !== '/callback') {
|
|
479
|
+
response.statusCode = 404;
|
|
480
|
+
response.end('not found');
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (requestUrl.searchParams.get('state') !== state) {
|
|
484
|
+
response.statusCode = 400;
|
|
485
|
+
response.end('invalid state');
|
|
486
|
+
settleError(new Error(`${args.action} OAuth callback returned an invalid state`));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const error = requestUrl.searchParams.get('error');
|
|
490
|
+
if (error) {
|
|
491
|
+
response.statusCode = 400;
|
|
492
|
+
response.end('OAuth failed');
|
|
493
|
+
settleError(new Error(error));
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
response.statusCode = 200;
|
|
497
|
+
response.end('workforce OAuth complete; you can close this tab');
|
|
498
|
+
settleOk();
|
|
499
|
+
});
|
|
500
|
+
function settleOk() {
|
|
501
|
+
if (settled)
|
|
502
|
+
return;
|
|
503
|
+
settled = true;
|
|
504
|
+
clearTimeout(timeout);
|
|
505
|
+
server.close();
|
|
506
|
+
resolve();
|
|
507
|
+
}
|
|
508
|
+
function settleError(error) {
|
|
509
|
+
if (settled)
|
|
510
|
+
return;
|
|
511
|
+
settled = true;
|
|
512
|
+
clearTimeout(timeout);
|
|
513
|
+
server.close();
|
|
514
|
+
reject(error);
|
|
515
|
+
}
|
|
516
|
+
server.on('error', settleError);
|
|
517
|
+
server.listen(0, '127.0.0.1', () => {
|
|
518
|
+
const address = server.address();
|
|
519
|
+
if (!address || typeof address === 'string') {
|
|
520
|
+
settleError(new Error(`failed to start ${args.action} OAuth callback server`));
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const callback = new URL('/callback', `http://127.0.0.1:${address.port}`);
|
|
524
|
+
callback.searchParams.set('state', state);
|
|
525
|
+
const connectUrl = args.buildUrl(callback.toString());
|
|
526
|
+
args.io.info(`cloud: open ${connectUrl} to finish ${args.action} OAuth`);
|
|
527
|
+
tryOpenBrowser(connectUrl);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
function expectHarnessSource(value) {
|
|
532
|
+
const normalized = value.trim().toLowerCase();
|
|
533
|
+
if (normalized === 'plan' || normalized === 'byok' || normalized === 'oauth') {
|
|
534
|
+
return normalized;
|
|
535
|
+
}
|
|
536
|
+
throw new Error(`cloud: harness source must be one of plan|byok|oauth; got "${value}"`);
|
|
537
|
+
}
|
|
538
|
+
function expectOnExistsChoice(value) {
|
|
539
|
+
const normalized = value.trim().toLowerCase();
|
|
540
|
+
if (normalized === 'update' || normalized === 'destroy' || normalized === 'cancel') {
|
|
541
|
+
return normalized;
|
|
542
|
+
}
|
|
543
|
+
throw new Error(`cloud: on-exists must be one of update|destroy|cancel; got "${value}"`);
|
|
544
|
+
}
|
|
545
|
+
function readFirstString(body, fields) {
|
|
546
|
+
const record = body;
|
|
547
|
+
for (const field of fields) {
|
|
548
|
+
const value = record[field];
|
|
549
|
+
if (typeof value === 'string' && value.trim()) {
|
|
550
|
+
return value.trim();
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return undefined;
|
|
554
|
+
}
|
|
555
|
+
async function pollUntil(check, timeoutMessage) {
|
|
556
|
+
const deadline = Date.now() + pollTimeoutMs();
|
|
557
|
+
while (Date.now() < deadline) {
|
|
558
|
+
if (await check())
|
|
559
|
+
return;
|
|
560
|
+
await sleep(pollIntervalMs());
|
|
561
|
+
}
|
|
562
|
+
throw new Error(timeoutMessage);
|
|
563
|
+
}
|
|
564
|
+
function readPersonaCloudDeployUrl(persona) {
|
|
565
|
+
const cloud = persona.cloud;
|
|
566
|
+
if (cloud !== null && typeof cloud === 'object' && 'deployUrl' in cloud) {
|
|
567
|
+
const deployUrl = cloud.deployUrl;
|
|
568
|
+
if (typeof deployUrl === 'string' && deployUrl.trim()) {
|
|
569
|
+
return deployUrl.trim();
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
function normalizeCloudUrl(url) {
|
|
575
|
+
const trimmed = url.trim();
|
|
576
|
+
if (!trimmed)
|
|
577
|
+
return DEFAULT_CLOUD_URL;
|
|
578
|
+
return trimmed.replace(/\/+$/, '');
|
|
579
|
+
}
|
|
580
|
+
function readInputsOverride() {
|
|
581
|
+
const raw = process.env.WORKFORCE_DEPLOY_INPUTS_JSON?.trim();
|
|
582
|
+
if (!raw)
|
|
583
|
+
return undefined;
|
|
584
|
+
let parsed;
|
|
585
|
+
try {
|
|
586
|
+
parsed = JSON.parse(raw);
|
|
587
|
+
}
|
|
588
|
+
catch {
|
|
589
|
+
throw new Error('WORKFORCE_DEPLOY_INPUTS_JSON is not valid JSON');
|
|
590
|
+
}
|
|
591
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
592
|
+
throw new Error('WORKFORCE_DEPLOY_INPUTS_JSON must be a JSON object');
|
|
593
|
+
}
|
|
594
|
+
const out = {};
|
|
595
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
596
|
+
if (typeof value !== 'string') {
|
|
597
|
+
throw new Error(`WORKFORCE_DEPLOY_INPUTS_JSON.${key} must be a string`);
|
|
598
|
+
}
|
|
599
|
+
out[key] = value;
|
|
600
|
+
}
|
|
601
|
+
return out;
|
|
602
|
+
}
|
|
603
|
+
async function pollAgentStatus(args) {
|
|
604
|
+
const statusUrl = `${args.cloudUrl}/api/v1/workspaces/${encodeURIComponent(args.workspaceId)}/agents/${encodeURIComponent(args.agentId)}`;
|
|
605
|
+
const deadline = Date.now() + pollTimeoutMs();
|
|
606
|
+
let lastStatus = 'starting';
|
|
607
|
+
while (Date.now() < deadline) {
|
|
608
|
+
await sleep(pollIntervalMs());
|
|
609
|
+
const body = await requestJsonWithRetry(statusUrl, {
|
|
610
|
+
method: 'GET',
|
|
611
|
+
headers: {
|
|
612
|
+
authorization: `Bearer ${args.token}`,
|
|
613
|
+
'user-agent': USER_AGENT
|
|
614
|
+
}
|
|
615
|
+
}, { action: 'cloud status poll' });
|
|
616
|
+
const status = expectStatus(body.status);
|
|
617
|
+
if (status !== lastStatus) {
|
|
618
|
+
emitLog(args, `cloud: status ${status}`);
|
|
619
|
+
lastStatus = status;
|
|
620
|
+
}
|
|
621
|
+
if (status === 'active' || status === 'failed')
|
|
622
|
+
return status;
|
|
623
|
+
}
|
|
624
|
+
throw new Error(`timed out after ${pollTimeoutMs() / 1000}s waiting for agent ${args.agentId}`);
|
|
625
|
+
}
|
|
626
|
+
function jsonHeaders(token) {
|
|
627
|
+
return {
|
|
628
|
+
authorization: `Bearer ${token}`,
|
|
629
|
+
'content-type': 'application/json',
|
|
630
|
+
'user-agent': USER_AGENT
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
async function requestJsonWithRetry(url, init, opts) {
|
|
634
|
+
let lastError;
|
|
635
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
636
|
+
try {
|
|
637
|
+
const res = await fetch(url, init);
|
|
638
|
+
if (res.status === 401) {
|
|
639
|
+
throw new Error(`${opts.action} failed: unauthorized. Run \`workforce login\` and retry.`);
|
|
640
|
+
}
|
|
641
|
+
if (res.status >= 500 && attempt < MAX_ATTEMPTS) {
|
|
642
|
+
lastError = new Error(`${opts.action} failed: ${res.status} ${await responseExcerpt(res)}`);
|
|
643
|
+
}
|
|
644
|
+
else if (!res.ok) {
|
|
645
|
+
throw new Error(`${opts.action} failed: ${res.status} ${await responseExcerpt(res)}`);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
return (await res.json());
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
lastError = err;
|
|
653
|
+
if (attempt === MAX_ATTEMPTS || !isRetryableError(err)) {
|
|
654
|
+
throw err;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
await sleep(backoffMs(attempt));
|
|
658
|
+
}
|
|
659
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
660
|
+
}
|
|
661
|
+
function isRetryableError(err) {
|
|
662
|
+
if (!(err instanceof Error))
|
|
663
|
+
return false;
|
|
664
|
+
if (err.message.includes('unauthorized'))
|
|
665
|
+
return false;
|
|
666
|
+
if (err.message.includes('failed: 4'))
|
|
667
|
+
return false;
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
function backoffMs(attempt) {
|
|
671
|
+
const override = numberFromEnv('WORKFORCE_DEPLOY_RETRY_BACKOFF_MS');
|
|
672
|
+
return override ?? attempt * 500;
|
|
673
|
+
}
|
|
674
|
+
function sleep(ms) {
|
|
675
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
676
|
+
}
|
|
677
|
+
function emitLog(args, line) {
|
|
678
|
+
args.onLog?.(line);
|
|
679
|
+
args.io.info(line);
|
|
680
|
+
}
|
|
681
|
+
function tryOpenBrowser(url) {
|
|
682
|
+
const command = platform() === 'darwin'
|
|
683
|
+
? 'open'
|
|
684
|
+
: platform() === 'win32'
|
|
685
|
+
? 'cmd'
|
|
686
|
+
: 'xdg-open';
|
|
687
|
+
const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
688
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
689
|
+
child.on('error', () => {
|
|
690
|
+
// URL is printed; browser launch is best-effort.
|
|
691
|
+
});
|
|
692
|
+
child.unref();
|
|
693
|
+
}
|
|
694
|
+
function pollTimeoutMs() {
|
|
695
|
+
return numberFromEnv('WORKFORCE_DEPLOY_POLL_TIMEOUT_MS') ?? POLL_TIMEOUT_MS;
|
|
696
|
+
}
|
|
697
|
+
function pollIntervalMs() {
|
|
698
|
+
return numberFromEnv('WORKFORCE_DEPLOY_POLL_INTERVAL_MS') ?? POLL_INTERVAL_MS;
|
|
699
|
+
}
|
|
700
|
+
function numberFromEnv(name) {
|
|
701
|
+
const raw = process.env[name]?.trim();
|
|
702
|
+
if (!raw)
|
|
703
|
+
return undefined;
|
|
704
|
+
const parsed = Number(raw);
|
|
705
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
706
|
+
}
|
|
707
|
+
async function responseExcerpt(res) {
|
|
708
|
+
const text = await res.text().catch(() => '');
|
|
709
|
+
return text.trim().slice(0, 500);
|
|
710
|
+
}
|
|
711
|
+
function expectString(value, field) {
|
|
712
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
713
|
+
throw new Error(`cloud deploy response missing ${field}`);
|
|
714
|
+
}
|
|
715
|
+
return value;
|
|
716
|
+
}
|
|
717
|
+
function expectStatus(value) {
|
|
718
|
+
if (value === 'starting' || value === 'active' || value === 'failed' || value === 'cancelled') {
|
|
719
|
+
return value;
|
|
720
|
+
}
|
|
721
|
+
throw new Error(`cloud deploy response has unknown status "${String(value)}"`);
|
|
722
|
+
}
|
|
21
723
|
//# sourceMappingURL=cloud.js.map
|