@hasna/loops 0.4.0 → 0.4.2
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/CHANGELOG.md +18 -0
- package/README.md +75 -33
- package/dist/api/index.d.ts +17 -0
- package/dist/api/index.js +1290 -0
- package/dist/cli/index.js +738 -201
- package/dist/daemon/index.js +64 -15
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1005 -155
- package/dist/lib/format.d.ts +3 -1
- package/dist/lib/mode.d.ts +48 -0
- package/dist/lib/mode.js +276 -0
- package/dist/lib/route/todos-cli.d.ts +1 -0
- package/dist/lib/route/types.d.ts +10 -0
- package/dist/lib/storage/contract.d.ts +133 -0
- package/dist/lib/storage/contract.js +1 -0
- package/dist/lib/storage/index.d.ts +6 -0
- package/dist/lib/storage/index.js +4612 -0
- package/dist/lib/storage/postgres-schema.d.ts +4 -0
- package/dist/lib/storage/postgres-schema.js +328 -0
- package/dist/lib/storage/postgres.d.ts +22 -0
- package/dist/lib/storage/postgres.js +423 -0
- package/dist/lib/storage/sqlite.d.ts +84 -0
- package/dist/lib/storage/sqlite.js +4187 -0
- package/dist/lib/store.d.ts +3 -0
- package/dist/lib/store.js +27 -10
- package/dist/lib/template-kit.d.ts +10 -8
- package/dist/lib/templates.d.ts +1 -0
- package/dist/mcp/index.js +64 -15
- package/dist/runner/index.d.ts +32 -0
- package/dist/runner/index.js +2037 -0
- package/dist/sdk/index.js +33 -15
- package/dist/types.d.ts +1 -1
- package/docs/DEPLOYMENT_MODES.md +148 -0
- package/docs/USAGE.md +66 -32
- package/package.json +34 -3
|
@@ -0,0 +1,2037 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
// package.json
|
|
4
|
+
var package_default = {
|
|
5
|
+
name: "@hasna/loops",
|
|
6
|
+
version: "0.4.2",
|
|
7
|
+
description: "Persistent local loop and workflow runner for deterministic commands and headless AI coding agents",
|
|
8
|
+
type: "module",
|
|
9
|
+
main: "dist/index.js",
|
|
10
|
+
types: "dist/index.d.ts",
|
|
11
|
+
bin: {
|
|
12
|
+
loops: "dist/cli/index.js",
|
|
13
|
+
"loops-daemon": "dist/daemon/index.js",
|
|
14
|
+
"loops-api": "dist/api/index.js",
|
|
15
|
+
"loops-runner": "dist/runner/index.js",
|
|
16
|
+
"loops-mcp": "dist/mcp/index.js"
|
|
17
|
+
},
|
|
18
|
+
exports: {
|
|
19
|
+
".": {
|
|
20
|
+
types: "./dist/index.d.ts",
|
|
21
|
+
import: "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./sdk": {
|
|
24
|
+
types: "./dist/sdk/index.d.ts",
|
|
25
|
+
import: "./dist/sdk/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./mcp": {
|
|
28
|
+
types: "./dist/mcp/index.d.ts",
|
|
29
|
+
import: "./dist/mcp/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./api": {
|
|
32
|
+
types: "./dist/api/index.d.ts",
|
|
33
|
+
import: "./dist/api/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./runner": {
|
|
36
|
+
types: "./dist/runner/index.d.ts",
|
|
37
|
+
import: "./dist/runner/index.js"
|
|
38
|
+
},
|
|
39
|
+
"./mode": {
|
|
40
|
+
types: "./dist/lib/mode.d.ts",
|
|
41
|
+
import: "./dist/lib/mode.js"
|
|
42
|
+
},
|
|
43
|
+
"./storage": {
|
|
44
|
+
types: "./dist/lib/store.d.ts",
|
|
45
|
+
import: "./dist/lib/store.js"
|
|
46
|
+
},
|
|
47
|
+
"./storage/contract": {
|
|
48
|
+
types: "./dist/lib/storage/contract.d.ts",
|
|
49
|
+
import: "./dist/lib/storage/contract.js"
|
|
50
|
+
},
|
|
51
|
+
"./storage/sqlite": {
|
|
52
|
+
types: "./dist/lib/storage/sqlite.d.ts",
|
|
53
|
+
import: "./dist/lib/storage/sqlite.js"
|
|
54
|
+
},
|
|
55
|
+
"./storage/postgres": {
|
|
56
|
+
types: "./dist/lib/storage/postgres.d.ts",
|
|
57
|
+
import: "./dist/lib/storage/postgres.js"
|
|
58
|
+
},
|
|
59
|
+
"./storage/postgres-schema": {
|
|
60
|
+
types: "./dist/lib/storage/postgres-schema.d.ts",
|
|
61
|
+
import: "./dist/lib/storage/postgres-schema.js"
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
files: [
|
|
65
|
+
"dist",
|
|
66
|
+
"README.md",
|
|
67
|
+
"CHANGELOG.md",
|
|
68
|
+
"docs",
|
|
69
|
+
"LICENSE"
|
|
70
|
+
],
|
|
71
|
+
scripts: {
|
|
72
|
+
build: "rm -rf dist && bun build src/cli/index.ts src/daemon/index.ts src/api/index.ts src/runner/index.ts src/mcp/index.ts src/index.ts src/sdk/index.ts src/lib/store.ts src/lib/mode.ts src/lib/storage/index.ts src/lib/storage/contract.ts src/lib/storage/sqlite.ts src/lib/storage/postgres.ts src/lib/storage/postgres-schema.ts --root src --outdir dist --target bun --packages external && chmod +x dist/cli/index.js dist/daemon/index.js dist/api/index.js dist/runner/index.js dist/mcp/index.js && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
|
|
73
|
+
"build:bin": "bun build src/cli/index.ts --compile --outfile dist/loops",
|
|
74
|
+
typecheck: "tsc --noEmit",
|
|
75
|
+
test: "bun test",
|
|
76
|
+
"test:boundary": "bun run scripts/no-private-cloud-boundary.mjs",
|
|
77
|
+
prepare: "test -d dist || bun run build",
|
|
78
|
+
"dev:cli": "bun run src/cli/index.ts",
|
|
79
|
+
"dev:daemon": "bun run src/daemon/index.ts",
|
|
80
|
+
prepublishOnly: "bun run build && bun run test:boundary"
|
|
81
|
+
},
|
|
82
|
+
keywords: [
|
|
83
|
+
"loops",
|
|
84
|
+
"scheduler",
|
|
85
|
+
"daemon",
|
|
86
|
+
"agents",
|
|
87
|
+
"claude",
|
|
88
|
+
"cursor",
|
|
89
|
+
"codewith",
|
|
90
|
+
"opencode",
|
|
91
|
+
"cli"
|
|
92
|
+
],
|
|
93
|
+
repository: {
|
|
94
|
+
type: "git",
|
|
95
|
+
url: "git+https://github.com/hasna/loops.git"
|
|
96
|
+
},
|
|
97
|
+
homepage: "https://github.com/hasna/loops#readme",
|
|
98
|
+
bugs: {
|
|
99
|
+
url: "https://github.com/hasna/loops/issues"
|
|
100
|
+
},
|
|
101
|
+
author: "Hasna <andrei@hasna.com>",
|
|
102
|
+
license: "Apache-2.0",
|
|
103
|
+
engines: {
|
|
104
|
+
bun: ">=1.0.0"
|
|
105
|
+
},
|
|
106
|
+
dependencies: {
|
|
107
|
+
"@hasna/events": "^0.1.9",
|
|
108
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
109
|
+
"@openrouter/ai-sdk-provider": "2.9.1",
|
|
110
|
+
ai: "6.0.204",
|
|
111
|
+
commander: "^13.1.0",
|
|
112
|
+
zod: "4.4.3"
|
|
113
|
+
},
|
|
114
|
+
optionalDependencies: {
|
|
115
|
+
"@hasna/machines": "0.0.49"
|
|
116
|
+
},
|
|
117
|
+
devDependencies: {
|
|
118
|
+
"@types/bun": "latest",
|
|
119
|
+
typescript: "^5.7.3"
|
|
120
|
+
},
|
|
121
|
+
publishConfig: {
|
|
122
|
+
registry: "https://registry.npmjs.org",
|
|
123
|
+
access: "public"
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/lib/version.ts
|
|
128
|
+
function packageVersion() {
|
|
129
|
+
if (typeof package_default.version !== "string" || package_default.version.trim() === "")
|
|
130
|
+
throw new Error("package.json version is missing");
|
|
131
|
+
return package_default.version;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/lib/mode.ts
|
|
135
|
+
var LOOP_DEPLOYMENT_MODES = ["local", "self_hosted", "cloud"];
|
|
136
|
+
var MODE_ENV_KEYS = ["LOOPS_MODE", "HASNA_LOOPS_MODE"];
|
|
137
|
+
var API_URL_ENV_KEYS = ["LOOPS_API_URL", "HASNA_LOOPS_API_URL"];
|
|
138
|
+
var CLOUD_API_URL_ENV_KEYS = ["LOOPS_CLOUD_API_URL", "HASNA_LOOPS_CLOUD_API_URL"];
|
|
139
|
+
var DATABASE_URL_ENV_KEYS = ["LOOPS_DATABASE_URL", "HASNA_LOOPS_DATABASE_URL"];
|
|
140
|
+
var API_TOKEN_ENV_KEYS = ["LOOPS_API_TOKEN", "HASNA_LOOPS_API_TOKEN"];
|
|
141
|
+
var CLOUD_TOKEN_ENV_KEYS = ["LOOPS_CLOUD_TOKEN", "HASNA_LOOPS_CLOUD_TOKEN"];
|
|
142
|
+
var TOKEN_ENV_KEYS = [...API_TOKEN_ENV_KEYS, ...CLOUD_TOKEN_ENV_KEYS];
|
|
143
|
+
function envValue(env, keys) {
|
|
144
|
+
for (const key of keys) {
|
|
145
|
+
const value = env[key]?.trim();
|
|
146
|
+
if (value)
|
|
147
|
+
return { key, value };
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
function normalizeLoopDeploymentMode(value) {
|
|
152
|
+
const normalized = value.trim().toLowerCase().replace(/-/g, "_");
|
|
153
|
+
if (normalized === "local")
|
|
154
|
+
return "local";
|
|
155
|
+
if (normalized === "self_hosted" || normalized === "selfhosted")
|
|
156
|
+
return "self_hosted";
|
|
157
|
+
if (normalized === "cloud" || normalized === "saas")
|
|
158
|
+
return "cloud";
|
|
159
|
+
throw new Error(`unsupported OpenLoops deployment mode "${value}"; expected local, self_hosted, or cloud`);
|
|
160
|
+
}
|
|
161
|
+
function resolveLoopDeploymentMode(env = process.env) {
|
|
162
|
+
const explicitMode = envValue(env, MODE_ENV_KEYS);
|
|
163
|
+
if (explicitMode) {
|
|
164
|
+
return {
|
|
165
|
+
deploymentMode: normalizeLoopDeploymentMode(explicitMode.value),
|
|
166
|
+
source: explicitMode.key
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const cloudApiUrl = envValue(env, CLOUD_API_URL_ENV_KEYS);
|
|
170
|
+
if (cloudApiUrl)
|
|
171
|
+
return { deploymentMode: "cloud", source: cloudApiUrl.key };
|
|
172
|
+
const apiUrl = envValue(env, API_URL_ENV_KEYS);
|
|
173
|
+
if (apiUrl)
|
|
174
|
+
return { deploymentMode: "self_hosted", source: apiUrl.key };
|
|
175
|
+
const databaseUrl = envValue(env, DATABASE_URL_ENV_KEYS);
|
|
176
|
+
if (databaseUrl)
|
|
177
|
+
return { deploymentMode: "self_hosted", source: databaseUrl.key };
|
|
178
|
+
return { deploymentMode: "local", source: "default" };
|
|
179
|
+
}
|
|
180
|
+
function loopControlPlaneConfig(env = process.env) {
|
|
181
|
+
return {
|
|
182
|
+
apiUrl: envValue(env, API_URL_ENV_KEYS)?.value,
|
|
183
|
+
cloudApiUrl: envValue(env, CLOUD_API_URL_ENV_KEYS)?.value,
|
|
184
|
+
databaseUrlPresent: Boolean(envValue(env, DATABASE_URL_ENV_KEYS)),
|
|
185
|
+
apiAuthTokenPresent: Boolean(envValue(env, API_TOKEN_ENV_KEYS)),
|
|
186
|
+
cloudAuthTokenPresent: Boolean(envValue(env, CLOUD_TOKEN_ENV_KEYS)),
|
|
187
|
+
authTokenPresent: Boolean(envValue(env, TOKEN_ENV_KEYS))
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function sourceOfTruthForMode(mode) {
|
|
191
|
+
if (mode === "local")
|
|
192
|
+
return "local_sqlite";
|
|
193
|
+
if (mode === "self_hosted")
|
|
194
|
+
return "self_hosted_control_plane";
|
|
195
|
+
return "cloud_control_plane";
|
|
196
|
+
}
|
|
197
|
+
function displayControlPlaneUrl(value) {
|
|
198
|
+
if (!value)
|
|
199
|
+
return;
|
|
200
|
+
try {
|
|
201
|
+
const url = new URL(value);
|
|
202
|
+
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/+$/g, "");
|
|
203
|
+
return `${url.origin}${path}`;
|
|
204
|
+
} catch {
|
|
205
|
+
return "[invalid-url]";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function buildDeploymentStatus(opts = {}) {
|
|
209
|
+
const env = opts.env ?? process.env;
|
|
210
|
+
const active = resolveLoopDeploymentMode(env);
|
|
211
|
+
const deploymentMode = opts.perspective ?? active.deploymentMode;
|
|
212
|
+
const config = loopControlPlaneConfig(env);
|
|
213
|
+
const rawApiUrl = deploymentMode === "cloud" ? config.cloudApiUrl : config.apiUrl;
|
|
214
|
+
const apiUrl = displayControlPlaneUrl(rawApiUrl);
|
|
215
|
+
const controlPlaneKind = deploymentMode === "local" ? "none" : deploymentMode;
|
|
216
|
+
const deploymentAuthTokenPresent = deploymentMode === "cloud" ? config.cloudAuthTokenPresent : deploymentMode === "self_hosted" ? config.apiAuthTokenPresent : false;
|
|
217
|
+
const controlPlaneConfigured = deploymentMode === "local" ? false : deploymentMode === "self_hosted" ? Boolean(config.apiUrl || config.databaseUrlPresent) : Boolean(config.cloudApiUrl && deploymentAuthTokenPresent);
|
|
218
|
+
const warnings = [];
|
|
219
|
+
if (deploymentMode === "self_hosted" && !controlPlaneConfigured) {
|
|
220
|
+
warnings.push("self_hosted mode needs LOOPS_API_URL or LOOPS_DATABASE_URL before it can become authoritative");
|
|
221
|
+
}
|
|
222
|
+
if (deploymentMode === "cloud" && config.apiUrl && !config.cloudApiUrl) {
|
|
223
|
+
warnings.push("LOOPS_API_URL selects self_hosted; cloud mode uses LOOPS_CLOUD_API_URL");
|
|
224
|
+
}
|
|
225
|
+
if (deploymentMode === "cloud" && !config.cloudApiUrl) {
|
|
226
|
+
warnings.push("cloud mode is a public contract until LOOPS_CLOUD_API_URL is configured");
|
|
227
|
+
}
|
|
228
|
+
if (deploymentMode === "cloud" && config.cloudApiUrl && !deploymentAuthTokenPresent) {
|
|
229
|
+
warnings.push("cloud mode needs LOOPS_CLOUD_TOKEN or HASNA_LOOPS_CLOUD_TOKEN before it can become ready");
|
|
230
|
+
}
|
|
231
|
+
if (opts.perspective && opts.perspective !== active.deploymentMode) {
|
|
232
|
+
warnings.push(`active deployment mode is ${active.deploymentMode}; this is the ${opts.perspective} perspective`);
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
packageVersion: packageVersion(),
|
|
236
|
+
deploymentMode,
|
|
237
|
+
activeDeploymentMode: active.deploymentMode,
|
|
238
|
+
active: deploymentMode === active.deploymentMode,
|
|
239
|
+
deploymentModeSource: active.source,
|
|
240
|
+
sourceOfTruth: sourceOfTruthForMode(deploymentMode),
|
|
241
|
+
localStore: {
|
|
242
|
+
role: deploymentMode === "local" ? "authoritative" : "cache_and_spool"
|
|
243
|
+
},
|
|
244
|
+
controlPlane: {
|
|
245
|
+
kind: controlPlaneKind,
|
|
246
|
+
configured: controlPlaneConfigured,
|
|
247
|
+
apiUrl,
|
|
248
|
+
databaseUrlPresent: config.databaseUrlPresent,
|
|
249
|
+
authTokenPresent: deploymentAuthTokenPresent
|
|
250
|
+
},
|
|
251
|
+
runner: {
|
|
252
|
+
required: deploymentMode !== "local",
|
|
253
|
+
role: deploymentMode === "local" ? "daemon" : "control_plane_worker"
|
|
254
|
+
},
|
|
255
|
+
warnings
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function deploymentStatusLine(status) {
|
|
259
|
+
const configured = status.controlPlane.kind === "none" ? "none" : String(status.controlPlane.configured);
|
|
260
|
+
const active = status.active ? "active" : `active=${status.activeDeploymentMode}`;
|
|
261
|
+
return [
|
|
262
|
+
`deploymentMode=${status.deploymentMode}`,
|
|
263
|
+
active,
|
|
264
|
+
`source=${status.deploymentModeSource}`,
|
|
265
|
+
`truth=${status.sourceOfTruth}`,
|
|
266
|
+
`local=${status.localStore.role}`,
|
|
267
|
+
`control_plane=${configured}`
|
|
268
|
+
].join(" ");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/runner/index.ts
|
|
272
|
+
import { Command } from "commander";
|
|
273
|
+
|
|
274
|
+
// src/lib/executor.ts
|
|
275
|
+
import { spawn as spawn2, spawnSync as spawnSync2 } from "child_process";
|
|
276
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
277
|
+
import { once } from "events";
|
|
278
|
+
import { lstatSync, mkdirSync, realpathSync } from "fs";
|
|
279
|
+
import { dirname, resolve } from "path";
|
|
280
|
+
|
|
281
|
+
// src/lib/accounts.ts
|
|
282
|
+
import { spawnSync } from "child_process";
|
|
283
|
+
import { existsSync } from "fs";
|
|
284
|
+
|
|
285
|
+
// src/lib/agent-adapter.ts
|
|
286
|
+
import { spawn } from "child_process";
|
|
287
|
+
|
|
288
|
+
// src/lib/redact.ts
|
|
289
|
+
var SCRUBBED = "[SCRUBBED]";
|
|
290
|
+
var TOKEN_PATTERNS = [
|
|
291
|
+
/\bsk-ant-[A-Za-z0-9_-]{8,}/g,
|
|
292
|
+
/\bsk-proj-[A-Za-z0-9_-]{8,}/g,
|
|
293
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
294
|
+
/\bghp_[A-Za-z0-9]{16,}\b/g,
|
|
295
|
+
/\bgithub_pat_[A-Za-z0-9_]{16,}\b/g,
|
|
296
|
+
/\bxox[a-z]-[A-Za-z0-9-]{8,}/g,
|
|
297
|
+
/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{10,}\b/g
|
|
298
|
+
];
|
|
299
|
+
var AUTHORIZATION_PATTERN = /(\bAuthorization(?:\\?["'])?\s*[:=]\s*(?:\\?["'])?(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{16,}/gi;
|
|
300
|
+
var PEM_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g;
|
|
301
|
+
var ASSIGNMENT_PATTERN = /([A-Za-z0-9_.-]{0,64}(?:key|token|secret|passwd|password|passphrase|credentials?)(?:\\?["'])?\s*[:=]\s*)(\\"([^"\\\r\n]{12,})\\"|"((?:[^"\\\r\n]|\\[^\r\n]){12,})"|'([^'\\\r\n]{12,})'|([A-Za-z0-9+/_.=-]{16,}))/gi;
|
|
302
|
+
var BENIGN_KEY_NAME_PATTERN = /(?:^|[._-])(?:idempotency|dedupe|route)[_-]?key$/i;
|
|
303
|
+
var ASSIGNMENT_HINTS = ["key", "token", "secret", "pass", "credential"];
|
|
304
|
+
function mayContainAssignment(text) {
|
|
305
|
+
const lowered = text.toLowerCase();
|
|
306
|
+
return ASSIGNMENT_HINTS.some((hint) => lowered.includes(hint));
|
|
307
|
+
}
|
|
308
|
+
function shannonEntropy(value) {
|
|
309
|
+
const counts = new Map;
|
|
310
|
+
for (const char of value)
|
|
311
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
312
|
+
let entropy = 0;
|
|
313
|
+
for (const count of counts.values()) {
|
|
314
|
+
const probability = count / value.length;
|
|
315
|
+
entropy -= probability * Math.log2(probability);
|
|
316
|
+
}
|
|
317
|
+
return entropy;
|
|
318
|
+
}
|
|
319
|
+
function isLikelySecretValue(value) {
|
|
320
|
+
if (value.length < 12)
|
|
321
|
+
return false;
|
|
322
|
+
if (value.includes(SCRUBBED))
|
|
323
|
+
return false;
|
|
324
|
+
if (value.startsWith("/") || value.startsWith("~") || value.startsWith("./"))
|
|
325
|
+
return false;
|
|
326
|
+
return shannonEntropy(value) >= 3.2;
|
|
327
|
+
}
|
|
328
|
+
function scrubSecrets(text) {
|
|
329
|
+
if (!text)
|
|
330
|
+
return text;
|
|
331
|
+
let scrubbed = text;
|
|
332
|
+
for (const pattern of TOKEN_PATTERNS)
|
|
333
|
+
scrubbed = scrubbed.replace(pattern, SCRUBBED);
|
|
334
|
+
scrubbed = scrubbed.replace(AUTHORIZATION_PATTERN, `$1${SCRUBBED}`);
|
|
335
|
+
scrubbed = scrubbed.replace(PEM_PATTERN, SCRUBBED);
|
|
336
|
+
if (!mayContainAssignment(scrubbed))
|
|
337
|
+
return scrubbed;
|
|
338
|
+
scrubbed = scrubbed.replace(ASSIGNMENT_PATTERN, (match, prefix, quotedValue, escapedQuoted, doubleQuoted, singleQuoted, bare) => {
|
|
339
|
+
const value = escapedQuoted ?? doubleQuoted ?? singleQuoted ?? bare ?? "";
|
|
340
|
+
const keyName = /^[A-Za-z0-9_.-]*/.exec(prefix)?.[0] ?? "";
|
|
341
|
+
if (BENIGN_KEY_NAME_PATTERN.test(keyName))
|
|
342
|
+
return match;
|
|
343
|
+
if (!isLikelySecretValue(value))
|
|
344
|
+
return match;
|
|
345
|
+
const quote = quotedValue.startsWith("\\\"") ? "\\\"" : quotedValue.startsWith('"') ? '"' : quotedValue.startsWith("'") ? "'" : "";
|
|
346
|
+
return `${prefix}${quote}${SCRUBBED}${quote}`;
|
|
347
|
+
});
|
|
348
|
+
return scrubbed;
|
|
349
|
+
}
|
|
350
|
+
function scrubSecretsDeep(value) {
|
|
351
|
+
if (typeof value === "string")
|
|
352
|
+
return scrubSecrets(value);
|
|
353
|
+
if (Array.isArray(value))
|
|
354
|
+
return value.map((entry) => scrubSecretsDeep(entry));
|
|
355
|
+
if (value !== null && typeof value === "object") {
|
|
356
|
+
const toJSON = value.toJSON;
|
|
357
|
+
if (typeof toJSON === "function")
|
|
358
|
+
return scrubSecretsDeep(toJSON.call(value));
|
|
359
|
+
const scrubbed = {};
|
|
360
|
+
for (const [key, entry] of Object.entries(value))
|
|
361
|
+
scrubbed[key] = scrubSecretsDeep(entry);
|
|
362
|
+
return scrubbed;
|
|
363
|
+
}
|
|
364
|
+
return value;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/lib/agent-adapter.ts
|
|
368
|
+
var UNSAFE_CODEWITH_DURABLE_EXTRA_ARGS = new Set([
|
|
369
|
+
"e",
|
|
370
|
+
"exec",
|
|
371
|
+
"agent",
|
|
372
|
+
"start",
|
|
373
|
+
"--ephemeral",
|
|
374
|
+
"--ignore-rules",
|
|
375
|
+
"--skip-git-repo-check",
|
|
376
|
+
"--json",
|
|
377
|
+
"--output-last-message",
|
|
378
|
+
"-o",
|
|
379
|
+
"--output-schema",
|
|
380
|
+
"--dangerously-bypass-approvals-and-sandbox"
|
|
381
|
+
]);
|
|
382
|
+
var CODEX_LIKE_SANDBOXES = ["read-only", "workspace-write", "danger-full-access"];
|
|
383
|
+
var CURSOR_SANDBOXES = ["enabled", "disabled"];
|
|
384
|
+
var PERMISSION_MODES = ["default", "plan", "auto", "bypass"];
|
|
385
|
+
function assertOptionalNonEmptyString(value, label) {
|
|
386
|
+
if (value === undefined)
|
|
387
|
+
return;
|
|
388
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
389
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
390
|
+
}
|
|
391
|
+
function validateAgentOptions(target, label, capabilities) {
|
|
392
|
+
const provider = target.provider;
|
|
393
|
+
if (typeof target.prompt !== "string" || target.prompt.trim() === "") {
|
|
394
|
+
throw new Error(`${label}.prompt must be a non-empty string`);
|
|
395
|
+
}
|
|
396
|
+
assertOptionalNonEmptyString(target.model, `${label}.model`);
|
|
397
|
+
assertOptionalNonEmptyString(target.variant, `${label}.variant`);
|
|
398
|
+
assertOptionalNonEmptyString(target.agent, `${label}.agent`);
|
|
399
|
+
assertOptionalNonEmptyString(target.authProfile, `${label}.authProfile`);
|
|
400
|
+
assertOptionalNonEmptyString(target.configIsolation, `${label}.configIsolation`);
|
|
401
|
+
if (target.configIsolation !== undefined && target.configIsolation !== "safe" && target.configIsolation !== "none") {
|
|
402
|
+
throw new Error(`${label}.configIsolation must be safe or none`);
|
|
403
|
+
}
|
|
404
|
+
if (target.authProfile !== undefined && provider !== "codewith") {
|
|
405
|
+
throw new Error(`${label}.authProfile is currently supported only for provider codewith`);
|
|
406
|
+
}
|
|
407
|
+
if (provider === "opencode" && (typeof target.model !== "string" || target.model.trim() === "")) {
|
|
408
|
+
throw new Error(`${label}.model is required for provider opencode; pass a provider/model id such as openrouter/google/gemini-2.5-flash`);
|
|
409
|
+
}
|
|
410
|
+
if (provider === "cursor" && target.variant !== undefined) {
|
|
411
|
+
throw new Error(`${label}.variant is not supported for provider cursor`);
|
|
412
|
+
}
|
|
413
|
+
if (provider === "codex" && target.agent !== undefined) {
|
|
414
|
+
throw new Error(`${label}.agent is not supported for provider codex`);
|
|
415
|
+
}
|
|
416
|
+
if (provider === "codewith" && target.agent !== undefined) {
|
|
417
|
+
throw new Error(`${label}.agent is not supported for provider codewith durable background-agent execution`);
|
|
418
|
+
}
|
|
419
|
+
if (provider === "codewith") {
|
|
420
|
+
const unsafe = target.extraArgs?.find((arg) => UNSAFE_CODEWITH_DURABLE_EXTRA_ARGS.has(arg));
|
|
421
|
+
if (unsafe) {
|
|
422
|
+
throw new Error(`${label}.extraArgs cannot include ${unsafe}; codewith agent steps use durable agent start, not exec/ephemeral flags`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (target.addDirs?.length && !["codewith", "codex"].includes(provider)) {
|
|
426
|
+
throw new Error(`${label}.addDirs is currently supported only for provider codewith or codex`);
|
|
427
|
+
}
|
|
428
|
+
if (target.permissionMode !== undefined) {
|
|
429
|
+
if (!PERMISSION_MODES.includes(target.permissionMode)) {
|
|
430
|
+
throw new Error(`${label}.permissionMode must be one of ${PERMISSION_MODES.join(", ")}`);
|
|
431
|
+
}
|
|
432
|
+
if (target.permissionMode === "plan" && !["claude", "cursor"].includes(provider)) {
|
|
433
|
+
throw new Error(`${label}.permissionMode plan is currently supported only for provider claude or cursor`);
|
|
434
|
+
}
|
|
435
|
+
if (target.permissionMode === "auto" && provider !== "claude") {
|
|
436
|
+
throw new Error(`${label}.permissionMode auto is currently supported only for provider claude`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (target.sandbox !== undefined) {
|
|
440
|
+
if (!capabilities.sandbox.length) {
|
|
441
|
+
throw new Error(`${label}.sandbox is currently supported only for provider codewith, codex, or cursor`);
|
|
442
|
+
}
|
|
443
|
+
if (!capabilities.sandbox.includes(target.sandbox)) {
|
|
444
|
+
throw new Error(`${label}.sandbox must be one of ${capabilities.sandbox.join(", ")}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function codewithLikeSandbox(target) {
|
|
449
|
+
return target.sandbox ?? (target.permissionMode === "bypass" ? "danger-full-access" : "workspace-write");
|
|
450
|
+
}
|
|
451
|
+
function configStringValue(value) {
|
|
452
|
+
return JSON.stringify(value);
|
|
453
|
+
}
|
|
454
|
+
function buildAgentInvocation(target) {
|
|
455
|
+
const isolation = target.configIsolation ?? "safe";
|
|
456
|
+
const permissionMode = target.permissionMode ?? "default";
|
|
457
|
+
const args = [];
|
|
458
|
+
switch (target.provider) {
|
|
459
|
+
case "claude": {
|
|
460
|
+
if (isolation === "safe")
|
|
461
|
+
args.push("--safe-mode", "--setting-sources", "local", "--no-session-persistence");
|
|
462
|
+
if (permissionMode !== "default") {
|
|
463
|
+
const mode = permissionMode === "bypass" ? "bypassPermissions" : permissionMode;
|
|
464
|
+
args.push("--permission-mode", mode);
|
|
465
|
+
}
|
|
466
|
+
args.push("-p", "--output-format", "json");
|
|
467
|
+
if (target.model)
|
|
468
|
+
args.push("--model", target.model);
|
|
469
|
+
if (target.variant)
|
|
470
|
+
args.push("--effort", target.variant);
|
|
471
|
+
if (target.agent)
|
|
472
|
+
args.push("--agent", target.agent);
|
|
473
|
+
args.push(...target.extraArgs ?? []);
|
|
474
|
+
return { command: "claude", args, stdin: target.prompt };
|
|
475
|
+
}
|
|
476
|
+
case "cursor": {
|
|
477
|
+
args.push("-c", [
|
|
478
|
+
"set -eu",
|
|
479
|
+
"if command -v agent >/dev/null 2>&1; then",
|
|
480
|
+
' exec agent "$@"',
|
|
481
|
+
"else",
|
|
482
|
+
" echo 'Executable not found in PATH: agent' >&2",
|
|
483
|
+
" exit 127",
|
|
484
|
+
"fi"
|
|
485
|
+
].join(`
|
|
486
|
+
`), "openloops-cursor", "-p");
|
|
487
|
+
if (permissionMode === "plan")
|
|
488
|
+
args.push("--mode", "plan");
|
|
489
|
+
if (permissionMode === "bypass")
|
|
490
|
+
args.push("--force");
|
|
491
|
+
const cursorSandbox = target.sandbox ?? (isolation === "safe" ? "enabled" : undefined);
|
|
492
|
+
if (cursorSandbox)
|
|
493
|
+
args.push("--sandbox", cursorSandbox);
|
|
494
|
+
if (target.model)
|
|
495
|
+
args.push("--model", target.model);
|
|
496
|
+
if (target.agent)
|
|
497
|
+
args.push("--agent", target.agent);
|
|
498
|
+
args.push(...target.extraArgs ?? []);
|
|
499
|
+
return { command: "sh", args, stdin: target.prompt, preflightAnyOf: ["agent"] };
|
|
500
|
+
}
|
|
501
|
+
case "codewith": {
|
|
502
|
+
args.push(...target.authProfile ? ["--auth-profile", target.authProfile] : []);
|
|
503
|
+
if (target.variant)
|
|
504
|
+
args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
|
|
505
|
+
args.push("--ask-for-approval", "never", "--sandbox", codewithLikeSandbox(target));
|
|
506
|
+
if (target.cwd)
|
|
507
|
+
args.push("--cd", target.cwd);
|
|
508
|
+
for (const dir of target.addDirs ?? [])
|
|
509
|
+
args.push("--add-dir", dir);
|
|
510
|
+
if (target.model)
|
|
511
|
+
args.push("--model", target.model);
|
|
512
|
+
args.push(...target.extraArgs ?? []);
|
|
513
|
+
args.push("agent", "start");
|
|
514
|
+
if (target.cwd)
|
|
515
|
+
args.push("--cwd", target.cwd);
|
|
516
|
+
args.push(target.prompt);
|
|
517
|
+
return { command: "codewith", args };
|
|
518
|
+
}
|
|
519
|
+
case "codex": {
|
|
520
|
+
if (target.variant)
|
|
521
|
+
args.push("-c", `model_reasoning_effort=${configStringValue(target.variant)}`);
|
|
522
|
+
args.push("--ask-for-approval", "never", "exec", "--json", "--ephemeral", "--sandbox", codewithLikeSandbox(target), "--skip-git-repo-check");
|
|
523
|
+
if (isolation === "safe")
|
|
524
|
+
args.push("--ignore-rules");
|
|
525
|
+
if (target.cwd)
|
|
526
|
+
args.push("--cd", target.cwd);
|
|
527
|
+
for (const dir of target.addDirs ?? [])
|
|
528
|
+
args.push("--add-dir", dir);
|
|
529
|
+
if (target.model)
|
|
530
|
+
args.push("--model", target.model);
|
|
531
|
+
args.push(...target.extraArgs ?? []);
|
|
532
|
+
return { command: "codex", args, stdin: target.prompt };
|
|
533
|
+
}
|
|
534
|
+
case "aicopilot":
|
|
535
|
+
case "opencode": {
|
|
536
|
+
args.push("run", "--format", "json");
|
|
537
|
+
if (isolation === "safe")
|
|
538
|
+
args.push("--pure");
|
|
539
|
+
if (permissionMode === "bypass")
|
|
540
|
+
args.push("--dangerously-skip-permissions");
|
|
541
|
+
if (target.cwd)
|
|
542
|
+
args.push("--dir", target.cwd);
|
|
543
|
+
if (target.model)
|
|
544
|
+
args.push("--model", target.model);
|
|
545
|
+
if (target.variant)
|
|
546
|
+
args.push("--variant", target.variant);
|
|
547
|
+
if (target.agent)
|
|
548
|
+
args.push("--agent", target.agent);
|
|
549
|
+
args.push(...target.extraArgs ?? []);
|
|
550
|
+
return { command: target.provider, args, stdin: target.prompt };
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function adapterFor(provider, capabilities) {
|
|
555
|
+
return {
|
|
556
|
+
provider,
|
|
557
|
+
capabilities,
|
|
558
|
+
validate(target, label = provider) {
|
|
559
|
+
validateAgentOptions(target, label, capabilities);
|
|
560
|
+
},
|
|
561
|
+
buildInvocation(target) {
|
|
562
|
+
validateAgentOptions(target, provider, capabilities);
|
|
563
|
+
return buildAgentInvocation(target);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
var PROVIDER_ADAPTERS = {
|
|
568
|
+
claude: adapterFor("claude", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" }),
|
|
569
|
+
cursor: adapterFor("cursor", { sandbox: CURSOR_SANDBOXES, durable: false, remote: true, promptChannel: "stdin" }),
|
|
570
|
+
codewith: adapterFor("codewith", { sandbox: CODEX_LIKE_SANDBOXES, durable: true, remote: false, promptChannel: "argv" }),
|
|
571
|
+
codex: adapterFor("codex", { sandbox: CODEX_LIKE_SANDBOXES, durable: false, remote: true, promptChannel: "stdin" }),
|
|
572
|
+
aicopilot: adapterFor("aicopilot", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" }),
|
|
573
|
+
opencode: adapterFor("opencode", { sandbox: [], durable: false, remote: true, promptChannel: "stdin" })
|
|
574
|
+
};
|
|
575
|
+
var AGENT_PROVIDERS = Object.keys(PROVIDER_ADAPTERS);
|
|
576
|
+
function providerAdapter(provider) {
|
|
577
|
+
const adapter = PROVIDER_ADAPTERS[provider];
|
|
578
|
+
if (!adapter)
|
|
579
|
+
throw new Error(`unsupported agent provider: ${String(provider)}`);
|
|
580
|
+
return adapter;
|
|
581
|
+
}
|
|
582
|
+
var DEFAULT_CAPTURE_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
583
|
+
function killProcessGroup(pgid) {
|
|
584
|
+
try {
|
|
585
|
+
process.kill(-pgid, "SIGTERM");
|
|
586
|
+
} catch {
|
|
587
|
+
try {
|
|
588
|
+
process.kill(pgid, "SIGTERM");
|
|
589
|
+
} catch {}
|
|
590
|
+
}
|
|
591
|
+
setTimeout(() => {
|
|
592
|
+
try {
|
|
593
|
+
process.kill(-pgid, "SIGKILL");
|
|
594
|
+
} catch {
|
|
595
|
+
try {
|
|
596
|
+
process.kill(pgid, "SIGKILL");
|
|
597
|
+
} catch {}
|
|
598
|
+
}
|
|
599
|
+
}, 2000).unref();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
class BoundedOutputBuffer {
|
|
603
|
+
maxBytes;
|
|
604
|
+
text = "";
|
|
605
|
+
truncatedBytes = 0;
|
|
606
|
+
constructor(maxBytes) {
|
|
607
|
+
this.maxBytes = maxBytes;
|
|
608
|
+
}
|
|
609
|
+
append(chunk) {
|
|
610
|
+
if (!chunk)
|
|
611
|
+
return;
|
|
612
|
+
this.text += chunk;
|
|
613
|
+
if (Buffer.byteLength(this.text, "utf8") <= this.maxBytes)
|
|
614
|
+
return;
|
|
615
|
+
this.text = scrubSecrets(this.text);
|
|
616
|
+
const encoded = Buffer.from(this.text, "utf8");
|
|
617
|
+
if (encoded.length <= this.maxBytes)
|
|
618
|
+
return;
|
|
619
|
+
let start = encoded.length - this.maxBytes;
|
|
620
|
+
while (start < encoded.length && (encoded[start] & 192) === 128)
|
|
621
|
+
start++;
|
|
622
|
+
this.truncatedBytes += start;
|
|
623
|
+
this.text = encoded.subarray(start).toString("utf8");
|
|
624
|
+
}
|
|
625
|
+
value() {
|
|
626
|
+
if (this.truncatedBytes === 0)
|
|
627
|
+
return this.text;
|
|
628
|
+
return `[truncated ${this.truncatedBytes} bytes]
|
|
629
|
+
${this.text}`;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async function spawnCapture(command, args, opts) {
|
|
633
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_CAPTURE_MAX_OUTPUT_BYTES;
|
|
634
|
+
const stdout = new BoundedOutputBuffer(maxOutputBytes);
|
|
635
|
+
const stderr = new BoundedOutputBuffer(maxOutputBytes);
|
|
636
|
+
let timedOut = false;
|
|
637
|
+
let error;
|
|
638
|
+
const child = spawn(command, args, {
|
|
639
|
+
cwd: opts.cwd,
|
|
640
|
+
env: opts.env,
|
|
641
|
+
detached: true,
|
|
642
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
643
|
+
});
|
|
644
|
+
const timer = setTimeout(() => {
|
|
645
|
+
timedOut = true;
|
|
646
|
+
if (child.pid)
|
|
647
|
+
killProcessGroup(child.pid);
|
|
648
|
+
}, opts.timeoutMs);
|
|
649
|
+
timer.unref();
|
|
650
|
+
child.stdout?.setEncoding("utf8");
|
|
651
|
+
child.stderr?.setEncoding("utf8");
|
|
652
|
+
child.stdout?.on("data", (chunk) => stdout.append(chunk));
|
|
653
|
+
child.stderr?.on("data", (chunk) => stderr.append(chunk));
|
|
654
|
+
const [status, signal] = await new Promise((resolve) => {
|
|
655
|
+
child.once("error", (err) => {
|
|
656
|
+
error = err.message;
|
|
657
|
+
resolve([null, null]);
|
|
658
|
+
});
|
|
659
|
+
child.once("close", (code, sig) => resolve([code, sig]));
|
|
660
|
+
});
|
|
661
|
+
clearTimeout(timer);
|
|
662
|
+
if (timedOut && !error)
|
|
663
|
+
error = `timed out after ${opts.timeoutMs}ms`;
|
|
664
|
+
return { status, signal, stdout: stdout.value(), stderr: stderr.value(), error, timedOut };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// src/lib/accounts.ts
|
|
668
|
+
var EXPORT_RE = /^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/;
|
|
669
|
+
var ACCOUNTS_ENV_TIMEOUT_MS = 30000;
|
|
670
|
+
function accountToolForProvider(provider) {
|
|
671
|
+
switch (provider) {
|
|
672
|
+
case "claude":
|
|
673
|
+
return "claude";
|
|
674
|
+
case "cursor":
|
|
675
|
+
return "cursor";
|
|
676
|
+
case "codewith":
|
|
677
|
+
return "codewith";
|
|
678
|
+
case "aicopilot":
|
|
679
|
+
return "aicopilot";
|
|
680
|
+
case "opencode":
|
|
681
|
+
return "opencode";
|
|
682
|
+
case "codex":
|
|
683
|
+
return "codex";
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function parseExportValue(raw) {
|
|
687
|
+
try {
|
|
688
|
+
return JSON.parse(raw);
|
|
689
|
+
} catch {
|
|
690
|
+
return raw.replace(/^['"]|['"]$/g, "");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function parseAccountExportLines(output) {
|
|
694
|
+
const env = {};
|
|
695
|
+
for (const line of output.split(/\r?\n/)) {
|
|
696
|
+
const match = EXPORT_RE.exec(line.trim());
|
|
697
|
+
if (!match)
|
|
698
|
+
continue;
|
|
699
|
+
env[match[1]] = parseExportValue(match[2]);
|
|
700
|
+
}
|
|
701
|
+
return env;
|
|
702
|
+
}
|
|
703
|
+
function primaryAccountDir(output) {
|
|
704
|
+
for (const line of output.split(/\r?\n/)) {
|
|
705
|
+
const match = EXPORT_RE.exec(line.trim());
|
|
706
|
+
if (!match)
|
|
707
|
+
continue;
|
|
708
|
+
return parseExportValue(match[2]);
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
function accountDirEnvVar(tool) {
|
|
713
|
+
switch (tool) {
|
|
714
|
+
case "claude":
|
|
715
|
+
return "CLAUDE_CONFIG_DIR";
|
|
716
|
+
case "codex":
|
|
717
|
+
case "codex-app":
|
|
718
|
+
return "CODEX_HOME";
|
|
719
|
+
case "cursor":
|
|
720
|
+
return "CURSOR_CONFIG_DIR";
|
|
721
|
+
case "opencode":
|
|
722
|
+
return "OPENCODE_CONFIG_DIR";
|
|
723
|
+
case "codewith":
|
|
724
|
+
return "CODEWITH_HOME";
|
|
725
|
+
case "aicopilot":
|
|
726
|
+
return "AICOPILOT_CONFIG_DIR";
|
|
727
|
+
default:
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function requiredAccountTool(account, toolHint) {
|
|
732
|
+
const tool = account.tool ?? toolHint;
|
|
733
|
+
if (!tool)
|
|
734
|
+
throw new Error("account.tool is required when no provider tool can be inferred");
|
|
735
|
+
return tool;
|
|
736
|
+
}
|
|
737
|
+
function accountEnvFromResult(account, tool, result) {
|
|
738
|
+
if (result.error) {
|
|
739
|
+
throw new Error(`failed to run accounts env for ${account.profile}/${tool}: ${result.error}`);
|
|
740
|
+
}
|
|
741
|
+
if ((result.status ?? 1) !== 0) {
|
|
742
|
+
const stderr = result.stderr.trim();
|
|
743
|
+
throw new Error(`accounts env failed for ${account.profile}/${tool}${stderr ? `: ${stderr}` : ""}`);
|
|
744
|
+
}
|
|
745
|
+
const accountEnv = parseAccountExportLines(result.stdout);
|
|
746
|
+
const profileDir = (accountDirEnvVar(tool) ? accountEnv[accountDirEnvVar(tool)] : undefined) ?? primaryAccountDir(result.stdout);
|
|
747
|
+
if (!profileDir)
|
|
748
|
+
throw new Error(`accounts env returned no profile directory for ${account.profile}/${tool}`);
|
|
749
|
+
if (!existsSync(profileDir))
|
|
750
|
+
throw new Error(`account profile directory does not exist for ${account.profile}/${tool}: ${profileDir}`);
|
|
751
|
+
return {
|
|
752
|
+
...accountEnv,
|
|
753
|
+
LOOPS_ACCOUNT_PROFILE: account.profile,
|
|
754
|
+
LOOPS_ACCOUNT_TOOL: tool
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
async function resolveAccountEnv(account, toolHint, env) {
|
|
758
|
+
if (!account)
|
|
759
|
+
return {};
|
|
760
|
+
const tool = requiredAccountTool(account, toolHint);
|
|
761
|
+
const result = await spawnCapture("accounts", ["env", account.profile, "--tool", tool], {
|
|
762
|
+
env,
|
|
763
|
+
timeoutMs: ACCOUNTS_ENV_TIMEOUT_MS
|
|
764
|
+
});
|
|
765
|
+
return accountEnvFromResult(account, tool, result);
|
|
766
|
+
}
|
|
767
|
+
function resolveAccountEnvSync(account, toolHint, env) {
|
|
768
|
+
if (!account)
|
|
769
|
+
return {};
|
|
770
|
+
const tool = requiredAccountTool(account, toolHint);
|
|
771
|
+
const result = spawnSync("accounts", ["env", account.profile, "--tool", tool], {
|
|
772
|
+
encoding: "utf8",
|
|
773
|
+
env,
|
|
774
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
775
|
+
timeout: ACCOUNTS_ENV_TIMEOUT_MS
|
|
776
|
+
});
|
|
777
|
+
return accountEnvFromResult(account, tool, {
|
|
778
|
+
status: result.status,
|
|
779
|
+
stdout: result.stdout ?? "",
|
|
780
|
+
stderr: result.stderr ?? "",
|
|
781
|
+
error: result.error?.message
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/lib/env.ts
|
|
786
|
+
import { accessSync, constants } from "fs";
|
|
787
|
+
import { homedir } from "os";
|
|
788
|
+
import { delimiter, join } from "path";
|
|
789
|
+
function compactPathParts(parts) {
|
|
790
|
+
const seen = new Set;
|
|
791
|
+
const result = [];
|
|
792
|
+
for (const part of parts) {
|
|
793
|
+
const value = part?.trim();
|
|
794
|
+
if (!value || seen.has(value))
|
|
795
|
+
continue;
|
|
796
|
+
seen.add(value);
|
|
797
|
+
result.push(value);
|
|
798
|
+
}
|
|
799
|
+
return result;
|
|
800
|
+
}
|
|
801
|
+
function commonExecutableDirs(env = process.env) {
|
|
802
|
+
const home = env.HOME || homedir();
|
|
803
|
+
return compactPathParts([
|
|
804
|
+
join(home, ".local", "bin"),
|
|
805
|
+
join(home, ".bun", "bin"),
|
|
806
|
+
join(home, ".cargo", "bin"),
|
|
807
|
+
join(home, ".npm-global", "bin"),
|
|
808
|
+
join(home, "bin"),
|
|
809
|
+
env.BUN_INSTALL ? join(env.BUN_INSTALL, "bin") : undefined,
|
|
810
|
+
env.PNPM_HOME,
|
|
811
|
+
env.NPM_CONFIG_PREFIX ? join(env.NPM_CONFIG_PREFIX, "bin") : undefined,
|
|
812
|
+
"/opt/homebrew/bin",
|
|
813
|
+
"/usr/local/bin",
|
|
814
|
+
"/usr/bin",
|
|
815
|
+
"/bin",
|
|
816
|
+
"/usr/sbin",
|
|
817
|
+
"/sbin"
|
|
818
|
+
]);
|
|
819
|
+
}
|
|
820
|
+
function normalizeExecutionPath(env = process.env) {
|
|
821
|
+
return compactPathParts([...(env.PATH ?? "").split(delimiter), ...commonExecutableDirs(env)]).join(delimiter);
|
|
822
|
+
}
|
|
823
|
+
function isExecutable(path) {
|
|
824
|
+
try {
|
|
825
|
+
accessSync(path, constants.X_OK);
|
|
826
|
+
return true;
|
|
827
|
+
} catch {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function executableExists(command, env = process.env) {
|
|
832
|
+
if (command.includes("/"))
|
|
833
|
+
return isExecutable(command);
|
|
834
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
835
|
+
if (dir && isExecutable(join(dir, command)))
|
|
836
|
+
return true;
|
|
837
|
+
}
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
function commandNotFoundMessage(command, env = process.env) {
|
|
841
|
+
return `Executable not found in PATH: ${command}. Effective PATH=${env.PATH || "(empty)"}`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
// src/lib/ids.ts
|
|
845
|
+
import { randomBytes } from "crypto";
|
|
846
|
+
var TIME_HEX_LENGTH = 12;
|
|
847
|
+
function genId() {
|
|
848
|
+
const time = Date.now().toString(16).padStart(TIME_HEX_LENGTH, "0");
|
|
849
|
+
return `${time}${randomBytes(10).toString("hex")}`;
|
|
850
|
+
}
|
|
851
|
+
function nowIso() {
|
|
852
|
+
return new Date().toISOString();
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// src/lib/machines.ts
|
|
856
|
+
import { createRequire } from "module";
|
|
857
|
+
var consumerModule;
|
|
858
|
+
function machinesConsumer() {
|
|
859
|
+
if (!consumerModule) {
|
|
860
|
+
try {
|
|
861
|
+
const resolved = Bun.resolveSync("@hasna/machines/consumer", import.meta.dir);
|
|
862
|
+
consumerModule = createRequire(import.meta.url)(resolved);
|
|
863
|
+
} catch (error) {
|
|
864
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
865
|
+
throw new Error(`@hasna/machines is not available; install the optional dependency to use machine-assigned loops: ${detail}`);
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
return consumerModule;
|
|
869
|
+
}
|
|
870
|
+
function compact(value) {
|
|
871
|
+
const text = value?.trim();
|
|
872
|
+
return text ? text : undefined;
|
|
873
|
+
}
|
|
874
|
+
function entryToSummary(entry, topology) {
|
|
875
|
+
return {
|
|
876
|
+
id: entry.machine_id,
|
|
877
|
+
hostname: compact(entry.hostname),
|
|
878
|
+
platform: compact(entry.platform),
|
|
879
|
+
user: compact(entry.user),
|
|
880
|
+
workspacePath: compact(entry.workspace_path),
|
|
881
|
+
route: entry.ssh.route,
|
|
882
|
+
local: entry.machine_id === topology.local_machine_id || entry.ssh.route === "local",
|
|
883
|
+
heartbeatStatus: entry.heartbeat_status,
|
|
884
|
+
tailscaleOnline: entry.tailscale.online,
|
|
885
|
+
tags: entry.tags
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
function machineFromRoute(route, topology) {
|
|
889
|
+
if (!route.ok || !route.machine_id) {
|
|
890
|
+
throw new Error(`OpenMachines route not found for machine: ${route.requested_machine_id}`);
|
|
891
|
+
}
|
|
892
|
+
const entry = topology.machines.find((machine) => machine.machine_id === route.machine_id);
|
|
893
|
+
return {
|
|
894
|
+
id: route.machine_id,
|
|
895
|
+
requestedId: route.requested_machine_id !== route.machine_id ? route.requested_machine_id : undefined,
|
|
896
|
+
route: route.route,
|
|
897
|
+
local: route.local,
|
|
898
|
+
confidence: route.confidence,
|
|
899
|
+
workspacePath: compact(entry?.workspace_path),
|
|
900
|
+
resolvedAt: route.generated_at,
|
|
901
|
+
packageVersion: route.package.version,
|
|
902
|
+
warnings: route.warnings.length ? route.warnings : undefined
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function listOpenMachines() {
|
|
906
|
+
const topology = machinesConsumer().discoverMachineTopology();
|
|
907
|
+
return topology.machines.map((entry) => entryToSummary(entry, topology));
|
|
908
|
+
}
|
|
909
|
+
function resolveLoopMachine(machineId) {
|
|
910
|
+
const consumer = machinesConsumer();
|
|
911
|
+
const topology = consumer.discoverMachineTopology();
|
|
912
|
+
const route = consumer.resolveMachineRoute(machineId, { topology });
|
|
913
|
+
return machineFromRoute(route, topology);
|
|
914
|
+
}
|
|
915
|
+
function resolveMachineCommand(machineId, command) {
|
|
916
|
+
return machinesConsumer().resolveMachineCommand(machineId, command);
|
|
917
|
+
}
|
|
918
|
+
function refreshLoopMachine(machine) {
|
|
919
|
+
return resolveLoopMachine(machine.id);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// src/lib/executor.ts
|
|
923
|
+
var DEFAULT_TIMEOUT_MS = 30 * 60000;
|
|
924
|
+
var DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024;
|
|
925
|
+
var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 30 * 60000;
|
|
926
|
+
var BUFFERED_OUTPUT_PROVIDERS = new Set(["claude", "codewith", "opencode", "aicopilot"]);
|
|
927
|
+
var DEFAULT_BUFFERED_AGENT_IDLE_TIMEOUT_MS = 4 * 60 * 60000;
|
|
928
|
+
var WORKTREE_GIT_TIMEOUT_MS = 5 * 60000;
|
|
929
|
+
var AUTH_ENV_KEYS = [
|
|
930
|
+
"CLAUDE_CONFIG_DIR",
|
|
931
|
+
"CODEWITH_HOME",
|
|
932
|
+
"CODEX_HOME",
|
|
933
|
+
"CURSOR_CONFIG_DIR",
|
|
934
|
+
"CURSOR_API_KEY",
|
|
935
|
+
"OPENCODE_CONFIG_DIR",
|
|
936
|
+
"AICOPILOT_CONFIG_DIR",
|
|
937
|
+
"ANTHROPIC_API_KEY",
|
|
938
|
+
"OPENAI_API_KEY",
|
|
939
|
+
"OPENROUTER_API_KEY",
|
|
940
|
+
"GITHUB_TOKEN",
|
|
941
|
+
"GH_TOKEN",
|
|
942
|
+
"XDG_CONFIG_HOME",
|
|
943
|
+
"XDG_DATA_HOME",
|
|
944
|
+
"XDG_STATE_HOME",
|
|
945
|
+
"XDG_CACHE_HOME"
|
|
946
|
+
];
|
|
947
|
+
var TRANSPORT_ENV_KEYS = new Set([
|
|
948
|
+
"BUN_INSTALL",
|
|
949
|
+
"HOME",
|
|
950
|
+
"LANG",
|
|
951
|
+
"LANGUAGE",
|
|
952
|
+
"LOGNAME",
|
|
953
|
+
"PATH",
|
|
954
|
+
"SHELL",
|
|
955
|
+
"SSH_AGENT_PID",
|
|
956
|
+
"SSH_AUTH_SOCK",
|
|
957
|
+
"TERM",
|
|
958
|
+
"TMP",
|
|
959
|
+
"TMPDIR",
|
|
960
|
+
"TEMP",
|
|
961
|
+
"USER",
|
|
962
|
+
"XDG_RUNTIME_DIR"
|
|
963
|
+
]);
|
|
964
|
+
function boundedText(text, maxBytes) {
|
|
965
|
+
const buffer = new BoundedOutputBuffer(maxBytes);
|
|
966
|
+
buffer.append(text);
|
|
967
|
+
return buffer.value();
|
|
968
|
+
}
|
|
969
|
+
function buildResult(status, startedAt, fields = {}) {
|
|
970
|
+
const finishedAt = fields.finishedAt ?? nowIso();
|
|
971
|
+
return {
|
|
972
|
+
status,
|
|
973
|
+
exitCode: fields.exitCode,
|
|
974
|
+
stdout: fields.stdout ?? "",
|
|
975
|
+
stderr: fields.stderr ?? "",
|
|
976
|
+
error: fields.error,
|
|
977
|
+
pid: fields.pid,
|
|
978
|
+
startedAt,
|
|
979
|
+
finishedAt,
|
|
980
|
+
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime()
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function failureResult(startedAt, error, fields = {}) {
|
|
984
|
+
return buildResult("failed", startedAt, { ...fields, error });
|
|
985
|
+
}
|
|
986
|
+
function timeoutResult(startedAt, error, fields = {}) {
|
|
987
|
+
return buildResult("timed_out", startedAt, { ...fields, error });
|
|
988
|
+
}
|
|
989
|
+
function successResult(startedAt, fields = {}) {
|
|
990
|
+
return buildResult("succeeded", startedAt, fields);
|
|
991
|
+
}
|
|
992
|
+
function notifySpawn(pid, opts) {
|
|
993
|
+
if (!pid)
|
|
994
|
+
return;
|
|
995
|
+
opts.onSpawn?.(pid);
|
|
996
|
+
opts.onSpawnProcess?.({ pid, pgid: pid, processStartedAt: nowIso() });
|
|
997
|
+
}
|
|
998
|
+
function shellQuote(value) {
|
|
999
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1000
|
+
}
|
|
1001
|
+
function metadataEnv(metadata) {
|
|
1002
|
+
const env = {};
|
|
1003
|
+
if (metadata.loopId)
|
|
1004
|
+
env.LOOPS_LOOP_ID = metadata.loopId;
|
|
1005
|
+
if (metadata.loopName)
|
|
1006
|
+
env.LOOPS_LOOP_NAME = metadata.loopName;
|
|
1007
|
+
if (metadata.runId)
|
|
1008
|
+
env.LOOPS_RUN_ID = metadata.runId;
|
|
1009
|
+
if (metadata.scheduledFor)
|
|
1010
|
+
env.LOOPS_SCHEDULED_FOR = metadata.scheduledFor;
|
|
1011
|
+
if (metadata.workflowId)
|
|
1012
|
+
env.LOOPS_WORKFLOW_ID = metadata.workflowId;
|
|
1013
|
+
if (metadata.workflowName)
|
|
1014
|
+
env.LOOPS_WORKFLOW_NAME = metadata.workflowName;
|
|
1015
|
+
if (metadata.workflowRunId)
|
|
1016
|
+
env.LOOPS_WORKFLOW_RUN_ID = metadata.workflowRunId;
|
|
1017
|
+
if (metadata.workflowStepId)
|
|
1018
|
+
env.LOOPS_WORKFLOW_STEP_ID = metadata.workflowStepId;
|
|
1019
|
+
if (metadata.goalId)
|
|
1020
|
+
env.LOOPS_GOAL_ID = metadata.goalId;
|
|
1021
|
+
if (metadata.goalObjective)
|
|
1022
|
+
env.LOOPS_GOAL_OBJECTIVE = metadata.goalObjective;
|
|
1023
|
+
if (metadata.goalNodeKey)
|
|
1024
|
+
env.LOOPS_GOAL_NODE_KEY = metadata.goalNodeKey;
|
|
1025
|
+
return env;
|
|
1026
|
+
}
|
|
1027
|
+
function codewithAgentIdempotencyKey(metadata) {
|
|
1028
|
+
const parts = [
|
|
1029
|
+
"openloops",
|
|
1030
|
+
metadata.workflowRunId ? `workflow-run:${metadata.workflowRunId}` : undefined,
|
|
1031
|
+
metadata.workflowStepId ? `step:${metadata.workflowStepId}` : undefined,
|
|
1032
|
+
metadata.runId ? `loop-run:${metadata.runId}` : undefined,
|
|
1033
|
+
metadata.loopId ? `loop:${metadata.loopId}` : undefined,
|
|
1034
|
+
metadata.scheduledFor ? `scheduled:${metadata.scheduledFor}` : undefined,
|
|
1035
|
+
metadata.goalId ? `goal:${metadata.goalId}` : undefined,
|
|
1036
|
+
metadata.goalNodeKey ? `node:${metadata.goalNodeKey}` : undefined
|
|
1037
|
+
].filter(Boolean);
|
|
1038
|
+
if (parts.length > 1)
|
|
1039
|
+
return parts.join(":");
|
|
1040
|
+
return `openloops:adhoc:${randomBytes2(16).toString("hex")}`;
|
|
1041
|
+
}
|
|
1042
|
+
function allowlistEnv(allowlist) {
|
|
1043
|
+
const env = {};
|
|
1044
|
+
if (allowlist?.tools?.length)
|
|
1045
|
+
env.LOOPS_AGENT_ALLOWED_TOOLS = allowlist.tools.join(",");
|
|
1046
|
+
if (allowlist?.commands?.length)
|
|
1047
|
+
env.LOOPS_AGENT_ALLOWED_COMMANDS = allowlist.commands.join(",");
|
|
1048
|
+
if (allowlist?.tools?.length || allowlist?.commands?.length)
|
|
1049
|
+
env.LOOPS_AGENT_ALLOWLIST_ENFORCEMENT = "metadata_only";
|
|
1050
|
+
return env;
|
|
1051
|
+
}
|
|
1052
|
+
function defaultAgentIdleTimeoutMs(target, opts) {
|
|
1053
|
+
if (target.timeoutMs !== undefined || target.idleTimeoutMs !== undefined)
|
|
1054
|
+
return;
|
|
1055
|
+
const raw = opts.env?.LOOPS_AGENT_IDLE_TIMEOUT_MS ?? process.env.LOOPS_AGENT_IDLE_TIMEOUT_MS;
|
|
1056
|
+
if (raw !== undefined && raw !== "") {
|
|
1057
|
+
const normalized = raw.trim().toLowerCase();
|
|
1058
|
+
if (normalized === "0" || normalized === "none" || normalized === "off")
|
|
1059
|
+
return;
|
|
1060
|
+
const parsed = Number(normalized);
|
|
1061
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
1062
|
+
return parsed;
|
|
1063
|
+
}
|
|
1064
|
+
return BUFFERED_OUTPUT_PROVIDERS.has(target.provider) ? DEFAULT_BUFFERED_AGENT_IDLE_TIMEOUT_MS : DEFAULT_AGENT_IDLE_TIMEOUT_MS;
|
|
1065
|
+
}
|
|
1066
|
+
function commandSpec(target, opts) {
|
|
1067
|
+
if (target.type === "command") {
|
|
1068
|
+
const commandTarget = target;
|
|
1069
|
+
return {
|
|
1070
|
+
command: commandTarget.command,
|
|
1071
|
+
args: commandTarget.args ?? [],
|
|
1072
|
+
cwd: commandTarget.cwd,
|
|
1073
|
+
shell: commandTarget.shell,
|
|
1074
|
+
env: commandTarget.env,
|
|
1075
|
+
timeoutMs: commandTarget.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : commandTarget.timeoutMs,
|
|
1076
|
+
idleTimeoutMs: commandTarget.idleTimeoutMs,
|
|
1077
|
+
account: commandTarget.account,
|
|
1078
|
+
accountTool: commandTarget.account?.tool
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
const agentTarget = target;
|
|
1082
|
+
const adapter = providerAdapter(agentTarget.provider);
|
|
1083
|
+
const invocation = adapter.buildInvocation(agentTarget);
|
|
1084
|
+
return {
|
|
1085
|
+
command: invocation.command,
|
|
1086
|
+
args: invocation.args,
|
|
1087
|
+
cwd: agentTarget.cwd,
|
|
1088
|
+
timeoutMs: agentTarget.timeoutMs ?? null,
|
|
1089
|
+
idleTimeoutMs: agentTarget.idleTimeoutMs ?? defaultAgentIdleTimeoutMs(agentTarget, opts),
|
|
1090
|
+
account: agentTarget.account,
|
|
1091
|
+
accountTool: agentTarget.account?.tool ?? accountToolForProvider(agentTarget.provider),
|
|
1092
|
+
nativeAuthProfile: agentTarget.authProfile ? { provider: agentTarget.provider, profile: agentTarget.authProfile } : undefined,
|
|
1093
|
+
preflightAnyOf: invocation.preflightAnyOf,
|
|
1094
|
+
stdin: invocation.stdin,
|
|
1095
|
+
allowlist: agentTarget.allowlist,
|
|
1096
|
+
worktree: agentTarget.worktree,
|
|
1097
|
+
codewithDurableAgent: adapter.capabilities.durable ? { target: agentTarget } : undefined
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
function composeExecutionEnv(spec, metadata, opts, accountEnv) {
|
|
1101
|
+
const env = { ...opts.env ?? process.env };
|
|
1102
|
+
if (accountEnv) {
|
|
1103
|
+
for (const key of AUTH_ENV_KEYS)
|
|
1104
|
+
delete env[key];
|
|
1105
|
+
Object.assign(env, accountEnv);
|
|
1106
|
+
}
|
|
1107
|
+
Object.assign(env, spec.env ?? {});
|
|
1108
|
+
Object.assign(env, allowlistEnv(spec.allowlist));
|
|
1109
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1110
|
+
Object.assign(env, metadataEnv(metadata));
|
|
1111
|
+
return env;
|
|
1112
|
+
}
|
|
1113
|
+
async function executionEnv(spec, metadata, opts) {
|
|
1114
|
+
const accountEnv = spec.account ? await resolveAccountEnv(spec.account, spec.accountTool, { ...opts.env ?? process.env }) : undefined;
|
|
1115
|
+
return composeExecutionEnv(spec, metadata, opts, accountEnv);
|
|
1116
|
+
}
|
|
1117
|
+
function executionEnvSync(spec, metadata, opts) {
|
|
1118
|
+
const accountEnv = spec.account ? resolveAccountEnvSync(spec.account, spec.accountTool, { ...opts.env ?? process.env }) : undefined;
|
|
1119
|
+
return composeExecutionEnv(spec, metadata, opts, accountEnv);
|
|
1120
|
+
}
|
|
1121
|
+
function resolvedMachine(opts) {
|
|
1122
|
+
if (!opts.machine)
|
|
1123
|
+
return;
|
|
1124
|
+
return (opts.machineResolver ?? refreshLoopMachine)(opts.machine);
|
|
1125
|
+
}
|
|
1126
|
+
function commandForShell(spec) {
|
|
1127
|
+
if (!spec.args.length)
|
|
1128
|
+
return spec.command;
|
|
1129
|
+
return [spec.command, ...spec.args.map(shellQuote)].join(" ");
|
|
1130
|
+
}
|
|
1131
|
+
function hereDoc(value) {
|
|
1132
|
+
let delimiter2 = `__OPENLOOPS_STDIN_${randomBytes2(8).toString("hex").toUpperCase()}__`;
|
|
1133
|
+
while (value.split(/\r?\n/).includes(delimiter2)) {
|
|
1134
|
+
delimiter2 = `__OPENLOOPS_STDIN_${randomBytes2(8).toString("hex").toUpperCase()}__`;
|
|
1135
|
+
}
|
|
1136
|
+
return [`cat > "$__OPENLOOPS_STDIN" <<'${delimiter2}'`, value, delimiter2];
|
|
1137
|
+
}
|
|
1138
|
+
function remoteBootstrapLines(spec, metadata, opts = {}) {
|
|
1139
|
+
const lines = [
|
|
1140
|
+
"set -e",
|
|
1141
|
+
'export PATH="$HOME/.local/bin:$HOME/.bun/bin:$HOME/.cargo/bin:$HOME/.npm-global/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin${PATH:+:$PATH}"'
|
|
1142
|
+
];
|
|
1143
|
+
const worktree = spec.worktree;
|
|
1144
|
+
const worktreeManaged = worktree?.enabled && (worktree.mode === "auto" || worktree.mode === "required");
|
|
1145
|
+
if (opts.worktree && worktree && worktreeManaged) {
|
|
1146
|
+
lines.push(...remoteWorktreePrepareLines(worktree));
|
|
1147
|
+
lines.push(...remoteWorktreeEnterLines(worktree, spec.cwd));
|
|
1148
|
+
} else if (worktree && worktreeManaged) {
|
|
1149
|
+
lines.push(`cd ${shellQuote(worktree.originalCwd)}`);
|
|
1150
|
+
} else if (spec.cwd) {
|
|
1151
|
+
lines.push(`cd ${shellQuote(spec.cwd)}`);
|
|
1152
|
+
}
|
|
1153
|
+
if (spec.account) {
|
|
1154
|
+
if (!spec.accountTool)
|
|
1155
|
+
throw new Error("account.tool is required when no provider tool can be inferred");
|
|
1156
|
+
lines.push("if ! command -v accounts >/dev/null 2>&1; then echo 'accounts CLI is not available on remote machine' >&2; exit 127; fi", `unset ${AUTH_ENV_KEYS.join(" ")}`, `eval "$(accounts env ${shellQuote(spec.account.profile)} --tool ${shellQuote(spec.accountTool)})"`, `export LOOPS_ACCOUNT_PROFILE=${shellQuote(spec.account.profile)}`, `export LOOPS_ACCOUNT_TOOL=${shellQuote(spec.accountTool)}`);
|
|
1157
|
+
}
|
|
1158
|
+
for (const [key, value] of Object.entries({ ...metadataEnv(metadata), ...spec.env ?? {} })) {
|
|
1159
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
1160
|
+
continue;
|
|
1161
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
1162
|
+
}
|
|
1163
|
+
for (const [key, value] of Object.entries(allowlistEnv(spec.allowlist))) {
|
|
1164
|
+
lines.push(`export ${key}=${shellQuote(value)}`);
|
|
1165
|
+
}
|
|
1166
|
+
return lines;
|
|
1167
|
+
}
|
|
1168
|
+
function remoteWorktreePrepareLines(worktree) {
|
|
1169
|
+
const { repoRoot, path, branch } = worktree;
|
|
1170
|
+
if (!repoRoot || !path || !branch) {
|
|
1171
|
+
return [
|
|
1172
|
+
"__openloops_prepare_worktree() {",
|
|
1173
|
+
` echo ${shellQuote("worktree preparation requires repoRoot, path, and branch metadata")} >&2`,
|
|
1174
|
+
" return 1",
|
|
1175
|
+
"}"
|
|
1176
|
+
];
|
|
1177
|
+
}
|
|
1178
|
+
return [
|
|
1179
|
+
"__openloops_prepare_worktree() {",
|
|
1180
|
+
` local repo=${shellQuote(repoRoot)} path=${shellQuote(path)} branch=${shellQuote(branch)}`,
|
|
1181
|
+
" local top expected_common actual_common current",
|
|
1182
|
+
' if [ -L "$path" ]; then echo "refusing symlinked worktree path $path" >&2; return 1; fi',
|
|
1183
|
+
' if [ -e "$path" ]; then',
|
|
1184
|
+
' top="$(git -C "$path" rev-parse --show-toplevel 2>/dev/null)" || { echo "refusing to reuse non-worktree path: $path" >&2; return 1; }',
|
|
1185
|
+
' if [ "$(cd "$top" 2>/dev/null && pwd -P)" != "$(cd "$path" 2>/dev/null && pwd -P)" ]; then echo "existing worktree top-level mismatch for $path: $top" >&2; return 1; fi',
|
|
1186
|
+
' expected_common="$(cd "$repo" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)"',
|
|
1187
|
+
' actual_common="$(cd "$path" 2>/dev/null && cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P)"',
|
|
1188
|
+
' if [ -z "$expected_common" ] || [ "$expected_common" != "$actual_common" ]; then echo "existing worktree $path belongs to a different git common dir" >&2; return 1; fi',
|
|
1189
|
+
' current="$(git -C "$path" branch --show-current 2>/dev/null)"',
|
|
1190
|
+
' if [ "$current" != "$branch" ]; then echo "existing worktree $path is on branch ${current:-unknown}, expected $branch" >&2; return 1; fi',
|
|
1191
|
+
" return 0",
|
|
1192
|
+
" fi",
|
|
1193
|
+
' git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "worktree repoRoot is not a git repository: $repo" >&2; return 1; }',
|
|
1194
|
+
' mkdir -p "$(dirname "$path")" || return 1',
|
|
1195
|
+
" # Preparation chatter goes to stderr so run stdout stays the agent's.",
|
|
1196
|
+
' if git -C "$repo" show-ref --verify --quiet "refs/heads/$branch"; then',
|
|
1197
|
+
' git -C "$repo" worktree add "$path" "$branch" 1>&2 || return 1',
|
|
1198
|
+
" else",
|
|
1199
|
+
' git -C "$repo" worktree add -b "$branch" "$path" HEAD 1>&2 || return 1',
|
|
1200
|
+
" fi",
|
|
1201
|
+
"}"
|
|
1202
|
+
];
|
|
1203
|
+
}
|
|
1204
|
+
function remoteWorktreeEnterLines(worktree, cwd) {
|
|
1205
|
+
const workdir = cwd ?? worktree.cwd;
|
|
1206
|
+
if (worktree.mode === "required") {
|
|
1207
|
+
return [
|
|
1208
|
+
"if ! __openloops_prepare_worktree; then",
|
|
1209
|
+
` echo ${shellQuote("worktree preparation failed (mode=required)")} >&2`,
|
|
1210
|
+
" exit 1",
|
|
1211
|
+
"fi",
|
|
1212
|
+
"__OPENLOOPS_WORKTREE_OK=1",
|
|
1213
|
+
`cd ${shellQuote(workdir)}`
|
|
1214
|
+
];
|
|
1215
|
+
}
|
|
1216
|
+
return [
|
|
1217
|
+
"if __openloops_prepare_worktree; then",
|
|
1218
|
+
" __OPENLOOPS_WORKTREE_OK=1",
|
|
1219
|
+
` cd ${shellQuote(workdir)}`,
|
|
1220
|
+
"else",
|
|
1221
|
+
` echo ${shellQuote(`worktree preparation failed (mode=${worktree.mode}); falling back to ${worktree.originalCwd}`)} >&2`,
|
|
1222
|
+
" __OPENLOOPS_WORKTREE_OK=0",
|
|
1223
|
+
` cd ${shellQuote(worktree.originalCwd)}`,
|
|
1224
|
+
"fi"
|
|
1225
|
+
];
|
|
1226
|
+
}
|
|
1227
|
+
function remoteScript(spec, metadata, fallbackSpec) {
|
|
1228
|
+
const lines = remoteBootstrapLines(spec, metadata, { worktree: true });
|
|
1229
|
+
let stdinRedirect = "";
|
|
1230
|
+
if (spec.stdin !== undefined) {
|
|
1231
|
+
lines.push('__OPENLOOPS_STDIN="$(mktemp -t openloops-stdin.XXXXXX)"', `trap 'rm -f "$__OPENLOOPS_STDIN"' EXIT`);
|
|
1232
|
+
lines.push(...hereDoc(spec.stdin));
|
|
1233
|
+
stdinRedirect = ' < "$__OPENLOOPS_STDIN"';
|
|
1234
|
+
}
|
|
1235
|
+
const invocationFor = (invocationSpec) => invocationSpec.shell ? `sh -c ${shellQuote(commandForShell(invocationSpec))}${stdinRedirect}` : `${[invocationSpec.command, ...invocationSpec.args].map(shellQuote).join(" ")}${stdinRedirect}`;
|
|
1236
|
+
if (spec.worktree?.enabled && spec.worktree.mode === "auto" && fallbackSpec) {
|
|
1237
|
+
lines.push('if [ "${__OPENLOOPS_WORKTREE_OK:-0}" = 1 ]; then', ` ${invocationFor(spec)}`, "else", ` ${invocationFor(fallbackSpec)}`, "fi");
|
|
1238
|
+
} else {
|
|
1239
|
+
lines.push(invocationFor(spec));
|
|
1240
|
+
}
|
|
1241
|
+
return `${lines.join(`
|
|
1242
|
+
`)}
|
|
1243
|
+
`;
|
|
1244
|
+
}
|
|
1245
|
+
function remotePreflightScript(spec, metadata) {
|
|
1246
|
+
const lines = [
|
|
1247
|
+
...remoteBootstrapLines(spec, metadata),
|
|
1248
|
+
"command -v bash >/dev/null 2>&1",
|
|
1249
|
+
`command -v ${shellQuote(spec.shell ? "sh" : spec.command)} >/dev/null 2>&1`
|
|
1250
|
+
];
|
|
1251
|
+
if (spec.preflightAnyOf?.length) {
|
|
1252
|
+
lines.push(`if ! ${spec.preflightAnyOf.map((command) => `command -v ${shellQuote(command)} >/dev/null 2>&1`).join(" && ! ")}; then`, ` echo 'none of required executables found: ${spec.preflightAnyOf.join(", ")}' >&2`, " exit 127", "fi");
|
|
1253
|
+
}
|
|
1254
|
+
if (spec.nativeAuthProfile?.provider === "codewith") {
|
|
1255
|
+
lines.push(`__OPENLOOPS_CODEWITH_PROFILES="$(${shellQuote(spec.command)} profile list)" || {`, ` printf '%s\\n' ${shellQuote("codewith auth profile preflight failed")} >&2`, " exit 1", "}", `if ! printf '%s\\n' "$__OPENLOOPS_CODEWITH_PROFILES" | awk 'NR > 1 { print $1 }' | grep -Fx ${shellQuote(spec.nativeAuthProfile.profile)} >/dev/null; then`, ` printf '%s\\n' ${shellQuote(`codewith auth profile not found: ${spec.nativeAuthProfile.profile}`)} >&2`, " exit 1", "fi");
|
|
1256
|
+
}
|
|
1257
|
+
return lines.join(`
|
|
1258
|
+
`);
|
|
1259
|
+
}
|
|
1260
|
+
function transportEnv(opts) {
|
|
1261
|
+
const source = opts.env ?? process.env;
|
|
1262
|
+
const env = {};
|
|
1263
|
+
for (const [key, value] of Object.entries(source)) {
|
|
1264
|
+
if (value === undefined)
|
|
1265
|
+
continue;
|
|
1266
|
+
if (TRANSPORT_ENV_KEYS.has(key) || key.startsWith("LC_"))
|
|
1267
|
+
env[key] = value;
|
|
1268
|
+
}
|
|
1269
|
+
env.PATH = normalizeExecutionPath(env);
|
|
1270
|
+
return env;
|
|
1271
|
+
}
|
|
1272
|
+
function assertCodewithProfileListed(profile, result) {
|
|
1273
|
+
if (result.error) {
|
|
1274
|
+
throw new Error(`codewith auth profile preflight failed: ${result.error}`);
|
|
1275
|
+
}
|
|
1276
|
+
if ((result.status ?? 1) !== 0) {
|
|
1277
|
+
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
1278
|
+
throw new Error(`codewith auth profile preflight failed${detail ? `: ${detail}` : ""}`);
|
|
1279
|
+
}
|
|
1280
|
+
const profiles = new Set((result.stdout || "").split(/\r?\n/).slice(1).map((line) => line.trim().split(/\s+/)[0]).filter(Boolean));
|
|
1281
|
+
if (!profiles.has(profile)) {
|
|
1282
|
+
throw new Error(`codewith auth profile not found: ${profile}`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
function preflightNativeAuthProfileSync(spec, env) {
|
|
1286
|
+
if (spec.nativeAuthProfile?.provider !== "codewith")
|
|
1287
|
+
return;
|
|
1288
|
+
const result = spawnSync2(spec.command, ["profile", "list"], {
|
|
1289
|
+
encoding: "utf8",
|
|
1290
|
+
env,
|
|
1291
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1292
|
+
timeout: 15000
|
|
1293
|
+
});
|
|
1294
|
+
assertCodewithProfileListed(spec.nativeAuthProfile.profile, {
|
|
1295
|
+
status: result.status,
|
|
1296
|
+
stdout: result.stdout ?? "",
|
|
1297
|
+
stderr: result.stderr ?? "",
|
|
1298
|
+
error: result.error?.message
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
async function preflightNativeAuthProfile(spec, env) {
|
|
1302
|
+
if (spec.nativeAuthProfile?.provider !== "codewith")
|
|
1303
|
+
return;
|
|
1304
|
+
const result = await spawnCapture(spec.command, ["profile", "list"], { env, timeoutMs: 15000 });
|
|
1305
|
+
assertCodewithProfileListed(spec.nativeAuthProfile.profile, result);
|
|
1306
|
+
}
|
|
1307
|
+
function codewithAgentStartArgs(target, idempotencyKey) {
|
|
1308
|
+
const args = providerAdapter(target.provider).buildInvocation(target).args;
|
|
1309
|
+
const startIndex = args.findIndex((arg, index) => arg === "start" && args[index - 1] === "agent");
|
|
1310
|
+
if (startIndex === -1)
|
|
1311
|
+
throw new Error("internal error: codewith durable agent args missing agent start");
|
|
1312
|
+
args.splice(startIndex + 1, 0, "--idempotency-key", idempotencyKey);
|
|
1313
|
+
return args;
|
|
1314
|
+
}
|
|
1315
|
+
function codewithAgentControlArgs(target, command, agentId) {
|
|
1316
|
+
return [
|
|
1317
|
+
...target.authProfile ? ["--auth-profile", target.authProfile] : [],
|
|
1318
|
+
"agent",
|
|
1319
|
+
command,
|
|
1320
|
+
...command === "logs" ? ["--limit", "20"] : [],
|
|
1321
|
+
agentId
|
|
1322
|
+
];
|
|
1323
|
+
}
|
|
1324
|
+
function parseJsonOutput(stdout, label) {
|
|
1325
|
+
try {
|
|
1326
|
+
const value = JSON.parse(stdout || "{}");
|
|
1327
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1328
|
+
throw new Error("not an object");
|
|
1329
|
+
return value;
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
throw new Error(`${label} did not return JSON${error instanceof Error ? `: ${error.message}` : ""}`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
function recordField(value, key) {
|
|
1335
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1336
|
+
return;
|
|
1337
|
+
const field = value[key];
|
|
1338
|
+
return field && typeof field === "object" && !Array.isArray(field) ? field : undefined;
|
|
1339
|
+
}
|
|
1340
|
+
function stringField(value, key) {
|
|
1341
|
+
const field = value?.[key];
|
|
1342
|
+
return typeof field === "string" ? field : undefined;
|
|
1343
|
+
}
|
|
1344
|
+
function numberField(value, key) {
|
|
1345
|
+
const field = value?.[key];
|
|
1346
|
+
return typeof field === "number" && Number.isFinite(field) ? field : undefined;
|
|
1347
|
+
}
|
|
1348
|
+
function codewithAgentStatus(readJson) {
|
|
1349
|
+
return stringField(recordField(readJson, "agent"), "status") ?? stringField(recordField(readJson, "statusSnapshot"), "status");
|
|
1350
|
+
}
|
|
1351
|
+
function codewithAgentEvidence(startJson, readJson, logsJson) {
|
|
1352
|
+
const agent = recordField(readJson, "agent") ?? recordField(startJson, "agent");
|
|
1353
|
+
const statusSnapshot = recordField(readJson, "statusSnapshot");
|
|
1354
|
+
const events = Array.isArray(logsJson?.data) ? logsJson.data.filter((event) => Boolean(event) && typeof event === "object" && !Array.isArray(event)).map((event) => ({
|
|
1355
|
+
seq: numberField(event, "seq"),
|
|
1356
|
+
eventType: stringField(event, "eventType"),
|
|
1357
|
+
createdAt: numberField(event, "createdAt")
|
|
1358
|
+
})) : undefined;
|
|
1359
|
+
return JSON.stringify({
|
|
1360
|
+
codewithAgent: {
|
|
1361
|
+
agentId: stringField(agent, "agentId"),
|
|
1362
|
+
status: stringField(agent, "status"),
|
|
1363
|
+
desiredState: stringField(agent, "desiredState"),
|
|
1364
|
+
statusReason: stringField(agent, "statusReason"),
|
|
1365
|
+
threadId: stringField(agent, "threadId"),
|
|
1366
|
+
rolloutPath: stringField(agent, "rolloutPath"),
|
|
1367
|
+
pid: numberField(agent, "pid"),
|
|
1368
|
+
exitCode: numberField(agent, "exitCode"),
|
|
1369
|
+
created: typeof startJson.created === "boolean" ? startJson.created : undefined
|
|
1370
|
+
},
|
|
1371
|
+
statusSnapshot: statusSnapshot ? {
|
|
1372
|
+
seq: numberField(statusSnapshot, "seq"),
|
|
1373
|
+
status: stringField(statusSnapshot, "status"),
|
|
1374
|
+
summary: stringField(statusSnapshot, "summary"),
|
|
1375
|
+
pendingInteractionCount: numberField(statusSnapshot, "pendingInteractionCount"),
|
|
1376
|
+
lastEventSeq: numberField(statusSnapshot, "lastEventSeq")
|
|
1377
|
+
} : undefined,
|
|
1378
|
+
events
|
|
1379
|
+
}, null, 2);
|
|
1380
|
+
}
|
|
1381
|
+
function sleep(ms) {
|
|
1382
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1383
|
+
}
|
|
1384
|
+
async function executeCodewithDurableAgent(spec, metadata, opts, env, startedAt) {
|
|
1385
|
+
const target = spec.codewithDurableAgent?.target;
|
|
1386
|
+
if (!target)
|
|
1387
|
+
throw new Error("internal error: missing codewith durable target");
|
|
1388
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
1389
|
+
const idempotencyKey = codewithAgentIdempotencyKey(metadata);
|
|
1390
|
+
const start = await spawnCapture(spec.command, codewithAgentStartArgs(target, idempotencyKey), {
|
|
1391
|
+
cwd: spec.cwd,
|
|
1392
|
+
env,
|
|
1393
|
+
timeoutMs: 30000,
|
|
1394
|
+
maxOutputBytes
|
|
1395
|
+
});
|
|
1396
|
+
const stderr = new BoundedOutputBuffer(maxOutputBytes);
|
|
1397
|
+
stderr.append(start.stderr);
|
|
1398
|
+
if (start.error || (start.status ?? 1) !== 0) {
|
|
1399
|
+
return failureResult(startedAt, start.error ?? `codewith agent start exited with code ${start.status ?? "unknown"}`, {
|
|
1400
|
+
exitCode: start.status ?? undefined,
|
|
1401
|
+
stdout: start.stdout,
|
|
1402
|
+
stderr: stderr.value()
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1405
|
+
let startJson;
|
|
1406
|
+
try {
|
|
1407
|
+
startJson = parseJsonOutput(start.stdout || "{}", "codewith agent start");
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
return failureResult(startedAt, error instanceof Error ? error.message : String(error), { stdout: start.stdout, stderr: stderr.value() });
|
|
1410
|
+
}
|
|
1411
|
+
const agentId = stringField(recordField(startJson, "agent"), "agentId");
|
|
1412
|
+
if (!agentId) {
|
|
1413
|
+
return failureResult(startedAt, "codewith agent start did not return agent.agentId", { stdout: start.stdout, stderr: stderr.value() });
|
|
1414
|
+
}
|
|
1415
|
+
const stopAgent = async () => {
|
|
1416
|
+
await spawnCapture(spec.command, codewithAgentControlArgs(target, "stop", agentId), {
|
|
1417
|
+
cwd: spec.cwd,
|
|
1418
|
+
env,
|
|
1419
|
+
timeoutMs: 15000,
|
|
1420
|
+
maxOutputBytes
|
|
1421
|
+
});
|
|
1422
|
+
};
|
|
1423
|
+
const pollMs = Math.max(100, Number(opts.env?.LOOPS_CODEWITH_AGENT_POLL_MS ?? process.env.LOOPS_CODEWITH_AGENT_POLL_MS ?? 2000) || 2000);
|
|
1424
|
+
let lastReadJson = startJson;
|
|
1425
|
+
let lastLogsJson;
|
|
1426
|
+
let lastFingerprint;
|
|
1427
|
+
let lastProgressAt = Date.now();
|
|
1428
|
+
const evidence = () => boundedText(codewithAgentEvidence(startJson, lastReadJson, lastLogsJson), maxOutputBytes);
|
|
1429
|
+
while (true) {
|
|
1430
|
+
if (opts.signal?.aborted) {
|
|
1431
|
+
await stopAgent();
|
|
1432
|
+
return failureResult(startedAt, "cancelled", { stdout: evidence(), stderr: stderr.value() });
|
|
1433
|
+
}
|
|
1434
|
+
const read = await spawnCapture(spec.command, codewithAgentControlArgs(target, "read", agentId), {
|
|
1435
|
+
cwd: spec.cwd,
|
|
1436
|
+
env,
|
|
1437
|
+
timeoutMs: 30000,
|
|
1438
|
+
maxOutputBytes
|
|
1439
|
+
});
|
|
1440
|
+
stderr.append(read.stderr);
|
|
1441
|
+
if (read.error || (read.status ?? 1) !== 0) {
|
|
1442
|
+
return failureResult(startedAt, read.error ?? `codewith agent read exited with code ${read.status ?? "unknown"}`, {
|
|
1443
|
+
exitCode: read.status ?? undefined,
|
|
1444
|
+
stdout: evidence(),
|
|
1445
|
+
stderr: stderr.value()
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
try {
|
|
1449
|
+
lastReadJson = parseJsonOutput(read.stdout || "{}", "codewith agent read");
|
|
1450
|
+
} catch (error) {
|
|
1451
|
+
return failureResult(startedAt, error instanceof Error ? error.message : String(error), {
|
|
1452
|
+
stdout: boundedText(read.stdout, maxOutputBytes),
|
|
1453
|
+
stderr: stderr.value()
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
const status = codewithAgentStatus(lastReadJson);
|
|
1457
|
+
const fingerprint = JSON.stringify({ status, snapshot: recordField(lastReadJson, "statusSnapshot") ?? null });
|
|
1458
|
+
if (fingerprint !== lastFingerprint) {
|
|
1459
|
+
lastFingerprint = fingerprint;
|
|
1460
|
+
lastProgressAt = Date.now();
|
|
1461
|
+
}
|
|
1462
|
+
if (status === "completed" || status === "failed" || status === "cancelled") {
|
|
1463
|
+
const logs = await spawnCapture(spec.command, codewithAgentControlArgs(target, "logs", agentId), {
|
|
1464
|
+
cwd: spec.cwd,
|
|
1465
|
+
env,
|
|
1466
|
+
timeoutMs: 30000,
|
|
1467
|
+
maxOutputBytes
|
|
1468
|
+
});
|
|
1469
|
+
if (!logs.error && (logs.status ?? 1) === 0) {
|
|
1470
|
+
try {
|
|
1471
|
+
lastLogsJson = parseJsonOutput(logs.stdout || "{}", "codewith agent logs");
|
|
1472
|
+
} catch {
|
|
1473
|
+
lastLogsJson = undefined;
|
|
1474
|
+
}
|
|
1475
|
+
} else {
|
|
1476
|
+
stderr.append(logs.stderr || logs.error || "");
|
|
1477
|
+
}
|
|
1478
|
+
if (status === "completed") {
|
|
1479
|
+
return successResult(startedAt, { exitCode: 0, stdout: evidence(), stderr: stderr.value() });
|
|
1480
|
+
}
|
|
1481
|
+
return failureResult(startedAt, stringField(recordField(lastReadJson, "agent"), "statusReason") ?? `codewith agent ${status}`, { exitCode: 1, stdout: evidence(), stderr: stderr.value() });
|
|
1482
|
+
}
|
|
1483
|
+
if (typeof spec.timeoutMs === "number" && new Date(nowIso()).getTime() - new Date(startedAt).getTime() >= spec.timeoutMs) {
|
|
1484
|
+
await stopAgent();
|
|
1485
|
+
return timeoutResult(startedAt, `timed out after ${spec.timeoutMs}ms`, { stdout: evidence(), stderr: stderr.value() });
|
|
1486
|
+
}
|
|
1487
|
+
if (typeof spec.idleTimeoutMs === "number" && Date.now() - lastProgressAt >= spec.idleTimeoutMs) {
|
|
1488
|
+
await stopAgent();
|
|
1489
|
+
return timeoutResult(startedAt, `idle timed out after ${spec.idleTimeoutMs}ms without agent progress`, {
|
|
1490
|
+
stdout: evidence(),
|
|
1491
|
+
stderr: stderr.value()
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
await sleep(pollMs);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
function resolvedDirEquals(left, right) {
|
|
1498
|
+
try {
|
|
1499
|
+
return realpathSync(left) === realpathSync(right);
|
|
1500
|
+
} catch {
|
|
1501
|
+
return false;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
async function ensureLocalWorktree(worktree, env) {
|
|
1505
|
+
const { repoRoot, path, branch } = worktree;
|
|
1506
|
+
if (!repoRoot || !path || !branch) {
|
|
1507
|
+
return { error: "worktree preparation requires repoRoot, path, and branch metadata" };
|
|
1508
|
+
}
|
|
1509
|
+
const git = (args) => spawnCapture("git", args, { env, timeoutMs: WORKTREE_GIT_TIMEOUT_MS });
|
|
1510
|
+
let stats;
|
|
1511
|
+
try {
|
|
1512
|
+
stats = lstatSync(path);
|
|
1513
|
+
} catch {
|
|
1514
|
+
stats = undefined;
|
|
1515
|
+
}
|
|
1516
|
+
if (stats?.isSymbolicLink())
|
|
1517
|
+
return { error: `refusing symlinked worktree path ${path}` };
|
|
1518
|
+
const commonDir = async (base) => {
|
|
1519
|
+
const result = await git(["-C", base, "rev-parse", "--git-common-dir"]);
|
|
1520
|
+
if (result.error || (result.status ?? 1) !== 0)
|
|
1521
|
+
return;
|
|
1522
|
+
const raw = result.stdout.trim();
|
|
1523
|
+
if (!raw)
|
|
1524
|
+
return;
|
|
1525
|
+
try {
|
|
1526
|
+
return realpathSync(resolve(base, raw));
|
|
1527
|
+
} catch {
|
|
1528
|
+
return resolve(base, raw);
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
if (stats) {
|
|
1532
|
+
const top = await git(["-C", path, "rev-parse", "--show-toplevel"]);
|
|
1533
|
+
if (top.error || (top.status ?? 1) !== 0) {
|
|
1534
|
+
return { error: `refusing to reuse non-worktree path: ${path}` };
|
|
1535
|
+
}
|
|
1536
|
+
if (!resolvedDirEquals(top.stdout.trim(), path)) {
|
|
1537
|
+
return { error: `existing worktree top-level mismatch for ${path}: ${top.stdout.trim()}` };
|
|
1538
|
+
}
|
|
1539
|
+
const expectedCommon = await commonDir(repoRoot);
|
|
1540
|
+
const actualCommon = await commonDir(path);
|
|
1541
|
+
if (!expectedCommon || expectedCommon !== actualCommon) {
|
|
1542
|
+
return { error: `existing worktree ${path} belongs to a different git common dir` };
|
|
1543
|
+
}
|
|
1544
|
+
const current = await git(["-C", path, "branch", "--show-current"]);
|
|
1545
|
+
const actualBranch = current.stdout.trim();
|
|
1546
|
+
if (current.error || (current.status ?? 1) !== 0 || actualBranch !== branch) {
|
|
1547
|
+
return { error: `existing worktree ${path} is on branch ${actualBranch || "unknown"}, expected ${branch}` };
|
|
1548
|
+
}
|
|
1549
|
+
return { cwd: worktree.cwd };
|
|
1550
|
+
}
|
|
1551
|
+
const inside = await git(["-C", repoRoot, "rev-parse", "--is-inside-work-tree"]);
|
|
1552
|
+
if (inside.error || (inside.status ?? 1) !== 0) {
|
|
1553
|
+
return { error: `worktree repoRoot is not a git repository: ${repoRoot}` };
|
|
1554
|
+
}
|
|
1555
|
+
try {
|
|
1556
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1557
|
+
} catch (error) {
|
|
1558
|
+
return { error: `could not create worktree parent directory: ${error instanceof Error ? error.message : String(error)}` };
|
|
1559
|
+
}
|
|
1560
|
+
const hasBranch = await git(["-C", repoRoot, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
1561
|
+
const add = hasBranch.status === 0 ? await git(["-C", repoRoot, "worktree", "add", path, branch]) : await git(["-C", repoRoot, "worktree", "add", "-b", branch, path, "HEAD"]);
|
|
1562
|
+
if (add.error || (add.status ?? 1) !== 0) {
|
|
1563
|
+
const detail = (add.stderr || add.stdout || add.error || "").toString().trim();
|
|
1564
|
+
return { error: `git worktree add failed for ${path}${detail ? `: ${detail}` : ""}` };
|
|
1565
|
+
}
|
|
1566
|
+
return { cwd: worktree.cwd };
|
|
1567
|
+
}
|
|
1568
|
+
async function enterWorktree(spec, opts, env, startedAt) {
|
|
1569
|
+
const worktree = spec.worktree;
|
|
1570
|
+
if (!worktree?.enabled || worktree.mode === "off" || worktree.mode === "main")
|
|
1571
|
+
return;
|
|
1572
|
+
const prepared = await ensureLocalWorktree(worktree, env);
|
|
1573
|
+
if (prepared.error) {
|
|
1574
|
+
if (worktree.mode === "required") {
|
|
1575
|
+
return { failure: failureResult(startedAt, `worktree preparation failed (mode=required): ${prepared.error}`) };
|
|
1576
|
+
}
|
|
1577
|
+
opts.log?.(`worktree preparation failed (mode=${worktree.mode}); falling back to ${worktree.originalCwd}: ${prepared.error}`);
|
|
1578
|
+
spec.cwd = worktree.originalCwd;
|
|
1579
|
+
return { fallbackCwd: worktree.originalCwd };
|
|
1580
|
+
}
|
|
1581
|
+
spec.cwd = prepared.cwd ?? spec.cwd;
|
|
1582
|
+
opts.log?.(`entered worktree ${worktree.path ?? spec.cwd}${worktree.branch ? ` branch ${worktree.branch}` : ""}`);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
function worktreeFallbackSpec(target, opts, fallbackCwd) {
|
|
1586
|
+
if (target.type !== "agent")
|
|
1587
|
+
return;
|
|
1588
|
+
const agentTarget = target;
|
|
1589
|
+
const fallbackTarget = {
|
|
1590
|
+
...agentTarget,
|
|
1591
|
+
cwd: fallbackCwd,
|
|
1592
|
+
worktree: agentTarget.worktree ? { ...agentTarget.worktree, enabled: false, cwd: fallbackCwd, reason: "auto worktree preparation failed" } : undefined
|
|
1593
|
+
};
|
|
1594
|
+
return commandSpec(fallbackTarget, opts);
|
|
1595
|
+
}
|
|
1596
|
+
function preflightRemoteSpec(spec, machine, metadata, opts) {
|
|
1597
|
+
const plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
1598
|
+
const result = spawnSync2(plan.command, plan.args, {
|
|
1599
|
+
encoding: "utf8",
|
|
1600
|
+
env: transportEnv(opts),
|
|
1601
|
+
input: remotePreflightScript(spec, metadata),
|
|
1602
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1603
|
+
timeout: 15000
|
|
1604
|
+
});
|
|
1605
|
+
if (result.error)
|
|
1606
|
+
throw new Error(`remote preflight failed on ${machine.id}: ${result.error.message}`);
|
|
1607
|
+
if ((result.status ?? 1) !== 0) {
|
|
1608
|
+
const detail = (result.stderr || result.stdout || `exit ${result.status ?? "unknown"}`).trim();
|
|
1609
|
+
throw new Error(`remote preflight failed on ${machine.id}${detail ? `: ${detail}` : ""}`);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
async function executeRemoteSpec(spec, machine, metadata, opts, fallbackSpec) {
|
|
1613
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
1614
|
+
const startedAt = nowIso();
|
|
1615
|
+
const stdout = new BoundedOutputBuffer(maxOutputBytes);
|
|
1616
|
+
const stderr = new BoundedOutputBuffer(maxOutputBytes);
|
|
1617
|
+
let timedOut = false;
|
|
1618
|
+
let idleTimedOut = false;
|
|
1619
|
+
let exitCode;
|
|
1620
|
+
let error;
|
|
1621
|
+
let plan;
|
|
1622
|
+
let script;
|
|
1623
|
+
try {
|
|
1624
|
+
plan = (opts.machineCommandResolver ?? resolveMachineCommand)(machine.id, "bash -s");
|
|
1625
|
+
script = remoteScript(spec, metadata, fallbackSpec);
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
return failureResult(startedAt, err instanceof Error ? err.message : String(err));
|
|
1628
|
+
}
|
|
1629
|
+
const child = spawn2(plan.command, plan.args, {
|
|
1630
|
+
env: transportEnv(opts),
|
|
1631
|
+
detached: true,
|
|
1632
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
1633
|
+
});
|
|
1634
|
+
notifySpawn(child.pid, opts);
|
|
1635
|
+
child.stdin?.on("error", (err) => {
|
|
1636
|
+
if (err.code !== "EPIPE")
|
|
1637
|
+
error = err.message;
|
|
1638
|
+
});
|
|
1639
|
+
child.stdin?.end(script);
|
|
1640
|
+
const abortHandler = () => {
|
|
1641
|
+
error = "cancelled";
|
|
1642
|
+
if (child.pid)
|
|
1643
|
+
killProcessGroup(child.pid);
|
|
1644
|
+
};
|
|
1645
|
+
if (opts.signal?.aborted)
|
|
1646
|
+
abortHandler();
|
|
1647
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1648
|
+
const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
|
|
1649
|
+
timedOut = true;
|
|
1650
|
+
if (child.pid)
|
|
1651
|
+
killProcessGroup(child.pid);
|
|
1652
|
+
}, spec.timeoutMs) : undefined;
|
|
1653
|
+
timer?.unref();
|
|
1654
|
+
let idleTimer;
|
|
1655
|
+
const resetIdleTimer = () => {
|
|
1656
|
+
if (!spec.idleTimeoutMs)
|
|
1657
|
+
return;
|
|
1658
|
+
if (idleTimer)
|
|
1659
|
+
clearTimeout(idleTimer);
|
|
1660
|
+
idleTimer = setTimeout(() => {
|
|
1661
|
+
idleTimedOut = true;
|
|
1662
|
+
if (child.pid)
|
|
1663
|
+
killProcessGroup(child.pid);
|
|
1664
|
+
}, spec.idleTimeoutMs);
|
|
1665
|
+
idleTimer.unref();
|
|
1666
|
+
};
|
|
1667
|
+
resetIdleTimer();
|
|
1668
|
+
child.stdout?.setEncoding("utf8");
|
|
1669
|
+
child.stderr?.setEncoding("utf8");
|
|
1670
|
+
child.stdout?.on("data", (chunk) => {
|
|
1671
|
+
stdout.append(chunk);
|
|
1672
|
+
resetIdleTimer();
|
|
1673
|
+
});
|
|
1674
|
+
child.stderr?.on("data", (chunk) => {
|
|
1675
|
+
stderr.append(chunk);
|
|
1676
|
+
resetIdleTimer();
|
|
1677
|
+
});
|
|
1678
|
+
try {
|
|
1679
|
+
const [code, signal] = await once(child, "exit");
|
|
1680
|
+
if (typeof code === "number")
|
|
1681
|
+
exitCode = code;
|
|
1682
|
+
if (signal)
|
|
1683
|
+
error = `terminated by ${signal}`;
|
|
1684
|
+
} catch (err) {
|
|
1685
|
+
error = err instanceof Error ? err.message : String(err);
|
|
1686
|
+
} finally {
|
|
1687
|
+
if (timer)
|
|
1688
|
+
clearTimeout(timer);
|
|
1689
|
+
if (idleTimer)
|
|
1690
|
+
clearTimeout(idleTimer);
|
|
1691
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
1692
|
+
}
|
|
1693
|
+
const fields = { exitCode, stdout: stdout.value(), stderr: stderr.value(), pid: child.pid };
|
|
1694
|
+
if (timedOut || idleTimedOut) {
|
|
1695
|
+
return timeoutResult(startedAt, idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`, fields);
|
|
1696
|
+
}
|
|
1697
|
+
if (error || exitCode !== 0) {
|
|
1698
|
+
return failureResult(startedAt, error ?? `remote process on ${machine.id} exited with code ${exitCode ?? "unknown"}`, fields);
|
|
1699
|
+
}
|
|
1700
|
+
return successResult(startedAt, fields);
|
|
1701
|
+
}
|
|
1702
|
+
function preflightTarget(target, metadata = {}, opts = {}) {
|
|
1703
|
+
const spec = commandSpec(target, opts);
|
|
1704
|
+
const machine = resolvedMachine(opts);
|
|
1705
|
+
if (machine && !machine.local) {
|
|
1706
|
+
preflightRemoteSpec(spec, machine, metadata, opts);
|
|
1707
|
+
return {
|
|
1708
|
+
command: spec.command,
|
|
1709
|
+
accountProfile: spec.account?.profile,
|
|
1710
|
+
accountTool: spec.accountTool
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
const env = executionEnvSync(spec, metadata, opts);
|
|
1714
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1715
|
+
throw new Error(commandNotFoundMessage(spec.command, env));
|
|
1716
|
+
}
|
|
1717
|
+
if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
|
|
1718
|
+
throw new Error(`none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
|
|
1719
|
+
}
|
|
1720
|
+
preflightNativeAuthProfileSync(spec, env);
|
|
1721
|
+
return {
|
|
1722
|
+
command: spec.command,
|
|
1723
|
+
accountProfile: spec.account?.profile,
|
|
1724
|
+
accountTool: spec.accountTool
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
async function executeTarget(target, metadata = {}, opts = {}) {
|
|
1728
|
+
let spec = commandSpec(target, opts);
|
|
1729
|
+
const machine = resolvedMachine(opts);
|
|
1730
|
+
if (machine && !machine.local && spec.codewithDurableAgent) {
|
|
1731
|
+
return failureResult(nowIso(), "remote Codewith durable background-agent steps require remote status polling support; run this Codewith step locally or add remote durable readback before dispatch");
|
|
1732
|
+
}
|
|
1733
|
+
if (machine && !machine.local) {
|
|
1734
|
+
const remoteFallbackSpec = spec.worktree?.enabled && spec.worktree.mode === "auto" ? worktreeFallbackSpec(target, opts, spec.worktree.originalCwd) : undefined;
|
|
1735
|
+
return executeRemoteSpec(spec, machine, metadata, opts, remoteFallbackSpec);
|
|
1736
|
+
}
|
|
1737
|
+
const maxOutputBytes = opts.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
|
|
1738
|
+
const startedAt = nowIso();
|
|
1739
|
+
const stdout = new BoundedOutputBuffer(maxOutputBytes);
|
|
1740
|
+
const stderr = new BoundedOutputBuffer(maxOutputBytes);
|
|
1741
|
+
let timedOut = false;
|
|
1742
|
+
let idleTimedOut = false;
|
|
1743
|
+
let exitCode;
|
|
1744
|
+
let error;
|
|
1745
|
+
const env = await executionEnv(spec, metadata, opts);
|
|
1746
|
+
if (!spec.shell && !executableExists(spec.command, env)) {
|
|
1747
|
+
return failureResult(startedAt, commandNotFoundMessage(spec.command, env));
|
|
1748
|
+
}
|
|
1749
|
+
if (spec.preflightAnyOf?.length && !spec.preflightAnyOf.some((command) => executableExists(command, env))) {
|
|
1750
|
+
return failureResult(startedAt, `none of required executables found: ${spec.preflightAnyOf.join(", ")}`);
|
|
1751
|
+
}
|
|
1752
|
+
try {
|
|
1753
|
+
await preflightNativeAuthProfile(spec, env);
|
|
1754
|
+
} catch (err) {
|
|
1755
|
+
return failureResult(startedAt, err instanceof Error ? err.message : String(err));
|
|
1756
|
+
}
|
|
1757
|
+
const worktreeEntry = await enterWorktree(spec, opts, env, startedAt);
|
|
1758
|
+
if (worktreeEntry?.failure)
|
|
1759
|
+
return worktreeEntry.failure;
|
|
1760
|
+
if (worktreeEntry?.fallbackCwd) {
|
|
1761
|
+
spec = worktreeFallbackSpec(target, opts, worktreeEntry.fallbackCwd) ?? spec;
|
|
1762
|
+
}
|
|
1763
|
+
if (spec.codewithDurableAgent) {
|
|
1764
|
+
return executeCodewithDurableAgent(spec, metadata, opts, env, startedAt);
|
|
1765
|
+
}
|
|
1766
|
+
const child = spawn2(spec.command, spec.args, {
|
|
1767
|
+
cwd: spec.cwd,
|
|
1768
|
+
env,
|
|
1769
|
+
shell: spec.shell ?? false,
|
|
1770
|
+
detached: true,
|
|
1771
|
+
stdio: spec.stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"]
|
|
1772
|
+
});
|
|
1773
|
+
notifySpawn(child.pid, opts);
|
|
1774
|
+
if (spec.stdin !== undefined && child.stdin) {
|
|
1775
|
+
child.stdin.on("error", (err) => {
|
|
1776
|
+
if (err.code !== "EPIPE")
|
|
1777
|
+
error = err.message;
|
|
1778
|
+
});
|
|
1779
|
+
child.stdin.end(spec.stdin);
|
|
1780
|
+
}
|
|
1781
|
+
const abortHandler = () => {
|
|
1782
|
+
error = "cancelled";
|
|
1783
|
+
if (child.pid)
|
|
1784
|
+
killProcessGroup(child.pid);
|
|
1785
|
+
};
|
|
1786
|
+
if (opts.signal?.aborted)
|
|
1787
|
+
abortHandler();
|
|
1788
|
+
opts.signal?.addEventListener("abort", abortHandler, { once: true });
|
|
1789
|
+
const timer = typeof spec.timeoutMs === "number" ? setTimeout(() => {
|
|
1790
|
+
timedOut = true;
|
|
1791
|
+
if (child.pid)
|
|
1792
|
+
killProcessGroup(child.pid);
|
|
1793
|
+
}, spec.timeoutMs) : undefined;
|
|
1794
|
+
timer?.unref();
|
|
1795
|
+
let idleTimer;
|
|
1796
|
+
const resetIdleTimer = () => {
|
|
1797
|
+
if (!spec.idleTimeoutMs)
|
|
1798
|
+
return;
|
|
1799
|
+
if (idleTimer)
|
|
1800
|
+
clearTimeout(idleTimer);
|
|
1801
|
+
idleTimer = setTimeout(() => {
|
|
1802
|
+
idleTimedOut = true;
|
|
1803
|
+
if (child.pid)
|
|
1804
|
+
killProcessGroup(child.pid);
|
|
1805
|
+
}, spec.idleTimeoutMs);
|
|
1806
|
+
idleTimer.unref();
|
|
1807
|
+
};
|
|
1808
|
+
resetIdleTimer();
|
|
1809
|
+
child.stdout?.setEncoding("utf8");
|
|
1810
|
+
child.stderr?.setEncoding("utf8");
|
|
1811
|
+
child.stdout?.on("data", (chunk) => {
|
|
1812
|
+
stdout.append(chunk);
|
|
1813
|
+
resetIdleTimer();
|
|
1814
|
+
});
|
|
1815
|
+
child.stderr?.on("data", (chunk) => {
|
|
1816
|
+
stderr.append(chunk);
|
|
1817
|
+
resetIdleTimer();
|
|
1818
|
+
});
|
|
1819
|
+
try {
|
|
1820
|
+
const [code, signal] = await once(child, "exit");
|
|
1821
|
+
if (typeof code === "number")
|
|
1822
|
+
exitCode = code;
|
|
1823
|
+
if (signal)
|
|
1824
|
+
error = `terminated by ${signal}`;
|
|
1825
|
+
} catch (err) {
|
|
1826
|
+
error = err instanceof Error ? err.message : String(err);
|
|
1827
|
+
} finally {
|
|
1828
|
+
if (timer)
|
|
1829
|
+
clearTimeout(timer);
|
|
1830
|
+
if (idleTimer)
|
|
1831
|
+
clearTimeout(idleTimer);
|
|
1832
|
+
opts.signal?.removeEventListener("abort", abortHandler);
|
|
1833
|
+
}
|
|
1834
|
+
const fields = { exitCode, stdout: stdout.value(), stderr: stderr.value(), pid: child.pid };
|
|
1835
|
+
if (timedOut || idleTimedOut) {
|
|
1836
|
+
return timeoutResult(startedAt, idleTimedOut ? `idle timed out after ${spec.idleTimeoutMs}ms without stdout/stderr` : `timed out after ${spec.timeoutMs}ms`, fields);
|
|
1837
|
+
}
|
|
1838
|
+
if (error || exitCode !== 0) {
|
|
1839
|
+
return failureResult(startedAt, error ?? `process exited with code ${exitCode ?? "unknown"}`, fields);
|
|
1840
|
+
}
|
|
1841
|
+
return successResult(startedAt, fields);
|
|
1842
|
+
}
|
|
1843
|
+
async function executeLoop(loop, run, opts = {}) {
|
|
1844
|
+
if (loop.target.type === "workflow") {
|
|
1845
|
+
throw new Error("workflow loop targets must be executed with executeLoopTarget");
|
|
1846
|
+
}
|
|
1847
|
+
if (loop.target.preflight?.beforeRun) {
|
|
1848
|
+
const startedAt = nowIso();
|
|
1849
|
+
try {
|
|
1850
|
+
preflightTarget(loop.target, {
|
|
1851
|
+
loopId: loop.id,
|
|
1852
|
+
loopName: loop.name,
|
|
1853
|
+
runId: run.id,
|
|
1854
|
+
scheduledFor: run.scheduledFor
|
|
1855
|
+
}, { ...opts, machine: opts.machine ?? loop.machine });
|
|
1856
|
+
} catch (error) {
|
|
1857
|
+
return failureResult(startedAt, `runtime preflight failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return executeTarget(loop.target, {
|
|
1861
|
+
loopId: loop.id,
|
|
1862
|
+
loopName: loop.name,
|
|
1863
|
+
runId: run.id,
|
|
1864
|
+
scheduledFor: run.scheduledFor
|
|
1865
|
+
}, { ...opts, machine: opts.machine ?? loop.machine });
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
// src/runner/index.ts
|
|
1869
|
+
var program = new Command;
|
|
1870
|
+
var DEFAULT_RUNNER_ID = `runner:${process.pid}`;
|
|
1871
|
+
var MIN_RUNNER_LEASE_MS = 1000;
|
|
1872
|
+
program.name("loops-runner").description("OpenLoops control-plane runner foundation").version(packageVersion()).option("-j, --json", "print JSON");
|
|
1873
|
+
function configuredApiUrl(env = process.env) {
|
|
1874
|
+
return env.LOOPS_API_URL?.trim() || env.HASNA_LOOPS_API_URL?.trim() || env.LOOPS_CLOUD_API_URL?.trim() || env.HASNA_LOOPS_CLOUD_API_URL?.trim();
|
|
1875
|
+
}
|
|
1876
|
+
function configuredApiToken(env = process.env) {
|
|
1877
|
+
return env.LOOPS_API_TOKEN?.trim() || env.HASNA_LOOPS_API_TOKEN?.trim() || env.LOOPS_CLOUD_TOKEN?.trim() || env.HASNA_LOOPS_CLOUD_TOKEN?.trim();
|
|
1878
|
+
}
|
|
1879
|
+
function isLocalApiUrl(value) {
|
|
1880
|
+
try {
|
|
1881
|
+
const url = new URL(value);
|
|
1882
|
+
return ["127.0.0.1", "localhost", "::1"].includes(url.hostname);
|
|
1883
|
+
} catch {
|
|
1884
|
+
return false;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
function runnerStatus(machineId = process.env.LOOPS_RUNNER_MACHINE_ID || process.env.HASNA_MACHINE_ID) {
|
|
1888
|
+
const deployment = buildDeploymentStatus();
|
|
1889
|
+
const local = deployment.deploymentMode === "local";
|
|
1890
|
+
const apiUrl = configuredApiUrl();
|
|
1891
|
+
const token = configuredApiToken();
|
|
1892
|
+
const apiReady = Boolean(apiUrl && (isLocalApiUrl(apiUrl) || token));
|
|
1893
|
+
return {
|
|
1894
|
+
ok: local || apiReady,
|
|
1895
|
+
service: "loops-runner",
|
|
1896
|
+
machineId,
|
|
1897
|
+
deployment,
|
|
1898
|
+
state: local ? "local_daemon_authoritative" : apiReady ? "control_plane_ready" : apiUrl ? "missing_control_plane_token" : "missing_control_plane_api_url"
|
|
1899
|
+
};
|
|
1900
|
+
}
|
|
1901
|
+
function resolveRunnerConfig(opts) {
|
|
1902
|
+
const env = opts.env ?? process.env;
|
|
1903
|
+
const apiUrl = opts.apiUrl ?? configuredApiUrl(env);
|
|
1904
|
+
if (!apiUrl)
|
|
1905
|
+
throw new Error("loops-runner requires LOOPS_API_URL or HASNA_LOOPS_API_URL");
|
|
1906
|
+
const token = opts.apiToken ?? configuredApiToken(env);
|
|
1907
|
+
if (!isLocalApiUrl(apiUrl) && !token)
|
|
1908
|
+
throw new Error("non-local loops-runner requires LOOPS_API_TOKEN or HASNA_LOOPS_API_TOKEN");
|
|
1909
|
+
return {
|
|
1910
|
+
apiUrl,
|
|
1911
|
+
token,
|
|
1912
|
+
runnerId: opts.runnerId ?? env.LOOPS_RUNNER_ID ?? env.LOOPS_RUNNER_MACHINE_ID ?? env.HASNA_MACHINE_ID ?? DEFAULT_RUNNER_ID,
|
|
1913
|
+
machineId: opts.machineId ?? env.LOOPS_RUNNER_MACHINE_ID ?? env.HASNA_MACHINE_ID
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
function endpoint(base, path) {
|
|
1917
|
+
return new URL(path.replace(/^\//, ""), base.endsWith("/") ? base : `${base}/`).toString();
|
|
1918
|
+
}
|
|
1919
|
+
async function postJson(fetchImpl, config, path, body) {
|
|
1920
|
+
const response = await fetchImpl(endpoint(config.apiUrl, path), {
|
|
1921
|
+
method: "POST",
|
|
1922
|
+
headers: {
|
|
1923
|
+
"content-type": "application/json",
|
|
1924
|
+
...config.token ? { authorization: `Bearer ${config.token}` } : {}
|
|
1925
|
+
},
|
|
1926
|
+
body: JSON.stringify(body)
|
|
1927
|
+
});
|
|
1928
|
+
const payload = await response.json().catch(() => ({}));
|
|
1929
|
+
if (!response.ok)
|
|
1930
|
+
throw new Error(typeof payload.error === "string" ? payload.error : `loops-api request failed: ${response.status}`);
|
|
1931
|
+
return payload;
|
|
1932
|
+
}
|
|
1933
|
+
async function runRunnerOnce(opts = {}) {
|
|
1934
|
+
const config = resolveRunnerConfig(opts);
|
|
1935
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
1936
|
+
const runnerBody = {
|
|
1937
|
+
runnerId: config.runnerId,
|
|
1938
|
+
machineId: config.machineId,
|
|
1939
|
+
now: (opts.now ?? new Date).toISOString(),
|
|
1940
|
+
maxClaims: 1
|
|
1941
|
+
};
|
|
1942
|
+
await postJson(fetchImpl, config, "/v1/runners/register", runnerBody);
|
|
1943
|
+
const claimed = await postJson(fetchImpl, config, "/v1/runners/claim", runnerBody);
|
|
1944
|
+
const claims = Array.isArray(claimed.claims) ? claimed.claims : [];
|
|
1945
|
+
const completed = [];
|
|
1946
|
+
for (const claim of claims) {
|
|
1947
|
+
const result = await executeClaimWithHeartbeat(fetchImpl, config, claim, opts);
|
|
1948
|
+
const finalized = await postJson(fetchImpl, config, `/v1/runs/${claim.run.id}/finalize`, {
|
|
1949
|
+
claimToken: claim.claimToken,
|
|
1950
|
+
status: result.status,
|
|
1951
|
+
finishedAt: result.finishedAt,
|
|
1952
|
+
durationMs: result.durationMs,
|
|
1953
|
+
stdout: result.stdout,
|
|
1954
|
+
stderr: result.stderr,
|
|
1955
|
+
error: result.error,
|
|
1956
|
+
exitCode: result.exitCode,
|
|
1957
|
+
pid: result.pid
|
|
1958
|
+
});
|
|
1959
|
+
const run = finalized.run ?? claim.run;
|
|
1960
|
+
completed.push(run);
|
|
1961
|
+
}
|
|
1962
|
+
return { ok: completed.every((run) => run.status === "succeeded"), claimed: claims.length, completed };
|
|
1963
|
+
}
|
|
1964
|
+
async function executeClaimWithHeartbeat(fetchImpl, config, claim, opts) {
|
|
1965
|
+
const execute = opts.execute ?? executeLoop;
|
|
1966
|
+
const leaseMs = runnerLeaseMs(claim.loop.leaseMs);
|
|
1967
|
+
const heartbeatIntervalMs = runnerHeartbeatIntervalMs(leaseMs, opts.heartbeatIntervalMs);
|
|
1968
|
+
const heartbeat = async () => {
|
|
1969
|
+
await postJson(fetchImpl, config, `/v1/runs/${claim.run.id}/heartbeat`, {
|
|
1970
|
+
claimToken: claim.claimToken,
|
|
1971
|
+
leaseMs
|
|
1972
|
+
});
|
|
1973
|
+
};
|
|
1974
|
+
await heartbeat();
|
|
1975
|
+
let lastHeartbeatError;
|
|
1976
|
+
const timer = setInterval(() => {
|
|
1977
|
+
heartbeat().catch((error) => {
|
|
1978
|
+
lastHeartbeatError = error;
|
|
1979
|
+
});
|
|
1980
|
+
}, heartbeatIntervalMs);
|
|
1981
|
+
try {
|
|
1982
|
+
const result = await execute(claim.loop, claim.run);
|
|
1983
|
+
return result;
|
|
1984
|
+
} finally {
|
|
1985
|
+
clearInterval(timer);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
function runnerLeaseMs(leaseMs) {
|
|
1989
|
+
return Math.max(MIN_RUNNER_LEASE_MS, leaseMs);
|
|
1990
|
+
}
|
|
1991
|
+
function runnerHeartbeatIntervalMs(leaseMs, configured) {
|
|
1992
|
+
const boundedLeaseMs = Number.isFinite(leaseMs) && leaseMs > 0 ? leaseMs : 30000;
|
|
1993
|
+
const safeDefault = Math.max(1, Math.floor(boundedLeaseMs / 2));
|
|
1994
|
+
const requested = configured === undefined ? safeDefault : Math.max(1, Math.floor(configured));
|
|
1995
|
+
return Math.min(30000, safeDefault, requested);
|
|
1996
|
+
}
|
|
1997
|
+
function wantsJson(opts) {
|
|
1998
|
+
return Boolean(program.opts().json || opts?.json);
|
|
1999
|
+
}
|
|
2000
|
+
function printStatus(opts) {
|
|
2001
|
+
const status = runnerStatus();
|
|
2002
|
+
if (wantsJson(opts))
|
|
2003
|
+
console.log(JSON.stringify(status, null, 2));
|
|
2004
|
+
else
|
|
2005
|
+
console.log(`${deploymentStatusLine(status.deployment)} runner=${status.state}${status.machineId ? ` machine=${status.machineId}` : ""}`);
|
|
2006
|
+
if (!status.ok)
|
|
2007
|
+
process.exitCode = 1;
|
|
2008
|
+
}
|
|
2009
|
+
async function main(argv = process.argv) {
|
|
2010
|
+
await program.parseAsync(argv);
|
|
2011
|
+
}
|
|
2012
|
+
program.action(() => printStatus());
|
|
2013
|
+
program.command("status").option("-j, --json", "print JSON").action((opts) => printStatus(opts));
|
|
2014
|
+
program.command("run-once").description("claim and execute one control-plane run").option("--api-url <url>", "control-plane API URL").option("--runner-id <id>", "runner id").option("--machine-id <id>", "machine id").option("-j, --json", "print JSON").action(async (opts) => {
|
|
2015
|
+
const result = await runRunnerOnce({
|
|
2016
|
+
apiUrl: opts.apiUrl,
|
|
2017
|
+
runnerId: opts.runnerId,
|
|
2018
|
+
machineId: opts.machineId
|
|
2019
|
+
});
|
|
2020
|
+
if (wantsJson(opts))
|
|
2021
|
+
console.log(JSON.stringify(result, null, 2));
|
|
2022
|
+
else
|
|
2023
|
+
console.log(`claimed=${result.claimed} completed=${result.completed.length}`);
|
|
2024
|
+
if (!result.ok)
|
|
2025
|
+
process.exitCode = 1;
|
|
2026
|
+
});
|
|
2027
|
+
if (import.meta.main) {
|
|
2028
|
+
main().catch((error) => {
|
|
2029
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
2030
|
+
process.exit(1);
|
|
2031
|
+
});
|
|
2032
|
+
}
|
|
2033
|
+
export {
|
|
2034
|
+
runnerStatus,
|
|
2035
|
+
runRunnerOnce,
|
|
2036
|
+
main
|
|
2037
|
+
};
|