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