@kici-dev/agent 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/execution/download.d.ts +27 -2
- package/dist/execution/generator-context.d.ts +54 -0
- package/dist/execution/global-eval-runner.d.ts +92 -0
- package/dist/execution/global-workflow-env.d.ts +57 -0
- package/dist/execution/init-runner.d.ts +60 -3
- package/dist/execution/job-runner.d.ts +98 -0
- package/dist/execution/sandbox/step-loop.d.ts +10 -1
- package/dist/execution/sandbox/workflow-runner.d.ts +52 -1
- package/dist/execution/workflow-loader.d.ts +8 -1
- package/dist/index.js +89 -19
- package/dist/metrics/prometheus.d.ts +10 -10
- package/dist/server.js +1155 -158
- package/dist/workflow-runner-bundle.js +5052 -4591
- package/dist/workflow-runner.js +269 -67
- package/package.json +5 -5
- package/sbom.spdx.json +69 -64
package/dist/server.js
CHANGED
|
@@ -27,7 +27,8 @@ import { gcStaleTmpDirs } from "@kici-dev/core/tmp-gc";
|
|
|
27
27
|
import fsPromises, { access, lstat, mkdir, readFile, readdir, realpath, unlink, writeFile } from "node:fs/promises";
|
|
28
28
|
import { makeTempDir } from "@kici-dev/core/tmp";
|
|
29
29
|
import Docker from "dockerode";
|
|
30
|
-
import { buildKiciApi, buildNeedsContext, isDynamicGroupRef, isDynamicJobFn, isParallelGroup, isStaticArray, isStaticObject, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
30
|
+
import { buildKiciApi, buildNeedsContext, createFilterContext, isDynamicGroupRef, isDynamicJobFn, isParallelGroup, isStaticArray, isStaticObject, setJobOutputsMap, setStepOutputsMap, setStepRefMap } from "@kici-dev/sdk";
|
|
31
|
+
import { $ } from "zx";
|
|
31
32
|
import { c, x } from "tar";
|
|
32
33
|
import https from "node:https";
|
|
33
34
|
import http from "node:http";
|
|
@@ -324,7 +325,7 @@ var LogBuffer = class extends RingBuffer {
|
|
|
324
325
|
};
|
|
325
326
|
//#endregion
|
|
326
327
|
//#region src/ws/orchestrator-client.ts
|
|
327
|
-
const logger$
|
|
328
|
+
const logger$13 = createLogger({ prefix: "orchestrator-client" });
|
|
328
329
|
/**
|
|
329
330
|
* How long to wait for `artifacts.upload.complete.ack` before failing the step.
|
|
330
331
|
*
|
|
@@ -484,7 +485,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
484
485
|
*/
|
|
485
486
|
connect() {
|
|
486
487
|
if (this._state !== "disconnected") {
|
|
487
|
-
logger$
|
|
488
|
+
logger$13.warn("connect() called while not disconnected", { state: this._state });
|
|
488
489
|
return;
|
|
489
490
|
}
|
|
490
491
|
this.intentionalDisconnect = false;
|
|
@@ -1048,7 +1049,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1048
1049
|
}
|
|
1049
1050
|
});
|
|
1050
1051
|
} catch (err) {
|
|
1051
|
-
logger$
|
|
1052
|
+
logger$13.error("Failed to create WebSocket", { error: toErrorMessage(err) });
|
|
1052
1053
|
this._state = "disconnected";
|
|
1053
1054
|
this.scheduleReconnect();
|
|
1054
1055
|
return;
|
|
@@ -1056,7 +1057,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1056
1057
|
this.ws.on("open", () => {
|
|
1057
1058
|
if (this.token) {
|
|
1058
1059
|
this._state = "authenticating";
|
|
1059
|
-
logger$
|
|
1060
|
+
logger$13.info("Connected to orchestrator, sending auth.request", {
|
|
1060
1061
|
url: this.url,
|
|
1061
1062
|
agentId: this.agentId
|
|
1062
1063
|
});
|
|
@@ -1067,7 +1068,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1067
1068
|
}));
|
|
1068
1069
|
} else {
|
|
1069
1070
|
this._state = "registering";
|
|
1070
|
-
logger$
|
|
1071
|
+
logger$13.info("Connected to orchestrator, sending agent.register (no token)", {
|
|
1071
1072
|
url: this.url,
|
|
1072
1073
|
agentId: this.agentId
|
|
1073
1074
|
});
|
|
@@ -1078,12 +1079,12 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1078
1079
|
this.handleMessage(data);
|
|
1079
1080
|
});
|
|
1080
1081
|
this.ws.on("close", (code, reason) => {
|
|
1081
|
-
logger$
|
|
1082
|
+
logger$13.info("Orchestrator connection closed", {
|
|
1082
1083
|
code,
|
|
1083
1084
|
reason: reason.toString()
|
|
1084
1085
|
});
|
|
1085
1086
|
if (code === WS_CLOSE_AGENT_AUTH_FAILED) {
|
|
1086
|
-
logger$
|
|
1087
|
+
logger$13.error("Orchestrator closed with auth-failed code -- token is invalid or revoked. NOT retrying.", {
|
|
1087
1088
|
code,
|
|
1088
1089
|
reason: reason.toString()
|
|
1089
1090
|
});
|
|
@@ -1120,7 +1121,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1120
1121
|
if (!this.intentionalDisconnect) this.scheduleReconnect();
|
|
1121
1122
|
});
|
|
1122
1123
|
this.ws.on("error", (err) => {
|
|
1123
|
-
logger$
|
|
1124
|
+
logger$13.error(`Orchestrator WebSocket error: ${err.message}`);
|
|
1124
1125
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.close();
|
|
1125
1126
|
});
|
|
1126
1127
|
}
|
|
@@ -1228,7 +1229,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1228
1229
|
this.pendingUserArtifactRequests.set(record.messageId, record.pending);
|
|
1229
1230
|
this.resendableCompletes.set(record.messageId, record);
|
|
1230
1231
|
this.sendDirect(record.frame);
|
|
1231
|
-
logger$
|
|
1232
|
+
logger$13.info("Re-sent artifact upload-complete after reconnect", {
|
|
1232
1233
|
messageId: record.messageId,
|
|
1233
1234
|
attempt: record.attempts
|
|
1234
1235
|
});
|
|
@@ -1255,7 +1256,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1255
1256
|
try {
|
|
1256
1257
|
raw = JSON.parse(data.toString());
|
|
1257
1258
|
} catch {
|
|
1258
|
-
logger$
|
|
1259
|
+
logger$13.warn("Malformed JSON received from orchestrator");
|
|
1259
1260
|
return;
|
|
1260
1261
|
}
|
|
1261
1262
|
const rawMsg = raw;
|
|
@@ -1314,13 +1315,13 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1314
1315
|
switch (msg.type) {
|
|
1315
1316
|
case "auth.success":
|
|
1316
1317
|
if (this._state === "authenticating") {
|
|
1317
|
-
logger$
|
|
1318
|
+
logger$13.info("Authentication successful, sending agent.register", { connectionId: msg.connectionId });
|
|
1318
1319
|
this._state = "registering";
|
|
1319
1320
|
this.sendAgentRegister();
|
|
1320
1321
|
}
|
|
1321
1322
|
break;
|
|
1322
1323
|
case "auth.failure":
|
|
1323
|
-
logger$
|
|
1324
|
+
logger$13.error("Authentication FAILED -- token is invalid or expired. NOT retrying.", { reason: msg.reason });
|
|
1324
1325
|
this.authFailed = true;
|
|
1325
1326
|
this.intentionalDisconnect = true;
|
|
1326
1327
|
if (this.ws) {
|
|
@@ -1330,7 +1331,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1330
1331
|
this._state = "disconnected";
|
|
1331
1332
|
break;
|
|
1332
1333
|
case "register.ack":
|
|
1333
|
-
logger$
|
|
1334
|
+
logger$13.info("Registration acknowledged by orchestrator", {
|
|
1334
1335
|
agentId: msg.agentId,
|
|
1335
1336
|
labels: msg.labels,
|
|
1336
1337
|
scalerManaged: msg.scalerManaged,
|
|
@@ -1347,14 +1348,14 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1347
1348
|
this.sendConfigAck(msg.agentId);
|
|
1348
1349
|
break;
|
|
1349
1350
|
case "job.dispatch":
|
|
1350
|
-
logger$
|
|
1351
|
+
logger$13.info("Job dispatch received", {
|
|
1351
1352
|
runId: msg.runId,
|
|
1352
1353
|
jobId: msg.jobId
|
|
1353
1354
|
});
|
|
1354
1355
|
this.onJobDispatch(msg);
|
|
1355
1356
|
break;
|
|
1356
1357
|
case "job.cancel":
|
|
1357
|
-
logger$
|
|
1358
|
+
logger$13.info("Job cancel received", {
|
|
1358
1359
|
runId: msg.runId,
|
|
1359
1360
|
jobId: msg.jobId,
|
|
1360
1361
|
reason: msg.reason
|
|
@@ -1362,7 +1363,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1362
1363
|
this.onJobCancel(msg);
|
|
1363
1364
|
break;
|
|
1364
1365
|
case "job.concurrency.ack": {
|
|
1365
|
-
logger$
|
|
1366
|
+
logger$13.info("Concurrency ack received", {
|
|
1366
1367
|
requestId: msg.requestId,
|
|
1367
1368
|
action: msg.action
|
|
1368
1369
|
});
|
|
@@ -1377,7 +1378,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1377
1378
|
break;
|
|
1378
1379
|
}
|
|
1379
1380
|
case "step.approval-resolved": {
|
|
1380
|
-
logger$
|
|
1381
|
+
logger$13.info("Step approval resolved", {
|
|
1381
1382
|
requestId: msg.requestId,
|
|
1382
1383
|
runId: msg.runId,
|
|
1383
1384
|
jobId: msg.jobId,
|
|
@@ -1397,7 +1398,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1397
1398
|
break;
|
|
1398
1399
|
}
|
|
1399
1400
|
case "fleet.logs.request":
|
|
1400
|
-
logger$
|
|
1401
|
+
logger$13.info("Fleet log collection requested", {
|
|
1401
1402
|
requestId: msg.requestId,
|
|
1402
1403
|
logWindowHours: msg.logWindowHours
|
|
1403
1404
|
});
|
|
@@ -1407,7 +1408,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1407
1408
|
return;
|
|
1408
1409
|
}
|
|
1409
1410
|
if (heartbeatSchema.safeParse(raw).success) return;
|
|
1410
|
-
logger$
|
|
1411
|
+
logger$13.warn("Invalid message from orchestrator", { errors: parsed.error.issues });
|
|
1411
1412
|
}
|
|
1412
1413
|
flushBuffer() {
|
|
1413
1414
|
const events = this.eventBuffer.flush();
|
|
@@ -1421,11 +1422,11 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1421
1422
|
}
|
|
1422
1423
|
this.disconnectedAt = null;
|
|
1423
1424
|
if (events.length > 0) {
|
|
1424
|
-
logger$
|
|
1425
|
+
logger$13.info("Flushing event buffer", { count: events.length });
|
|
1425
1426
|
for (const msg of events) this.sendDirect(msg);
|
|
1426
1427
|
}
|
|
1427
1428
|
if (logLines.length > 0) {
|
|
1428
|
-
logger$
|
|
1429
|
+
logger$13.info("Flushing log buffer", { count: logLines.length });
|
|
1429
1430
|
for (let i = 0; i < logLines.length; i += OrchestratorClient.LOG_BATCH_SIZE) {
|
|
1430
1431
|
const batch = logLines.slice(i, i + OrchestratorClient.LOG_BATCH_SIZE);
|
|
1431
1432
|
this.sendAgentLogMessage(batch);
|
|
@@ -1478,14 +1479,14 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1478
1479
|
*/
|
|
1479
1480
|
blockMmdsAccess() {
|
|
1480
1481
|
if (process.getuid?.() !== 0) {
|
|
1481
|
-
logger$
|
|
1482
|
+
logger$13.info("MMDS iptables block skipped (non-root) — network isolation handled by orchestrator");
|
|
1482
1483
|
return;
|
|
1483
1484
|
}
|
|
1484
1485
|
try {
|
|
1485
1486
|
execSync("iptables -A OUTPUT -d 169.254.169.254 -j DROP", { timeout: 5e3 });
|
|
1486
|
-
logger$
|
|
1487
|
+
logger$13.info("MMDS access blocked via iptables");
|
|
1487
1488
|
} catch (err) {
|
|
1488
|
-
logger$
|
|
1489
|
+
logger$13.warn("Failed to block MMDS access via iptables", { error: toErrorMessage(err) });
|
|
1489
1490
|
}
|
|
1490
1491
|
}
|
|
1491
1492
|
/**
|
|
@@ -1499,7 +1500,7 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1499
1500
|
messageId: `config-ack-${agentId}-${Date.now()}`,
|
|
1500
1501
|
agentId
|
|
1501
1502
|
}));
|
|
1502
|
-
logger$
|
|
1503
|
+
logger$13.info("Config ACK sent to orchestrator", { agentId });
|
|
1503
1504
|
}
|
|
1504
1505
|
}
|
|
1505
1506
|
startHeartbeat() {
|
|
@@ -1565,12 +1566,12 @@ var OrchestratorClient = class OrchestratorClient {
|
|
|
1565
1566
|
scheduleReconnect() {
|
|
1566
1567
|
this.cancelReconnect();
|
|
1567
1568
|
if (this.authFailed) {
|
|
1568
|
-
logger$
|
|
1569
|
+
logger$13.error("Not reconnecting: authentication permanently failed");
|
|
1569
1570
|
return;
|
|
1570
1571
|
}
|
|
1571
1572
|
const delay = this.getReconnectDelay();
|
|
1572
1573
|
this.reconnectAttempts++;
|
|
1573
|
-
logger$
|
|
1574
|
+
logger$13.info("Scheduling reconnect", {
|
|
1574
1575
|
attempt: this.reconnectAttempts,
|
|
1575
1576
|
delayMs: Math.round(delay)
|
|
1576
1577
|
});
|
|
@@ -1655,14 +1656,14 @@ var init_console_capture = __esmMin((() => {
|
|
|
1655
1656
|
init_console_capture();
|
|
1656
1657
|
function safe(name, fallback = "unknown") {
|
|
1657
1658
|
switch (name) {
|
|
1658
|
-
case "version": return "0.
|
|
1659
|
-
case "buildCommit": return "
|
|
1660
|
-
case "sdkVersion": return "0.
|
|
1661
|
-
case "sdkBundleHash": return "
|
|
1662
|
-
case "sharedVersion": return "0.
|
|
1663
|
-
case "sharedBundleHash": return "
|
|
1664
|
-
case "engineVersion": return "0.
|
|
1665
|
-
case "engineBundleHash": return "
|
|
1659
|
+
case "version": return "0.5.0";
|
|
1660
|
+
case "buildCommit": return "cb51c7d1e";
|
|
1661
|
+
case "sdkVersion": return "0.5.0";
|
|
1662
|
+
case "sdkBundleHash": return "5b85e7cffa4a08e39329ff80448a2744af8e5b5f9840ec3801fad45ed11a3de9";
|
|
1663
|
+
case "sharedVersion": return "0.5.0";
|
|
1664
|
+
case "sharedBundleHash": return "b6b6d1818d1fe648150422daf073dafc18b037404cacf82e0ec03b4a2863e4c4";
|
|
1665
|
+
case "engineVersion": return "0.5.0";
|
|
1666
|
+
case "engineBundleHash": return "35d174ce6f4748abffcad194b335b508cade5d1c8af16d9cd3bdcb744f38717e";
|
|
1666
1667
|
default: return fallback;
|
|
1667
1668
|
}
|
|
1668
1669
|
}
|
|
@@ -1947,7 +1948,7 @@ async function gcStaleAgentTmpDirs(base = kiciTmpBase()) {
|
|
|
1947
1948
|
* spawn fails and the caller clears the orchestrator's reboot-pending flag and
|
|
1948
1949
|
* surfaces the error — the deadline sweep is the backstop.
|
|
1949
1950
|
*/
|
|
1950
|
-
const logger$
|
|
1951
|
+
const logger$12 = createLogger({ prefix: "reboot" });
|
|
1951
1952
|
/** The OS reboot primitive for a Node platform string. */
|
|
1952
1953
|
function rebootCommandFor(platform) {
|
|
1953
1954
|
switch (platform) {
|
|
@@ -1985,7 +1986,7 @@ function issueReboot(platform = process.platform) {
|
|
|
1985
1986
|
stdio: "ignore"
|
|
1986
1987
|
});
|
|
1987
1988
|
child.on("error", (err) => {
|
|
1988
|
-
logger$
|
|
1989
|
+
logger$12.error("Reboot command failed to spawn", {
|
|
1989
1990
|
cmd,
|
|
1990
1991
|
args,
|
|
1991
1992
|
error: String(err)
|
|
@@ -1993,14 +1994,14 @@ function issueReboot(platform = process.platform) {
|
|
|
1993
1994
|
reject(err);
|
|
1994
1995
|
});
|
|
1995
1996
|
child.on("exit", (code) => {
|
|
1996
|
-
if (code !== 0 && code !== null) logger$
|
|
1997
|
+
if (code !== 0 && code !== null) logger$12.warn("Reboot command exited non-zero (privilege denied?)", {
|
|
1997
1998
|
cmd,
|
|
1998
1999
|
args,
|
|
1999
2000
|
code
|
|
2000
2001
|
});
|
|
2001
2002
|
});
|
|
2002
2003
|
child.unref();
|
|
2003
|
-
logger$
|
|
2004
|
+
logger$12.info("Issued host reboot", {
|
|
2004
2005
|
cmd,
|
|
2005
2006
|
args
|
|
2006
2007
|
});
|
|
@@ -2259,6 +2260,229 @@ var init_git_clone = __esmMin((() => {
|
|
|
2259
2260
|
init_ssh_auth();
|
|
2260
2261
|
}));
|
|
2261
2262
|
//#endregion
|
|
2263
|
+
//#region src/checkout/changed-files.ts
|
|
2264
|
+
/** Build the auth context for the fetches, mirroring git-clone.ts's auth. */
|
|
2265
|
+
async function buildAuthCtx(auth) {
|
|
2266
|
+
if (!auth) return { args: [] };
|
|
2267
|
+
if (auth.kind === "basic") {
|
|
2268
|
+
const user = auth.user ?? "x-access-token";
|
|
2269
|
+
return { args: ["-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`${user}:${auth.secret}`).toString("base64")}`] };
|
|
2270
|
+
}
|
|
2271
|
+
const sshSetup = await setupSshAuth({
|
|
2272
|
+
privateKey: auth.secret,
|
|
2273
|
+
hostKeyPolicy: auth.sshHostKeyPolicy,
|
|
2274
|
+
knownHosts: auth.sshKnownHostsPem
|
|
2275
|
+
});
|
|
2276
|
+
return {
|
|
2277
|
+
args: [],
|
|
2278
|
+
env: { GIT_SSH_COMMAND: sshSetup.gitSshCommand },
|
|
2279
|
+
cleanup: () => sshSetup.cleanup()
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
function git(workDir, args, ctx) {
|
|
2283
|
+
return execFileSync("git", [
|
|
2284
|
+
...ctx.args,
|
|
2285
|
+
...BASE_GIT_ARGS,
|
|
2286
|
+
"-C",
|
|
2287
|
+
workDir,
|
|
2288
|
+
...args
|
|
2289
|
+
], {
|
|
2290
|
+
encoding: "utf8",
|
|
2291
|
+
stdio: [
|
|
2292
|
+
"ignore",
|
|
2293
|
+
"pipe",
|
|
2294
|
+
"pipe"
|
|
2295
|
+
],
|
|
2296
|
+
...ctx.env && { env: {
|
|
2297
|
+
...process.env,
|
|
2298
|
+
...ctx.env
|
|
2299
|
+
} }
|
|
2300
|
+
});
|
|
2301
|
+
}
|
|
2302
|
+
function tryGit(workDir, args, ctx) {
|
|
2303
|
+
try {
|
|
2304
|
+
git(workDir, args, ctx);
|
|
2305
|
+
return true;
|
|
2306
|
+
} catch {
|
|
2307
|
+
return false;
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
function parseNameOnly(out) {
|
|
2311
|
+
return out.split("\n").map((s) => s.replace(/\r$/, "")).filter((s) => s.length > 0);
|
|
2312
|
+
}
|
|
2313
|
+
/** Ensure `commitish` exists locally; fetch / deepen (bounded) if not. */
|
|
2314
|
+
function ensureCommit(workDir, commitish, ctx) {
|
|
2315
|
+
if (tryGit(workDir, [
|
|
2316
|
+
"cat-file",
|
|
2317
|
+
"-e",
|
|
2318
|
+
`${commitish}^{commit}`
|
|
2319
|
+
], ctx)) return true;
|
|
2320
|
+
if (tryGit(workDir, [
|
|
2321
|
+
"fetch",
|
|
2322
|
+
"--depth",
|
|
2323
|
+
"1",
|
|
2324
|
+
"origin",
|
|
2325
|
+
commitish
|
|
2326
|
+
], ctx)) {
|
|
2327
|
+
if (tryGit(workDir, [
|
|
2328
|
+
"cat-file",
|
|
2329
|
+
"-e",
|
|
2330
|
+
`${commitish}^{commit}`
|
|
2331
|
+
], ctx)) return true;
|
|
2332
|
+
}
|
|
2333
|
+
for (let i = 0; i < MAX_DEEPEN; i++) {
|
|
2334
|
+
if (!tryGit(workDir, [
|
|
2335
|
+
"fetch",
|
|
2336
|
+
`--deepen=${DEEPEN_STEP}`,
|
|
2337
|
+
"origin"
|
|
2338
|
+
], ctx)) break;
|
|
2339
|
+
if (tryGit(workDir, [
|
|
2340
|
+
"cat-file",
|
|
2341
|
+
"-e",
|
|
2342
|
+
`${commitish}^{commit}`
|
|
2343
|
+
], ctx)) return true;
|
|
2344
|
+
}
|
|
2345
|
+
return false;
|
|
2346
|
+
}
|
|
2347
|
+
function pushDiff(workDir, before, ctx) {
|
|
2348
|
+
const isZero = !before || ZERO_SHA.test(before);
|
|
2349
|
+
const baseRef = isZero ? EMPTY_TREE_SHA : before;
|
|
2350
|
+
if (!isZero && !ensureCommit(workDir, before, ctx)) return {
|
|
2351
|
+
files: [],
|
|
2352
|
+
status: "unavailable"
|
|
2353
|
+
};
|
|
2354
|
+
return {
|
|
2355
|
+
files: parseNameOnly(git(workDir, [
|
|
2356
|
+
"diff",
|
|
2357
|
+
"--name-only",
|
|
2358
|
+
baseRef,
|
|
2359
|
+
"HEAD"
|
|
2360
|
+
], ctx)),
|
|
2361
|
+
status: "fetched"
|
|
2362
|
+
};
|
|
2363
|
+
}
|
|
2364
|
+
function prDiff(workDir, base, ctx) {
|
|
2365
|
+
const candidates = [
|
|
2366
|
+
base,
|
|
2367
|
+
`origin/${base}`,
|
|
2368
|
+
"FETCH_HEAD"
|
|
2369
|
+
];
|
|
2370
|
+
const resolveBase = () => candidates.find((c) => tryGit(workDir, [
|
|
2371
|
+
"rev-parse",
|
|
2372
|
+
"--verify",
|
|
2373
|
+
`${c}^{commit}`
|
|
2374
|
+
], ctx));
|
|
2375
|
+
let baseRef = resolveBase();
|
|
2376
|
+
if (!baseRef) {
|
|
2377
|
+
if (!ensureCommit(workDir, base, ctx)) return {
|
|
2378
|
+
files: [],
|
|
2379
|
+
status: "unavailable"
|
|
2380
|
+
};
|
|
2381
|
+
baseRef = resolveBase();
|
|
2382
|
+
}
|
|
2383
|
+
if (!baseRef) return {
|
|
2384
|
+
files: [],
|
|
2385
|
+
status: "unavailable"
|
|
2386
|
+
};
|
|
2387
|
+
for (let i = 0; i <= MAX_DEEPEN; i++) {
|
|
2388
|
+
if (tryGit(workDir, [
|
|
2389
|
+
"merge-base",
|
|
2390
|
+
baseRef,
|
|
2391
|
+
"HEAD"
|
|
2392
|
+
], ctx)) return {
|
|
2393
|
+
files: parseNameOnly(git(workDir, [
|
|
2394
|
+
"diff",
|
|
2395
|
+
"--name-only",
|
|
2396
|
+
`${baseRef}...HEAD`
|
|
2397
|
+
], ctx)),
|
|
2398
|
+
status: "fetched"
|
|
2399
|
+
};
|
|
2400
|
+
if (!tryGit(workDir, [
|
|
2401
|
+
"fetch",
|
|
2402
|
+
`--deepen=${DEEPEN_STEP}`,
|
|
2403
|
+
"origin"
|
|
2404
|
+
], ctx)) break;
|
|
2405
|
+
}
|
|
2406
|
+
return {
|
|
2407
|
+
files: [],
|
|
2408
|
+
status: "unavailable"
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Compute the changed-files list from the agent's local clone (HEAD is the
|
|
2413
|
+
* checked-out head commit). Ground truth for job/step rule evaluation. `auth`
|
|
2414
|
+
* (the same credentials used for the clone) authenticates the deepen / fetch
|
|
2415
|
+
* calls so a private remote resolves. Returns `unavailable` for diff-less
|
|
2416
|
+
* events (schedule/tag/manual) or any git failure — never throws.
|
|
2417
|
+
*/
|
|
2418
|
+
async function computeChangedFiles(workDir, event, auth) {
|
|
2419
|
+
let ctx;
|
|
2420
|
+
try {
|
|
2421
|
+
if (event.type !== "push" && event.type !== "pull_request") return {
|
|
2422
|
+
files: [],
|
|
2423
|
+
status: "unavailable"
|
|
2424
|
+
};
|
|
2425
|
+
ctx = await buildAuthCtx(auth);
|
|
2426
|
+
if (event.type === "push") return pushDiff(workDir, event.payload?.before ?? "", ctx);
|
|
2427
|
+
const base = event.baseBranch ?? event.targetBranch;
|
|
2428
|
+
if (!base) return {
|
|
2429
|
+
files: [],
|
|
2430
|
+
status: "unavailable"
|
|
2431
|
+
};
|
|
2432
|
+
return prDiff(workDir, base, ctx);
|
|
2433
|
+
} catch {
|
|
2434
|
+
return {
|
|
2435
|
+
files: [],
|
|
2436
|
+
status: "unavailable"
|
|
2437
|
+
};
|
|
2438
|
+
} finally {
|
|
2439
|
+
if (ctx?.cleanup) await ctx.cleanup().catch(() => {});
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
var EMPTY_TREE_SHA, ZERO_SHA, MAX_DEEPEN, DEEPEN_STEP, BASE_GIT_ARGS;
|
|
2443
|
+
var init_changed_files = __esmMin((() => {
|
|
2444
|
+
init_ssh_auth();
|
|
2445
|
+
EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
2446
|
+
ZERO_SHA = /^0+$/;
|
|
2447
|
+
MAX_DEEPEN = 4;
|
|
2448
|
+
DEEPEN_STEP = 50;
|
|
2449
|
+
BASE_GIT_ARGS = [
|
|
2450
|
+
"-c",
|
|
2451
|
+
"safe.directory=*",
|
|
2452
|
+
"-c",
|
|
2453
|
+
"core.quotePath=false"
|
|
2454
|
+
];
|
|
2455
|
+
}));
|
|
2456
|
+
//#endregion
|
|
2457
|
+
//#region src/execution/generator-context.ts
|
|
2458
|
+
/**
|
|
2459
|
+
* Build the context handed to a `DynamicJobFn`.
|
|
2460
|
+
*
|
|
2461
|
+
* Optional members are spread conditionally rather than assigned `undefined`,
|
|
2462
|
+
* so an absent `needs` / repo pair leaves no key behind — a present-but-
|
|
2463
|
+
* undefined key reads as "declared" to a generator and serializes differently
|
|
2464
|
+
* between the two evaluations.
|
|
2465
|
+
*/
|
|
2466
|
+
function buildGeneratorContext(input) {
|
|
2467
|
+
const { workflowName, event, env, repos, needs, $, log, kici } = input;
|
|
2468
|
+
return {
|
|
2469
|
+
$,
|
|
2470
|
+
ctx: {
|
|
2471
|
+
workflow: { name: workflowName },
|
|
2472
|
+
event,
|
|
2473
|
+
...needs && { needs }
|
|
2474
|
+
},
|
|
2475
|
+
log,
|
|
2476
|
+
env,
|
|
2477
|
+
kici,
|
|
2478
|
+
...repos && {
|
|
2479
|
+
sourceRepo: repos.sourceRepo,
|
|
2480
|
+
workflowRepo: repos.workflowRepo
|
|
2481
|
+
}
|
|
2482
|
+
};
|
|
2483
|
+
}
|
|
2484
|
+
var init_generator_context = __esmMin((() => {}));
|
|
2485
|
+
//#endregion
|
|
2262
2486
|
//#region src/execution/workflow-loader.ts
|
|
2263
2487
|
/**
|
|
2264
2488
|
* Workflow module loading: transforms `.ts` workflow files on import via the
|
|
@@ -2429,7 +2653,7 @@ function extractSteps(workflow, jobName) {
|
|
|
2429
2653
|
* A sibling mismatch logs a warning; a missing target job throws a clear
|
|
2430
2654
|
* determinism error.
|
|
2431
2655
|
*/
|
|
2432
|
-
async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames, upstreamSnapshot, declaredNeeds) {
|
|
2656
|
+
async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event, env, apiTransport, expectedJobNames, upstreamSnapshot, declaredNeeds, repos) {
|
|
2433
2657
|
const dynamicFn = extractDynamicJobFn(workflow, dynamicIndex);
|
|
2434
2658
|
const { $ } = await import("zx");
|
|
2435
2659
|
const { createLogger } = await import("@kici-dev/shared");
|
|
@@ -2437,17 +2661,16 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
|
|
|
2437
2661
|
const log = createLogger({ prefix: `dynamic-job-fn:${workflow.name}` });
|
|
2438
2662
|
const kici = buildKiciApi(apiTransport ?? (() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available during re-evaluation"))));
|
|
2439
2663
|
const needs = upstreamSnapshot ? buildNeedsContext(upstreamSnapshot, declaredNeeds ?? []) : void 0;
|
|
2440
|
-
const generatedJobs = await dynamicFn({
|
|
2664
|
+
const generatedJobs = await dynamicFn(buildGeneratorContext({
|
|
2665
|
+
workflowName: workflow.name,
|
|
2666
|
+
event,
|
|
2667
|
+
env,
|
|
2668
|
+
...repos && { repos },
|
|
2669
|
+
...needs && { needs },
|
|
2441
2670
|
$,
|
|
2442
|
-
ctx: {
|
|
2443
|
-
workflow: { name: workflow.name },
|
|
2444
|
-
event,
|
|
2445
|
-
...needs && { needs }
|
|
2446
|
-
},
|
|
2447
2671
|
log,
|
|
2448
|
-
env,
|
|
2449
2672
|
kici
|
|
2450
|
-
});
|
|
2673
|
+
}));
|
|
2451
2674
|
const actualNames = generatedJobs.map((j) => j.name);
|
|
2452
2675
|
let droppedJobs = [];
|
|
2453
2676
|
if (expectedJobNames) {
|
|
@@ -2470,8 +2693,9 @@ async function extractStepsFromDynamicJob(workflow, dynamicIndex, jobName, event
|
|
|
2470
2693
|
}
|
|
2471
2694
|
var AGENT_SDK_VERSION, AGENT_SDK_BUNDLE_HASH, hookRegistered;
|
|
2472
2695
|
var init_workflow_loader = __esmMin((() => {
|
|
2473
|
-
|
|
2474
|
-
|
|
2696
|
+
init_generator_context();
|
|
2697
|
+
AGENT_SDK_VERSION = "0.5.0";
|
|
2698
|
+
AGENT_SDK_BUNDLE_HASH = "5b85e7cffa4a08e39329ff80448a2744af8e5b5f9840ec3801fad45ed11a3de9";
|
|
2475
2699
|
hookRegistered = false;
|
|
2476
2700
|
}));
|
|
2477
2701
|
//#endregion
|
|
@@ -2492,7 +2716,7 @@ var init_workflow_loader = __esmMin((() => {
|
|
|
2492
2716
|
async function packKiciSource(workDir) {
|
|
2493
2717
|
const kiciDir = join(workDir, ".kici");
|
|
2494
2718
|
if (!existsSync(kiciDir)) throw new Error(`.kici/ not found at ${kiciDir}`);
|
|
2495
|
-
logger$
|
|
2719
|
+
logger$11.info("Packing .kici/ source tarball", { dir: workDir });
|
|
2496
2720
|
const startTime = Date.now();
|
|
2497
2721
|
const stream = c({
|
|
2498
2722
|
gzip: true,
|
|
@@ -2506,7 +2730,7 @@ async function packKiciSource(workDir) {
|
|
|
2506
2730
|
const hash = sha256(tarball);
|
|
2507
2731
|
const sizeKB = (tarball.length / 1024).toFixed(2);
|
|
2508
2732
|
const durationMs = Date.now() - startTime;
|
|
2509
|
-
logger$
|
|
2733
|
+
logger$11.info(".kici/ source packed", {
|
|
2510
2734
|
sizeKB,
|
|
2511
2735
|
hash: hash.slice(0, 12),
|
|
2512
2736
|
durationMs
|
|
@@ -2516,9 +2740,9 @@ async function packKiciSource(workDir) {
|
|
|
2516
2740
|
hash
|
|
2517
2741
|
};
|
|
2518
2742
|
}
|
|
2519
|
-
var logger$
|
|
2743
|
+
var logger$11;
|
|
2520
2744
|
var init_source_packer = __esmMin((() => {
|
|
2521
|
-
logger$
|
|
2745
|
+
logger$11 = createLogger({ prefix: "source-packer" });
|
|
2522
2746
|
}));
|
|
2523
2747
|
//#endregion
|
|
2524
2748
|
//#region src/execution/dep-restore.ts
|
|
@@ -2605,6 +2829,52 @@ async function extractIntoScratch(url, kiciDir, attempt) {
|
|
|
2605
2829
|
};
|
|
2606
2830
|
}
|
|
2607
2831
|
/**
|
|
2832
|
+
* Append `SCRATCH_DIR_GIT_EXCLUDE_GLOB` to `${repoWorkDir}/.git/info/exclude`
|
|
2833
|
+
* so any in-flight or orphaned dep-restore scratch dirs are invisible to
|
|
2834
|
+
* `git status` / `git add` inside the customer's cloned working tree.
|
|
2835
|
+
*
|
|
2836
|
+
* Why `.git/info/exclude` and not `.gitignore`:
|
|
2837
|
+
* - `.gitignore` lives in the customer's repo and is committed; we MUST NOT
|
|
2838
|
+
* modify it. Doing so would surface the rule in their PRs and create a
|
|
2839
|
+
* diff customers never asked for.
|
|
2840
|
+
* - `.git/info/exclude` is per-clone, on-disk only, and exactly the git
|
|
2841
|
+
* mechanism for "ignore these patterns in THIS working tree". Git creates
|
|
2842
|
+
* an empty (template-commented) file on `git init` / `git clone`, so it
|
|
2843
|
+
* already exists by the time we're called.
|
|
2844
|
+
*
|
|
2845
|
+
* Why this lives next to `extractIntoScratch`:
|
|
2846
|
+
* - The exclude glob is tied 1:1 to the scratch dir naming convention. If
|
|
2847
|
+
* the prefix ever changes, the rule must change too. Defining both in the
|
|
2848
|
+
* same file means a rename touches one place, not two.
|
|
2849
|
+
*
|
|
2850
|
+
* Best-effort: if the exclude file is missing (e.g. caller sandbox blocked
|
|
2851
|
+
* `git clone` and the dir layout differs) we log and continue — failing the
|
|
2852
|
+
* job over a missing git ignore wiring would be worse than the cosmetic
|
|
2853
|
+
* issue we're solving.
|
|
2854
|
+
*
|
|
2855
|
+
* Idempotent: callers may invoke this multiple times (dual-clone path, retry
|
|
2856
|
+
* after partial setup). We skip the append if the glob is already present.
|
|
2857
|
+
*
|
|
2858
|
+
* @param repoWorkDir - The git working tree root (the dir that contains
|
|
2859
|
+
* `.git/`). For normal workflows this is the agent's job workDir; for
|
|
2860
|
+
* global workflows it is the workflow repo dir (whose `.kici/` carries
|
|
2861
|
+
* the scratch dirs).
|
|
2862
|
+
*/
|
|
2863
|
+
async function excludeScratchFromGit(repoWorkDir) {
|
|
2864
|
+
const excludePath = join(repoWorkDir, ".git", "info", "exclude");
|
|
2865
|
+
try {
|
|
2866
|
+
const existing = await fsPromises.readFile(excludePath, "utf-8").catch(() => "");
|
|
2867
|
+
if (existing.split("\n").some((line) => line.trim() === SCRATCH_DIR_GIT_EXCLUDE_GLOB)) return;
|
|
2868
|
+
const suffix = existing.length === 0 || existing.endsWith("\n") ? "" : "\n";
|
|
2869
|
+
await fsPromises.appendFile(excludePath, `${suffix}# kici: hide dep-restore scratch dirs from customer git status\n${SCRATCH_DIR_GIT_EXCLUDE_GLOB}\n`);
|
|
2870
|
+
} catch (err) {
|
|
2871
|
+
logger$10.warn("Failed to register scratch dir glob in .git/info/exclude", {
|
|
2872
|
+
excludePath,
|
|
2873
|
+
error: err instanceof Error ? err.message : String(err)
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
/**
|
|
2608
2878
|
* Rewrite localhost URLs to use the orchestrator host.
|
|
2609
2879
|
*
|
|
2610
2880
|
* The orchestrator rewrites file:// cache URLs to http://localhost:PORT/...
|
|
@@ -2659,7 +2929,7 @@ async function cleanupScratch(scratchDir) {
|
|
|
2659
2929
|
force: true
|
|
2660
2930
|
});
|
|
2661
2931
|
} catch (cleanupErr) {
|
|
2662
|
-
logger$
|
|
2932
|
+
logger$10.warn("Scratch dir cleanup failed (orphan left behind)", {
|
|
2663
2933
|
scratchDir,
|
|
2664
2934
|
error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)
|
|
2665
2935
|
});
|
|
@@ -2684,7 +2954,7 @@ async function cleanupScratch(scratchDir) {
|
|
|
2684
2954
|
*/
|
|
2685
2955
|
async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
2686
2956
|
depsUrl = resolveOrchestratorUrl(depsUrl);
|
|
2687
|
-
logger$
|
|
2957
|
+
logger$10.info("Downloading dependency tarball", { url: depsUrl });
|
|
2688
2958
|
const kiciDir = join(workDir, ".kici");
|
|
2689
2959
|
if (depsUrl.startsWith("file://")) {
|
|
2690
2960
|
const localPath = fileURLToPath(depsUrl);
|
|
@@ -2698,7 +2968,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
2698
2968
|
await moveScratchIntoRepo(scratchDir, workDir);
|
|
2699
2969
|
await cleanupScratch(scratchDir);
|
|
2700
2970
|
const sizeMB = (data.length / (1024 * 1024)).toFixed(2);
|
|
2701
|
-
logger$
|
|
2971
|
+
logger$10.info("Dependencies restored from cache (file)", {
|
|
2702
2972
|
sizeMB,
|
|
2703
2973
|
targetDir: workDir
|
|
2704
2974
|
});
|
|
@@ -2707,7 +2977,7 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
2707
2977
|
if (!depsUrl.startsWith("http://") && !depsUrl.startsWith("https://")) throw new Error(`Unsupported deps URL scheme: ${depsUrl}`);
|
|
2708
2978
|
let lastError;
|
|
2709
2979
|
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
2710
|
-
if (attempt > 0) logger$
|
|
2980
|
+
if (attempt > 0) logger$10.warn("Retrying dep tarball download", {
|
|
2711
2981
|
attempt,
|
|
2712
2982
|
url: depsUrl
|
|
2713
2983
|
});
|
|
@@ -2716,11 +2986,11 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
2716
2986
|
if (depsHash && hash !== depsHash) throw new Error(`Dep tarball hash mismatch: expected ${depsHash}, got ${hash}`);
|
|
2717
2987
|
await moveScratchIntoRepo(scratchDir, workDir);
|
|
2718
2988
|
await cleanupScratch(scratchDir);
|
|
2719
|
-
logger$
|
|
2989
|
+
logger$10.info("Dependencies restored from cache (stream)", { targetDir: workDir });
|
|
2720
2990
|
return;
|
|
2721
2991
|
} catch (err) {
|
|
2722
2992
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
2723
|
-
logger$
|
|
2993
|
+
logger$10.warn("Dep tarball download failed", {
|
|
2724
2994
|
attempt,
|
|
2725
2995
|
error: lastError.message
|
|
2726
2996
|
});
|
|
@@ -2728,12 +2998,12 @@ async function restoreDeps(workDir, depsUrl, depsHash) {
|
|
|
2728
2998
|
}
|
|
2729
2999
|
throw new Error(`Dep tarball download failed after 3 attempts: ${lastError?.message}`);
|
|
2730
3000
|
}
|
|
2731
|
-
var logger$
|
|
3001
|
+
var logger$10, DOWNLOAD_TIMEOUT_MS$1, SCRATCH_DIR_BASENAME_PREFIX, SCRATCH_DIR_GIT_EXCLUDE_GLOB;
|
|
2732
3002
|
var init_dep_restore = __esmMin((() => {
|
|
2733
|
-
logger$
|
|
3003
|
+
logger$10 = createLogger({ prefix: "dep-restore" });
|
|
2734
3004
|
DOWNLOAD_TIMEOUT_MS$1 = 300 * 1e3;
|
|
2735
3005
|
SCRATCH_DIR_BASENAME_PREFIX = ".dep-restore-scratch-";
|
|
2736
|
-
|
|
3006
|
+
SCRATCH_DIR_GIT_EXCLUDE_GLOB = `.kici/${SCRATCH_DIR_BASENAME_PREFIX}*`;
|
|
2737
3007
|
}));
|
|
2738
3008
|
//#endregion
|
|
2739
3009
|
//#region src/execution/download.ts
|
|
@@ -2744,6 +3014,20 @@ var init_dep_restore = __esmMin((() => {
|
|
|
2744
3014
|
* dep-restore.ts and workflow-loader.ts.
|
|
2745
3015
|
*/
|
|
2746
3016
|
/**
|
|
3017
|
+
* Whether a failed upload attempt is worth repeating.
|
|
3018
|
+
*
|
|
3019
|
+
* A transport failure (connection refused, reset, DNS) never reached a
|
|
3020
|
+
* responder, and 5xx / 429 are the object-storage overload signals AWS
|
|
3021
|
+
* documents as retry-with-backoff (S3 answers `SlowDown` with 503). Every other
|
|
3022
|
+
* status is a decision the server will repeat: a 403 from an expired or
|
|
3023
|
+
* malformed signature, a 400 from a malformed request. Retrying those burns the
|
|
3024
|
+
* ceiling without a chance of success and delays the real error.
|
|
3025
|
+
*/
|
|
3026
|
+
function isRetryableUploadFailure(err) {
|
|
3027
|
+
if (!(err instanceof PresignedUploadHttpError)) return true;
|
|
3028
|
+
return err.statusCode >= 500 || err.statusCode === 429;
|
|
3029
|
+
}
|
|
3030
|
+
/**
|
|
2747
3031
|
* Download content from an HTTP/HTTPS URL.
|
|
2748
3032
|
*
|
|
2749
3033
|
* Includes a 5-minute timeout to prevent the agent from hanging indefinitely
|
|
@@ -2767,21 +3051,10 @@ function downloadUrl(url) {
|
|
|
2767
3051
|
}).on("error", reject);
|
|
2768
3052
|
});
|
|
2769
3053
|
}
|
|
2770
|
-
/**
|
|
2771
|
-
|
|
2772
|
-
*
|
|
2773
|
-
* Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
|
|
2774
|
-
* 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
|
|
2775
|
-
* filesystem cache backend's signed URLs work from container agents that
|
|
2776
|
-
* can't reach the orchestrator's host loopback directly.
|
|
2777
|
-
*
|
|
2778
|
-
* @param url - The pre-signed URL to upload to
|
|
2779
|
-
* @param data - The buffer to upload
|
|
2780
|
-
*/
|
|
2781
|
-
function uploadToPresignedUrl(url, data) {
|
|
3054
|
+
/** One PUT of the whole buffer. Rejects with {@link PresignedUploadHttpError} on a non-2xx. */
|
|
3055
|
+
function putOnce(resolvedUrl, data, timeoutMs) {
|
|
2782
3056
|
return new Promise((resolve, reject) => {
|
|
2783
|
-
const
|
|
2784
|
-
const parsed = new URL(resolved);
|
|
3057
|
+
const parsed = new URL(resolvedUrl);
|
|
2785
3058
|
const req = (parsed.protocol === "https:" ? https : http).request({
|
|
2786
3059
|
hostname: parsed.hostname,
|
|
2787
3060
|
port: parsed.port,
|
|
@@ -2790,7 +3063,7 @@ function uploadToPresignedUrl(url, data) {
|
|
|
2790
3063
|
headers: { "Content-Length": data.length }
|
|
2791
3064
|
}, (res) => {
|
|
2792
3065
|
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
|
|
2793
|
-
reject(
|
|
3066
|
+
reject(new PresignedUploadHttpError(res.statusCode));
|
|
2794
3067
|
res.resume();
|
|
2795
3068
|
return;
|
|
2796
3069
|
}
|
|
@@ -2798,14 +3071,80 @@ function uploadToPresignedUrl(url, data) {
|
|
|
2798
3071
|
res.on("end", () => resolve());
|
|
2799
3072
|
res.on("error", reject);
|
|
2800
3073
|
});
|
|
3074
|
+
req.setTimeout(timeoutMs, () => {
|
|
3075
|
+
req.destroy(/* @__PURE__ */ new Error(`Pre-signed upload timed out after ${timeoutMs}ms`));
|
|
3076
|
+
});
|
|
2801
3077
|
req.on("error", reject);
|
|
2802
3078
|
req.end(data);
|
|
2803
3079
|
});
|
|
2804
3080
|
}
|
|
2805
|
-
|
|
3081
|
+
/**
|
|
3082
|
+
* Upload a buffer to a pre-signed S3 URL via HTTP PUT, retrying a transient
|
|
3083
|
+
* failure.
|
|
3084
|
+
*
|
|
3085
|
+
* Used for direct-to-S3 uploads of bundles and dep tarballs. Localhost /
|
|
3086
|
+
* 127.0.0.1 URLs are rewritten via `resolveOrchestratorUrl` so the
|
|
3087
|
+
* filesystem cache backend's signed URLs work from container agents that
|
|
3088
|
+
* can't reach the orchestrator's host loopback directly.
|
|
3089
|
+
*
|
|
3090
|
+
* **Why retrying is safe here.** A pre-signed PUT writes one whole object at a
|
|
3091
|
+
* single key: there is no multipart session, no append, and no
|
|
3092
|
+
* server-generated identity, so a repeat attempt writes the same bytes to the
|
|
3093
|
+
* same key and the last write wins. S3 also only makes an object visible once
|
|
3094
|
+
* the body has been received in full, so an attempt that died mid-body left
|
|
3095
|
+
* nothing behind. A retry therefore cannot double-write or produce a torn
|
|
3096
|
+
* object — which is why every AWS SDK retries PUTs by default.
|
|
3097
|
+
*
|
|
3098
|
+
* Only a failure that can plausibly differ next time is repeated — see
|
|
3099
|
+
* {@link isRetryableUploadFailure}.
|
|
3100
|
+
*
|
|
3101
|
+
* @param url - The pre-signed URL to upload to
|
|
3102
|
+
* @param data - The buffer to upload
|
|
3103
|
+
* @param opts.baseDelayMs - Backoff before the first retry (doubles thereafter)
|
|
3104
|
+
* @param opts.timeoutMs - Per-attempt socket-inactivity timeout (see
|
|
3105
|
+
* {@link UPLOAD_TIMEOUT_MS}); an override exists so a test can drive the
|
|
3106
|
+
* stall path without waiting out the production budget.
|
|
3107
|
+
*/
|
|
3108
|
+
async function uploadToPresignedUrl(url, data, opts) {
|
|
3109
|
+
const resolved = resolveOrchestratorUrl(url);
|
|
3110
|
+
const baseDelayMs = opts?.baseDelayMs ?? UPLOAD_RETRY_BASE_DELAY_MS;
|
|
3111
|
+
const timeoutMs = opts?.timeoutMs ?? UPLOAD_TIMEOUT_MS;
|
|
3112
|
+
let lastError;
|
|
3113
|
+
for (let attempt = 0; attempt <= 2; attempt++) {
|
|
3114
|
+
if (attempt > 0) {
|
|
3115
|
+
const delayMs = baseDelayMs * 2 ** (attempt - 1);
|
|
3116
|
+
logger$9.warn("Retrying pre-signed upload", {
|
|
3117
|
+
attempt,
|
|
3118
|
+
delayMs,
|
|
3119
|
+
error: lastError?.message
|
|
3120
|
+
});
|
|
3121
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
3122
|
+
}
|
|
3123
|
+
try {
|
|
3124
|
+
await putOnce(resolved, data, timeoutMs);
|
|
3125
|
+
return;
|
|
3126
|
+
} catch (err) {
|
|
3127
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
3128
|
+
if (!isRetryableUploadFailure(lastError)) throw lastError;
|
|
3129
|
+
}
|
|
3130
|
+
}
|
|
3131
|
+
throw new Error(`Pre-signed upload failed after 3 attempts: ${lastError?.message}`);
|
|
3132
|
+
}
|
|
3133
|
+
var logger$9, DOWNLOAD_TIMEOUT_MS, UPLOAD_TIMEOUT_MS, UPLOAD_RETRY_BASE_DELAY_MS, PresignedUploadHttpError;
|
|
2806
3134
|
var init_download = __esmMin((() => {
|
|
2807
3135
|
init_dep_restore();
|
|
3136
|
+
logger$9 = createLogger({ prefix: "agent:download" });
|
|
2808
3137
|
DOWNLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
3138
|
+
UPLOAD_TIMEOUT_MS = 300 * 1e3;
|
|
3139
|
+
UPLOAD_RETRY_BASE_DELAY_MS = 500;
|
|
3140
|
+
PresignedUploadHttpError = class extends Error {
|
|
3141
|
+
statusCode;
|
|
3142
|
+
constructor(statusCode) {
|
|
3143
|
+
super(`HTTP ${statusCode} uploading to pre-signed URL`);
|
|
3144
|
+
this.statusCode = statusCode;
|
|
3145
|
+
this.name = "PresignedUploadHttpError";
|
|
3146
|
+
}
|
|
3147
|
+
};
|
|
2809
3148
|
}));
|
|
2810
3149
|
//#endregion
|
|
2811
3150
|
//#region src/execution/source-restore.ts
|
|
@@ -2889,6 +3228,44 @@ var init_timeout_util = __esmMin((() => {}));
|
|
|
2889
3228
|
//#endregion
|
|
2890
3229
|
//#region src/execution/init-runner.ts
|
|
2891
3230
|
/**
|
|
3231
|
+
* Run a workflow's `filter` and report whether the workflow applies.
|
|
3232
|
+
*
|
|
3233
|
+
* Shared by both agent-side evaluation sites for a same-repo workflow: the init
|
|
3234
|
+
* job that gates each static job's dispatch, and the dynamic-eval job that gates
|
|
3235
|
+
* whether a generator runs at all. Both must reach the same verdict from the same
|
|
3236
|
+
* inputs, so neither builds the context itself.
|
|
3237
|
+
*
|
|
3238
|
+
* The context is built through `createFilterContext` rather than as an object
|
|
3239
|
+
* literal: the factory installs `changedFiles` as a throwing getter, so a filter
|
|
3240
|
+
* that reads the diff on an event that has none fails loudly instead of seeing an
|
|
3241
|
+
* empty list. A `false` verdict dispatches none of the workflow's own jobs, so a
|
|
3242
|
+
* silently-empty diff would suppress it on a mistake. On this same-repo path the
|
|
3243
|
+
* verdict is at least recoverable — the run row exists, carrying the `__init__*`
|
|
3244
|
+
* jobs, and this evaluation's own step log records the verdict; it is the
|
|
3245
|
+
* organization-wide path, which runs elsewhere, that leaves nothing behind.
|
|
3246
|
+
*
|
|
3247
|
+
* A throwing filter propagates: the evaluating job fails, which surfaces as a
|
|
3248
|
+
* failed run. "Could not decide" is never treated as "do not run" — that would
|
|
3249
|
+
* be a false green, the same reasoning `buildJobRuleCompletion` applies to a rule
|
|
3250
|
+
* whose `check()` threw.
|
|
3251
|
+
*/
|
|
3252
|
+
async function evaluateWorkflowFilter(workflow, event, input, timeoutMs) {
|
|
3253
|
+
if (typeof workflow.filter !== "function") throw new Error(`Workflow '${workflow.name}' is recorded as declaring a filter, but its module exports none — the lock file is out of date. Run 'kici compile' and commit the result.`);
|
|
3254
|
+
if (!input) throw new Error(`Workflow '${workflow.name}' declares a filter but the evaluating job supplied no filter context (source tree / changed files) to evaluate it against.`);
|
|
3255
|
+
const filterFn = workflow.filter;
|
|
3256
|
+
const ctx = createFilterContext({
|
|
3257
|
+
sourceRepo: input.sourceRepo,
|
|
3258
|
+
workflowRepo: input.workflowRepo,
|
|
3259
|
+
event,
|
|
3260
|
+
changedFiles: input.changedFiles,
|
|
3261
|
+
changedFilesStatus: input.changedFilesStatus,
|
|
3262
|
+
...input.env && { env: input.env },
|
|
3263
|
+
...input.$ && { $: input.$ }
|
|
3264
|
+
});
|
|
3265
|
+
const verdict = await withTimeout(() => filterFn(ctx), timeoutMs, `filter for workflow '${workflow.name}'`);
|
|
3266
|
+
return Boolean(verdict);
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
2892
3269
|
* Find a static job by name in a workflow's jobs array.
|
|
2893
3270
|
* Skips dynamic job functions (factories).
|
|
2894
3271
|
*/
|
|
@@ -2907,15 +3284,25 @@ function findJobByName(workflow, jobName) {
|
|
|
2907
3284
|
* -: If a dynamic function returns undefined/null, the field is left undefined.
|
|
2908
3285
|
* -: Each dynamic function call is wrapped in a timeout (default 60s).
|
|
2909
3286
|
*
|
|
3287
|
+
* A workflow-level `filter` is evaluated FIRST when `flags.hasFilter` is set. A
|
|
3288
|
+
* `false` verdict returns immediately: no job of that workflow will be
|
|
3289
|
+
* dispatched, so evaluating this one's dynamic fields would run customer code
|
|
3290
|
+
* whose result nothing can consume.
|
|
3291
|
+
*
|
|
2910
3292
|
* @param workflow - The extracted Workflow object
|
|
2911
3293
|
* @param jobName - Name of the job whose dynamic fields to evaluate
|
|
2912
3294
|
* @param event - Normalized event envelope — same shape every dynamic-function call site receives.
|
|
2913
3295
|
* @param flags - Which fields are dynamic and need evaluation
|
|
2914
3296
|
* @param timeoutMs - Timeout per dynamic function call (default 60_000ms)
|
|
3297
|
+
* @param filterInput - Source tree + diff the workflow's `filter` reads. Required when `flags.hasFilter`.
|
|
2915
3298
|
*/
|
|
2916
|
-
async function evaluateDynamicFields(workflow, jobName, event, flags, timeoutMs = 6e4) {
|
|
2917
|
-
const job = findJobByName(workflow, jobName);
|
|
3299
|
+
async function evaluateDynamicFields(workflow, jobName, event, flags, timeoutMs = 6e4, filterInput) {
|
|
2918
3300
|
const result = {};
|
|
3301
|
+
if (flags.hasFilter) {
|
|
3302
|
+
result.filterPassed = await evaluateWorkflowFilter(workflow, event, filterInput, timeoutMs);
|
|
3303
|
+
if (!result.filterPassed) return result;
|
|
3304
|
+
}
|
|
3305
|
+
const job = findJobByName(workflow, jobName);
|
|
2919
3306
|
if (flags.dynamicMatrix && typeof job.matrix === "function") {
|
|
2920
3307
|
const matrixContext = {
|
|
2921
3308
|
$: (await import("zx")).$,
|
|
@@ -4012,6 +4399,231 @@ var init_dynamic_job_serializer = __esmMin((() => {
|
|
|
4012
4399
|
}
|
|
4013
4400
|
};
|
|
4014
4401
|
DYNAMIC_FIELD_TIMEOUT_MS = 6e4;
|
|
4402
|
+
}));
|
|
4403
|
+
//#endregion
|
|
4404
|
+
//#region src/execution/global-workflow-env.ts
|
|
4405
|
+
/**
|
|
4406
|
+
* Derive an `owner/repo` identifier from a clone URL, stripping the trailing
|
|
4407
|
+
* `.git` and any `http(s)://host/` prefix.
|
|
4408
|
+
*/
|
|
4409
|
+
function repoIdentifierFromUrl(repoUrl) {
|
|
4410
|
+
return repoUrl.replace(/\.git$/, "").replace(/^https?:\/\/[^/]+\//, "");
|
|
4411
|
+
}
|
|
4412
|
+
/**
|
|
4413
|
+
* Inject the seven global-workflow env keys and return a restorer that puts
|
|
4414
|
+
* `process.env` back exactly as it was — each key reset to its prior value, or
|
|
4415
|
+
* deleted if it had none.
|
|
4416
|
+
*
|
|
4417
|
+
* **The restorer is mandatory for any caller in a long-lived process.** The
|
|
4418
|
+
* sandbox may ignore it: it runs one job per forked child, which exits. The
|
|
4419
|
+
* pre-dispatch global eval round may NOT: it runs in the agent process, which
|
|
4420
|
+
* serves many dispatches from one `JobRunner`. Leaving the keys set there is
|
|
4421
|
+
* this module's own hazard running backwards — a later NON-global
|
|
4422
|
+
* `DynamicJobFn` evaluation builds its generator context with
|
|
4423
|
+
* `env: process.env` still carrying `KICI_IS_GLOBAL_WORKFLOW=true` and a
|
|
4424
|
+
* `KICI_SOURCE_REPO_PATH` pointing at a deleted work directory, while that
|
|
4425
|
+
* job's own sandbox re-evaluation sees neither (`buildSanitizedEnv` scrubs the
|
|
4426
|
+
* whole `KICI_*` namespace on the trusted profile, and the default profile is
|
|
4427
|
+
* allowlist-only). That is the same two-worlds determinism failure, injected
|
|
4428
|
+
* into an unrelated job.
|
|
4429
|
+
*
|
|
4430
|
+
* `RepoInfo.ref` / `.sha` are optional, so an evaluation with no checkout
|
|
4431
|
+
* metadata writes an empty string rather than leaving the key unset — matching
|
|
4432
|
+
* how `KICI_WORKFLOW_REPO` already handles a missing identifier. Assigning
|
|
4433
|
+
* `undefined` to a `process.env` key would stringify to `"undefined"`, which is
|
|
4434
|
+
* worse than either.
|
|
4435
|
+
*/
|
|
4436
|
+
function applyGlobalWorkflowEnv(repos) {
|
|
4437
|
+
const prior = GLOBAL_WORKFLOW_ENV_KEYS.map((key) => [key, process.env[key]]);
|
|
4438
|
+
process.env.KICI_IS_GLOBAL_WORKFLOW = "true";
|
|
4439
|
+
process.env.KICI_WORKFLOW_REPO_PATH = repos.workflowRepo.path;
|
|
4440
|
+
process.env.KICI_SOURCE_REPO_PATH = repos.sourceRepo.path;
|
|
4441
|
+
process.env.KICI_SOURCE_REPO = repos.sourceRepo.identifier;
|
|
4442
|
+
process.env.KICI_SOURCE_BRANCH = repos.sourceRepo.ref ?? "";
|
|
4443
|
+
process.env.KICI_SOURCE_SHA = repos.sourceRepo.sha ?? "";
|
|
4444
|
+
process.env.KICI_WORKFLOW_REPO = repos.workflowRepo.identifier;
|
|
4445
|
+
return () => {
|
|
4446
|
+
for (const [key, value] of prior) if (value === void 0) delete process.env[key];
|
|
4447
|
+
else process.env[key] = value;
|
|
4448
|
+
};
|
|
4449
|
+
}
|
|
4450
|
+
var GLOBAL_WORKFLOW_ENV_KEYS;
|
|
4451
|
+
var init_global_workflow_env = __esmMin((() => {
|
|
4452
|
+
GLOBAL_WORKFLOW_ENV_KEYS = [
|
|
4453
|
+
"KICI_IS_GLOBAL_WORKFLOW",
|
|
4454
|
+
"KICI_WORKFLOW_REPO_PATH",
|
|
4455
|
+
"KICI_SOURCE_REPO_PATH",
|
|
4456
|
+
"KICI_SOURCE_REPO",
|
|
4457
|
+
"KICI_SOURCE_BRANCH",
|
|
4458
|
+
"KICI_SOURCE_SHA",
|
|
4459
|
+
"KICI_WORKFLOW_REPO"
|
|
4460
|
+
];
|
|
4461
|
+
}));
|
|
4462
|
+
//#endregion
|
|
4463
|
+
//#region src/execution/global-eval-runner.ts
|
|
4464
|
+
function buildRoundState(args) {
|
|
4465
|
+
const loadModule = args.loadModule ?? (async (sourceFile) => (await loadWorkflowSource(args.workflowDir, sourceFile)).module);
|
|
4466
|
+
return {
|
|
4467
|
+
args,
|
|
4468
|
+
$: args.$ ?? $,
|
|
4469
|
+
log: args.log ?? NOOP_LOG,
|
|
4470
|
+
kici: args.kici ?? buildKiciApi(() => Promise.reject(/* @__PURE__ */ new Error("Agent API not available"))),
|
|
4471
|
+
loadModule,
|
|
4472
|
+
moduleCache: /* @__PURE__ */ new Map()
|
|
4473
|
+
};
|
|
4474
|
+
}
|
|
4475
|
+
/** Load a workflow, caching per source file so a shared module is imported once. */
|
|
4476
|
+
async function loadWorkflowCached(shared, sourceFile, workflowName) {
|
|
4477
|
+
let pending = shared.moduleCache.get(sourceFile);
|
|
4478
|
+
if (!pending) {
|
|
4479
|
+
pending = shared.loadModule(sourceFile);
|
|
4480
|
+
shared.moduleCache.set(sourceFile, pending);
|
|
4481
|
+
}
|
|
4482
|
+
return extractWorkflow(await pending, workflowName);
|
|
4483
|
+
}
|
|
4484
|
+
/**
|
|
4485
|
+
* Run every `DynamicJobFn` the workflow declares and serialize the result.
|
|
4486
|
+
*
|
|
4487
|
+
* The generator context is built through `buildGeneratorContext` with the same
|
|
4488
|
+
* repo pair the sandbox re-evaluation gets, so the two calls a generator
|
|
4489
|
+
* receives cannot drift apart. Returns `undefined` when the workflow declares
|
|
4490
|
+
* no generators, which keeps the `jobs` key off the wire entirely.
|
|
4491
|
+
*/
|
|
4492
|
+
async function generateDynamicJobs(workflow, shared) {
|
|
4493
|
+
const { args } = shared;
|
|
4494
|
+
const generators = workflow.jobs.filter(isDynamicJobFn);
|
|
4495
|
+
if (generators.length === 0) return void 0;
|
|
4496
|
+
const serializerCtx = {
|
|
4497
|
+
event: args.event,
|
|
4498
|
+
$: shared.$,
|
|
4499
|
+
log: shared.log,
|
|
4500
|
+
env: process.env,
|
|
4501
|
+
workflowName: workflow.name
|
|
4502
|
+
};
|
|
4503
|
+
const jobs = [];
|
|
4504
|
+
for (const generator of generators) {
|
|
4505
|
+
const generated = await generator(buildGeneratorContext({
|
|
4506
|
+
workflowName: workflow.name,
|
|
4507
|
+
event: args.event,
|
|
4508
|
+
env: process.env,
|
|
4509
|
+
repos: args.repos,
|
|
4510
|
+
$: shared.$,
|
|
4511
|
+
log: shared.log,
|
|
4512
|
+
kici: shared.kici
|
|
4513
|
+
}));
|
|
4514
|
+
jobs.push(...await serializeJobsToLock(generated, serializerCtx));
|
|
4515
|
+
}
|
|
4516
|
+
return jobs;
|
|
4517
|
+
}
|
|
4518
|
+
/**
|
|
4519
|
+
* Evaluate one candidate to a verdict: run its `filter` if it declares one,
|
|
4520
|
+
* then its generators if it survives.
|
|
4521
|
+
*/
|
|
4522
|
+
async function evaluateCandidateInner(candidate, shared) {
|
|
4523
|
+
const { args } = shared;
|
|
4524
|
+
const workflow = await loadWorkflowCached(shared, candidate.sourceFile, candidate.workflowName);
|
|
4525
|
+
if (candidate.hasFilter) {
|
|
4526
|
+
if (typeof workflow.filter !== "function") throw new Error(`Workflow '${candidate.workflowName}' is recorded as declaring a filter, but its module exports none — the lock file is out of date. Run 'kici compile' and commit the result.`);
|
|
4527
|
+
const filterCtx = createFilterContext({
|
|
4528
|
+
sourceRepo: args.repos.sourceRepo,
|
|
4529
|
+
workflowRepo: args.repos.workflowRepo,
|
|
4530
|
+
event: args.event,
|
|
4531
|
+
changedFiles: args.changedFiles,
|
|
4532
|
+
changedFilesStatus: args.changedFilesStatus,
|
|
4533
|
+
env: process.env,
|
|
4534
|
+
$: shared.$
|
|
4535
|
+
});
|
|
4536
|
+
if (!await workflow.filter(filterCtx)) return {
|
|
4537
|
+
workflowName: candidate.workflowName,
|
|
4538
|
+
run: false
|
|
4539
|
+
};
|
|
4540
|
+
}
|
|
4541
|
+
const jobs = await generateDynamicJobs(workflow, shared);
|
|
4542
|
+
return {
|
|
4543
|
+
workflowName: candidate.workflowName,
|
|
4544
|
+
run: true,
|
|
4545
|
+
...jobs && { jobs }
|
|
4546
|
+
};
|
|
4547
|
+
}
|
|
4548
|
+
/**
|
|
4549
|
+
* Evaluate one candidate, never throwing. A failure — a throwing filter, a
|
|
4550
|
+
* broken generator, a blown per-candidate budget — becomes an indeterminate
|
|
4551
|
+
* verdict so the round's other candidates still get real answers.
|
|
4552
|
+
*/
|
|
4553
|
+
async function evaluateCandidate(candidate, shared) {
|
|
4554
|
+
try {
|
|
4555
|
+
return await withTimeout(() => evaluateCandidateInner(candidate, shared), shared.args.candidateTimeoutMs, `global workflow '${candidate.workflowName}'`);
|
|
4556
|
+
} catch (error) {
|
|
4557
|
+
return {
|
|
4558
|
+
workflowName: candidate.workflowName,
|
|
4559
|
+
run: false,
|
|
4560
|
+
indeterminate: true,
|
|
4561
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
4562
|
+
};
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
4565
|
+
/**
|
|
4566
|
+
* Evaluate candidates one at a time — they share one checkout and one working
|
|
4567
|
+
* directory, so a parallel `$` would race on cwd.
|
|
4568
|
+
*
|
|
4569
|
+
* Stops before starting a candidate once the round deadline has passed or the
|
|
4570
|
+
* caller aborted. That check is what keeps the sequential guarantee meaningful
|
|
4571
|
+
* past a timeout: `withTimeout` races rather than cancels, so without it the
|
|
4572
|
+
* loop would keep launching every remaining candidate — each up to
|
|
4573
|
+
* `candidateTimeoutMs` — into a work directory the job has already reported on
|
|
4574
|
+
* and whose cleanup has already deleted. With it, at most one candidate is ever
|
|
4575
|
+
* in flight past the deadline.
|
|
4576
|
+
*
|
|
4577
|
+
* Results are appended to the caller's array as they land, so a round that
|
|
4578
|
+
* blows its own budget can still report the verdicts it did establish rather
|
|
4579
|
+
* than discarding the work.
|
|
4580
|
+
*/
|
|
4581
|
+
async function evaluateAllCandidates(shared, into, deadline) {
|
|
4582
|
+
for (const candidate of shared.args.candidates) {
|
|
4583
|
+
if (shared.args.signal?.aborted || Date.now() >= deadline) return;
|
|
4584
|
+
into.push(await evaluateCandidate(candidate, shared));
|
|
4585
|
+
}
|
|
4586
|
+
}
|
|
4587
|
+
/**
|
|
4588
|
+
* Run one global eval round and return every candidate's verdict, in candidate
|
|
4589
|
+
* order. Never throws: a round that exceeds `roundTimeoutMs` reports whatever
|
|
4590
|
+
* it established and marks the rest indeterminate.
|
|
4591
|
+
*/
|
|
4592
|
+
async function runGlobalEvalRound(args) {
|
|
4593
|
+
const restoreEnv = applyGlobalWorkflowEnv(args.repos);
|
|
4594
|
+
const shared = buildRoundState(args);
|
|
4595
|
+
const settled = [];
|
|
4596
|
+
let stopReason;
|
|
4597
|
+
try {
|
|
4598
|
+
await withTimeout(() => evaluateAllCandidates(shared, settled, Date.now() + args.roundTimeoutMs), args.roundTimeoutMs, `global eval round (${args.candidates.length} candidate(s))`);
|
|
4599
|
+
} catch (error) {
|
|
4600
|
+
stopReason = error instanceof Error ? error.message : String(error);
|
|
4601
|
+
} finally {
|
|
4602
|
+
restoreEnv();
|
|
4603
|
+
}
|
|
4604
|
+
const candidates = [...settled];
|
|
4605
|
+
const reason = stopReason ?? (args.signal?.aborted ? "global eval round was cancelled before this candidate was evaluated" : "global eval round deadline reached before this candidate was evaluated");
|
|
4606
|
+
for (const candidate of args.candidates.slice(candidates.length)) candidates.push({
|
|
4607
|
+
workflowName: candidate.workflowName,
|
|
4608
|
+
run: false,
|
|
4609
|
+
indeterminate: true,
|
|
4610
|
+
reason
|
|
4611
|
+
});
|
|
4612
|
+
return { candidates };
|
|
4613
|
+
}
|
|
4614
|
+
var NOOP_LOG;
|
|
4615
|
+
var init_global_eval_runner = __esmMin((() => {
|
|
4616
|
+
init_timeout_util();
|
|
4617
|
+
init_generator_context();
|
|
4618
|
+
init_global_workflow_env();
|
|
4619
|
+
init_workflow_loader();
|
|
4620
|
+
init_dynamic_job_serializer();
|
|
4621
|
+
NOOP_LOG = {
|
|
4622
|
+
info: () => {},
|
|
4623
|
+
warn: () => {},
|
|
4624
|
+
error: () => {},
|
|
4625
|
+
debug: () => {}
|
|
4626
|
+
};
|
|
4015
4627
|
})), DEFAULT_MAX_LOG_SIZE_BYTES, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_FLUSH_LINE_THRESHOLD, PAUSE_SAFETY_TIMEOUT_MS, LogStreamer;
|
|
4016
4628
|
var init_log_streamer = __esmMin((() => {
|
|
4017
4629
|
init_prometheus();
|
|
@@ -6964,6 +7576,9 @@ var init_sandbox = __esmMin((() => {
|
|
|
6964
7576
|
var job_runner_exports = /* @__PURE__ */ __exportAll({
|
|
6965
7577
|
JobRunner: () => JobRunner$1,
|
|
6966
7578
|
buildEvalNeedsContext: () => buildEvalNeedsContext,
|
|
7579
|
+
buildEvalShell: () => buildEvalShell,
|
|
7580
|
+
buildInitFilterInput: () => buildInitFilterInput,
|
|
7581
|
+
ensureFilterSourceDir: () => ensureFilterSourceDir,
|
|
6967
7582
|
resolveJobWorkDir: () => resolveJobWorkDir,
|
|
6968
7583
|
resolveRunnerBundlePath: () => resolveRunnerBundlePath
|
|
6969
7584
|
});
|
|
@@ -6979,6 +7594,192 @@ async function fileExists(p) {
|
|
|
6979
7594
|
}
|
|
6980
7595
|
}
|
|
6981
7596
|
/**
|
|
7597
|
+
* Build the source / workflow repo pair the round hands to every filter and
|
|
7598
|
+
* generator. Mirrors the sandbox's own `setupGlobalWorkflowEnv` construction so
|
|
7599
|
+
* a generator's two evaluations see the same identifiers, refs, and shas — only
|
|
7600
|
+
* the absolute paths differ, and those are never compared.
|
|
7601
|
+
*/
|
|
7602
|
+
function buildRoundRepos(dispatch, config, workflowDir, sourceDir) {
|
|
7603
|
+
return {
|
|
7604
|
+
workflowRepo: {
|
|
7605
|
+
identifier: config.workflowRepoIdentifier ?? repoIdentifierFromUrl(config.workflowRepoUrl),
|
|
7606
|
+
path: workflowDir,
|
|
7607
|
+
ref: config.workflowRef,
|
|
7608
|
+
sha: config.workflowSha
|
|
7609
|
+
},
|
|
7610
|
+
sourceRepo: {
|
|
7611
|
+
identifier: repoIdentifierFromUrl(dispatch.repoUrl),
|
|
7612
|
+
path: sourceDir,
|
|
7613
|
+
ref: dispatch.ref,
|
|
7614
|
+
sha: dispatch.sha
|
|
7615
|
+
}
|
|
7616
|
+
};
|
|
7617
|
+
}
|
|
7618
|
+
/**
|
|
7619
|
+
* Resolve the changed-files list a `filter` reads — for a global eval round and
|
|
7620
|
+
* for a filter-bearing init job alike.
|
|
7621
|
+
*
|
|
7622
|
+
* Ground truth is the agent's own source clone; an already-`fetched` list from
|
|
7623
|
+
* the orchestrator is a free fast-path. A diff-less event (schedule / tag /
|
|
7624
|
+
* manual) resolves to `unavailable`, which makes `ctx.changedFiles` throw
|
|
7625
|
+
* rather than read as an empty diff — a `filter` returning false produces no
|
|
7626
|
+
* run at all, so a silently-empty diff would suppress the workflow with no
|
|
7627
|
+
* artifact anywhere to inspect.
|
|
7628
|
+
*/
|
|
7629
|
+
async function resolveEvalChangedFiles(dispatch, event, sourceDir) {
|
|
7630
|
+
const ev = event;
|
|
7631
|
+
if (ev.changedFilesStatus === "fetched") return {
|
|
7632
|
+
files: ev.changedFiles ?? [],
|
|
7633
|
+
status: "fetched"
|
|
7634
|
+
};
|
|
7635
|
+
return computeChangedFiles(sourceDir, event, dispatch.sourceAuth ?? dispatch.workflowAuth ?? (dispatch.token ? {
|
|
7636
|
+
kind: "basic",
|
|
7637
|
+
user: "x-access-token",
|
|
7638
|
+
secret: dispatch.token
|
|
7639
|
+
} : void 0));
|
|
7640
|
+
}
|
|
7641
|
+
/**
|
|
7642
|
+
* Materialize the source tree a non-global workflow's `filter` reads through
|
|
7643
|
+
* `ctx.sourceRepo.path`.
|
|
7644
|
+
*
|
|
7645
|
+
* An init or dynamic-eval job normally restores only `.kici/` from the cached
|
|
7646
|
+
* source tarball — enough to import the workflow module, but a directory with no
|
|
7647
|
+
* repo in it. A filter that reads a file or shells out against that path would
|
|
7648
|
+
* get a confidently wrong answer, and `changedFiles` could not be computed at
|
|
7649
|
+
* all, so a filter-bearing job clones the source repo into a sibling directory.
|
|
7650
|
+
*
|
|
7651
|
+
* When no tarball was attached the job already cloned the whole repo into
|
|
7652
|
+
* `workDir`, and that clone is reused rather than duplicated — including the
|
|
7653
|
+
* local working-tree case, where there is no repo url and `workDir` IS the tree.
|
|
7654
|
+
*
|
|
7655
|
+
* A tarball with no repo url is the one combination that cannot be honoured:
|
|
7656
|
+
* `workDir` holds `.kici/` alone and there is nothing to clone from. Returning it
|
|
7657
|
+
* would hand the filter a directory in which every path test answers "absent" —
|
|
7658
|
+
* the exact silent lie this function exists to prevent — so it throws instead.
|
|
7659
|
+
*/
|
|
7660
|
+
async function ensureFilterSourceDir(dispatch, workDir) {
|
|
7661
|
+
if (!dispatch.sourceTarUrl) return workDir;
|
|
7662
|
+
if (!dispatch.repoUrl) throw new Error("Workflow declares a filter, but this job restored its source from the cache with no repo url to clone from — the filter would see an empty tree. Re-run with a source repository configured, or remove the filter.");
|
|
7663
|
+
const sourceDir = join(workDir, FILTER_SOURCE_DIRNAME);
|
|
7664
|
+
const sourceAuth = dispatch.sourceAuth;
|
|
7665
|
+
await gitClone({
|
|
7666
|
+
repoUrl: dispatch.repoUrl,
|
|
7667
|
+
ref: dispatch.ref,
|
|
7668
|
+
sha: dispatch.sha,
|
|
7669
|
+
workDir: sourceDir,
|
|
7670
|
+
gitAuth: sourceAuth,
|
|
7671
|
+
token: sourceAuth ? void 0 : dispatch.token
|
|
7672
|
+
});
|
|
7673
|
+
return sourceDir;
|
|
7674
|
+
}
|
|
7675
|
+
/**
|
|
7676
|
+
* Restore deps, materialize the workflow source, and install `.kici/`
|
|
7677
|
+
* dependencies for a dynamic-eval job — everything that has to exist before its
|
|
7678
|
+
* workflow module can be imported.
|
|
7679
|
+
*
|
|
7680
|
+
* The init handler performs the same three steps against its own log wording and
|
|
7681
|
+
* keeps its own copy: sharing one helper would have to either move that
|
|
7682
|
+
* handler's `logger.info` call site (which Loki keys off) or parameterize it,
|
|
7683
|
+
* and neither is worth it for twenty lines.
|
|
7684
|
+
*/
|
|
7685
|
+
async function materializeEvalWorkspace(dispatch, workDir, log) {
|
|
7686
|
+
if (dispatch.depsUrl) {
|
|
7687
|
+
log("Restoring dependencies from cache");
|
|
7688
|
+
await restoreDeps(workDir, dispatch.depsUrl, dispatch.depsHash);
|
|
7689
|
+
log("Dependencies restored");
|
|
7690
|
+
}
|
|
7691
|
+
if (dispatch.sourceTarUrl) {
|
|
7692
|
+
log("Restoring workflow source from cached tarball");
|
|
7693
|
+
await restoreSource(workDir, dispatch.sourceTarUrl);
|
|
7694
|
+
} else {
|
|
7695
|
+
log(`Cloning ${dispatch.repoUrl} ref=${dispatch.ref}`);
|
|
7696
|
+
const cloneStart = Date.now();
|
|
7697
|
+
await gitClone({
|
|
7698
|
+
repoUrl: dispatch.repoUrl,
|
|
7699
|
+
ref: dispatch.ref,
|
|
7700
|
+
sha: dispatch.sha,
|
|
7701
|
+
workDir,
|
|
7702
|
+
gitAuth: dispatch.sourceAuth,
|
|
7703
|
+
token: dispatch.sourceAuth ? void 0 : dispatch.token
|
|
7704
|
+
});
|
|
7705
|
+
cloneDurationSeconds.record((Date.now() - cloneStart) / 1e3);
|
|
7706
|
+
}
|
|
7707
|
+
const kiciDir = join(workDir, ".kici");
|
|
7708
|
+
if (!dispatch.depsUrl && await fileExists(join(kiciDir, "package.json"))) {
|
|
7709
|
+
log("Installing dependencies locally");
|
|
7710
|
+
await installDeps(kiciDir, {
|
|
7711
|
+
npmRegistries: dispatch.npmRegistries,
|
|
7712
|
+
installEnvSecrets: dispatch.installEnvSecrets,
|
|
7713
|
+
jobIdShort: dispatch.jobId.slice(0, 8)
|
|
7714
|
+
});
|
|
7715
|
+
}
|
|
7716
|
+
}
|
|
7717
|
+
/**
|
|
7718
|
+
* Build the context a non-global workflow's `filter` is evaluated against.
|
|
7719
|
+
*
|
|
7720
|
+
* `sourceRepo` and `workflowRepo` are the same repo — that is what "non-global"
|
|
7721
|
+
* means — so both carry the same identifier, path, ref, and sha. The zx shell is
|
|
7722
|
+
* rooted at the source tree and streams into the evaluating step's log, matching
|
|
7723
|
+
* what the global eval round hands its own filters.
|
|
7724
|
+
*
|
|
7725
|
+
* They are two distinct objects all the same. Being the same repo is a fact
|
|
7726
|
+
* about their VALUES, not a licence to hand the author one object under two
|
|
7727
|
+
* names: a filter that mutated `ctx.sourceRepo` would silently see
|
|
7728
|
+
* `ctx.workflowRepo` change with it, which happens on no other path.
|
|
7729
|
+
*/
|
|
7730
|
+
async function buildInitFilterInput(dispatch, event, workDir, emit) {
|
|
7731
|
+
const sourceDir = await ensureFilterSourceDir(dispatch, workDir);
|
|
7732
|
+
const diff = await resolveEvalChangedFiles(dispatch, event, sourceDir);
|
|
7733
|
+
const repo = {
|
|
7734
|
+
identifier: repoIdentifierFromUrl(dispatch.repoUrl),
|
|
7735
|
+
path: sourceDir,
|
|
7736
|
+
ref: dispatch.ref,
|
|
7737
|
+
sha: dispatch.sha
|
|
7738
|
+
};
|
|
7739
|
+
return {
|
|
7740
|
+
sourceRepo: repo,
|
|
7741
|
+
workflowRepo: { ...repo },
|
|
7742
|
+
changedFiles: diff.files,
|
|
7743
|
+
changedFilesStatus: diff.status,
|
|
7744
|
+
env: process.env,
|
|
7745
|
+
$: await buildEvalShell(sourceDir, emit)
|
|
7746
|
+
};
|
|
7747
|
+
}
|
|
7748
|
+
/**
|
|
7749
|
+
* Build the per-invocation zx `$` a global eval round hands to filters and
|
|
7750
|
+
* generators, so a `await $\`…\`` inside one is visible in the eval step's log.
|
|
7751
|
+
*
|
|
7752
|
+
* **`env` is the LIVE `process.env` reference, never a spread.** A spread is a
|
|
7753
|
+
* snapshot taken when the shell is built, which is before the round applies the
|
|
7754
|
+
* seven `KICI_*` keys — so a filter that shells out (`$\`printenv
|
|
7755
|
+
* KICI_SOURCE_REPO_PATH\``, or any subprocess inheriting env) would see nothing
|
|
7756
|
+
* here while the sandbox re-evaluation's ambient `$` resolves `process.env`
|
|
7757
|
+
* after `setupGlobalWorkflowEnv` has run and does see them. That is the same
|
|
7758
|
+
* two-worlds determinism failure the cwd choice below exists to prevent, one
|
|
7759
|
+
* layer down. Passing the live reference reproduces the ambient `$`'s own
|
|
7760
|
+
* behaviour, which is what the sandbox uses.
|
|
7761
|
+
*
|
|
7762
|
+
* `verbose: true` + `makeStreamingZxLog` honors a per-call `quiet: true`, so a
|
|
7763
|
+
* decrypted secret never leaks into the log.
|
|
7764
|
+
*
|
|
7765
|
+
* `emit` is a callback rather than the `LogStreamer` itself so the caller can
|
|
7766
|
+
* route it through its own closed-guard: `LogStreamer.destroy()` sets no closed
|
|
7767
|
+
* flag and `addLine` buffers unconditionally, so a subprocess line arriving
|
|
7768
|
+
* after the step was reported would otherwise emit a `log.chunk` for a terminal
|
|
7769
|
+
* step. That is the likeliest path for it — an orphaned candidate is usually
|
|
7770
|
+
* orphaned *because* it is waiting on a subprocess.
|
|
7771
|
+
*/
|
|
7772
|
+
async function buildEvalShell(cwd, emit) {
|
|
7773
|
+
const { $: zx$ } = await import("zx");
|
|
7774
|
+
return zx$({
|
|
7775
|
+
cwd,
|
|
7776
|
+
env: process.env,
|
|
7777
|
+
verbose: true,
|
|
7778
|
+
quiet: false,
|
|
7779
|
+
log: makeStreamingZxLog(emit)
|
|
7780
|
+
});
|
|
7781
|
+
}
|
|
7782
|
+
/**
|
|
6982
7783
|
* Resolve the absolute path to the compiled workflow-runner.js entry point.
|
|
6983
7784
|
*
|
|
6984
7785
|
* The runner is a separate rolldown entry point. Its location depends on the
|
|
@@ -7062,9 +7863,10 @@ async function resolveJobWorkDir(inPlace, repoUrl) {
|
|
|
7062
7863
|
inPlace: false
|
|
7063
7864
|
};
|
|
7064
7865
|
}
|
|
7065
|
-
var logger$2, JobRunner$1;
|
|
7866
|
+
var logger$2, DEFAULT_GLOBAL_EVAL_ROUND_TIMEOUT_MS, DEFAULT_GLOBAL_EVAL_CANDIDATE_TIMEOUT_MS, FILTER_SOURCE_DIRNAME, JobRunner$1;
|
|
7066
7867
|
var init_job_runner = __esmMin((() => {
|
|
7067
7868
|
init_git_clone();
|
|
7869
|
+
init_changed_files();
|
|
7068
7870
|
init_workflow_loader();
|
|
7069
7871
|
init_source_packer();
|
|
7070
7872
|
init_source_restore();
|
|
@@ -7076,6 +7878,9 @@ var init_job_runner = __esmMin((() => {
|
|
|
7076
7878
|
init_timeout_util();
|
|
7077
7879
|
init_streaming_zx_log();
|
|
7078
7880
|
init_dynamic_job_serializer();
|
|
7881
|
+
init_generator_context();
|
|
7882
|
+
init_global_workflow_env();
|
|
7883
|
+
init_global_eval_runner();
|
|
7079
7884
|
init_log_streamer();
|
|
7080
7885
|
init_overlay_applier();
|
|
7081
7886
|
init_dep_installer();
|
|
@@ -7085,6 +7890,9 @@ var init_job_runner = __esmMin((() => {
|
|
|
7085
7890
|
init_sandbox();
|
|
7086
7891
|
init_prometheus();
|
|
7087
7892
|
logger$2 = createLogger({ prefix: "job-runner" });
|
|
7893
|
+
DEFAULT_GLOBAL_EVAL_ROUND_TIMEOUT_MS = 12e4;
|
|
7894
|
+
DEFAULT_GLOBAL_EVAL_CANDIDATE_TIMEOUT_MS = 2e4;
|
|
7895
|
+
FILTER_SOURCE_DIRNAME = "__kici_filter_source__";
|
|
7088
7896
|
JobRunner$1 = class {
|
|
7089
7897
|
send;
|
|
7090
7898
|
config;
|
|
@@ -7180,6 +7988,10 @@ var init_job_runner = __esmMin((() => {
|
|
|
7180
7988
|
await this.handleInitJob(dispatch, workDir, abortController);
|
|
7181
7989
|
return true;
|
|
7182
7990
|
}
|
|
7991
|
+
if (jobConfig.globalEvalRound === true) {
|
|
7992
|
+
await this.handleGlobalEvalRound(dispatch, workDir, abortController);
|
|
7993
|
+
return true;
|
|
7994
|
+
}
|
|
7183
7995
|
if (jobConfig.dynamicJobFn === true) {
|
|
7184
7996
|
await this.handleDynamicJobFn(dispatch, workDir, abortController);
|
|
7185
7997
|
return true;
|
|
@@ -7595,6 +8407,53 @@ var init_job_runner = __esmMin((() => {
|
|
|
7595
8407
|
}
|
|
7596
8408
|
}
|
|
7597
8409
|
/**
|
|
8410
|
+
* Materialize the init job's workflow source into `workDir`. A test run ships
|
|
8411
|
+
* its full working tree as an encrypted overlay tarball (`fullRepo`) rather
|
|
8412
|
+
* than a git repo, so skip the clone and let the overlay populate the
|
|
8413
|
+
* workspace — the same handling the normal execution-job path uses. Otherwise
|
|
8414
|
+
* restore from the cached tarball if present, else clone. In every case, apply
|
|
8415
|
+
* an attached overlay tarball afterward (test runs with uncommitted changes;
|
|
8416
|
+
* for a fullRepo run this is what actually populates the workspace, so the
|
|
8417
|
+
* init job resolves a dynamic context against the real source tree instead of
|
|
8418
|
+
* an empty directory).
|
|
8419
|
+
*/
|
|
8420
|
+
async materializeInitJobSource(dispatch, workDir, initLog) {
|
|
8421
|
+
const initJobConfig = dispatch.jobConfig;
|
|
8422
|
+
if (initJobConfig.fullRepo) {
|
|
8423
|
+
initLog("Test run: materializing workspace from overlay (no clone)");
|
|
8424
|
+
await fsPromises.mkdir(workDir, { recursive: true });
|
|
8425
|
+
} else if (dispatch.sourceTarUrl) {
|
|
8426
|
+
initLog("Restoring workflow source from cached tarball");
|
|
8427
|
+
await restoreSource(workDir, dispatch.sourceTarUrl);
|
|
8428
|
+
} else {
|
|
8429
|
+
initLog(`Cloning ${dispatch.repoUrl} (ref: ${dispatch.ref})`);
|
|
8430
|
+
const cloneStart = Date.now();
|
|
8431
|
+
await gitClone({
|
|
8432
|
+
repoUrl: dispatch.repoUrl,
|
|
8433
|
+
ref: dispatch.ref,
|
|
8434
|
+
sha: dispatch.sha,
|
|
8435
|
+
workDir,
|
|
8436
|
+
gitAuth: dispatch.sourceAuth,
|
|
8437
|
+
token: dispatch.sourceAuth ? void 0 : dispatch.token
|
|
8438
|
+
});
|
|
8439
|
+
cloneDurationSeconds.record((Date.now() - cloneStart) / 1e3);
|
|
8440
|
+
}
|
|
8441
|
+
if (initJobConfig.tarballUrl && initJobConfig.cliPublicKey && initJobConfig.orchestratorPrivateKey) {
|
|
8442
|
+
initLog("Applying overlay tarball for test run");
|
|
8443
|
+
const overlayResult = await applyOverlay({
|
|
8444
|
+
tarballUrl: initJobConfig.tarballUrl,
|
|
8445
|
+
cliPublicKey: initJobConfig.cliPublicKey,
|
|
8446
|
+
orchestratorPrivateKey: initJobConfig.orchestratorPrivateKey,
|
|
8447
|
+
repoDir: workDir
|
|
8448
|
+
});
|
|
8449
|
+
logger$2.info("Init job: overlay applied", {
|
|
8450
|
+
jobId: dispatch.jobId,
|
|
8451
|
+
filesApplied: overlayResult.filesApplied,
|
|
8452
|
+
filesDeleted: overlayResult.filesDeleted
|
|
8453
|
+
});
|
|
8454
|
+
}
|
|
8455
|
+
}
|
|
8456
|
+
/**
|
|
7598
8457
|
* Phase 2 of build: install dependencies locally if needed for the build,
|
|
7599
8458
|
* and (when the orchestrator has flagged the dep cache as stale) pack
|
|
7600
8459
|
* `.kici/node_modules/` into a tarball and upload it to the deps cache.
|
|
@@ -7670,6 +8529,169 @@ var init_job_runner = __esmMin((() => {
|
|
|
7670
8529
|
});
|
|
7671
8530
|
}
|
|
7672
8531
|
/**
|
|
8532
|
+
* Clone both repos for a global eval round and materialize the workflow
|
|
8533
|
+
* repo's dependencies, mirroring the sandbox's own dual-clone: the workflow
|
|
8534
|
+
* repo under `<workDir>/workflow`, the source repo under `<workDir>/source`.
|
|
8535
|
+
*
|
|
8536
|
+
* `.kici/` lives in the WORKFLOW repo for a global workflow, so deps and the
|
|
8537
|
+
* scratch-dir git exclude both apply to that checkout, never the source one.
|
|
8538
|
+
*/
|
|
8539
|
+
async checkoutForGlobalEvalRound(dispatch, config, workflowDir, sourceDir, log) {
|
|
8540
|
+
const workflowAuth = dispatch.workflowAuth ?? dispatch.sourceAuth;
|
|
8541
|
+
const sourceAuth = dispatch.sourceAuth ?? dispatch.workflowAuth;
|
|
8542
|
+
await fsPromises.mkdir(workflowDir, { recursive: true });
|
|
8543
|
+
await fsPromises.mkdir(sourceDir, { recursive: true });
|
|
8544
|
+
log(`Cloning workflow repo ${config.workflowRepoUrl} (ref: ${config.workflowRef ?? ""})`);
|
|
8545
|
+
const cloneStart = Date.now();
|
|
8546
|
+
await gitClone({
|
|
8547
|
+
repoUrl: config.workflowRepoUrl,
|
|
8548
|
+
ref: config.workflowRef ?? "",
|
|
8549
|
+
sha: config.workflowSha ?? "",
|
|
8550
|
+
workDir: workflowDir,
|
|
8551
|
+
gitAuth: workflowAuth,
|
|
8552
|
+
token: workflowAuth ? void 0 : dispatch.token
|
|
8553
|
+
});
|
|
8554
|
+
await excludeScratchFromGit(workflowDir);
|
|
8555
|
+
log(`Cloning source repo ${dispatch.repoUrl} (ref: ${dispatch.ref})`);
|
|
8556
|
+
await gitClone({
|
|
8557
|
+
repoUrl: dispatch.repoUrl,
|
|
8558
|
+
ref: dispatch.ref,
|
|
8559
|
+
sha: dispatch.sha,
|
|
8560
|
+
workDir: sourceDir,
|
|
8561
|
+
gitAuth: sourceAuth,
|
|
8562
|
+
token: sourceAuth ? void 0 : dispatch.token
|
|
8563
|
+
});
|
|
8564
|
+
cloneDurationSeconds.record((Date.now() - cloneStart) / 1e3);
|
|
8565
|
+
if (dispatch.depsUrl) {
|
|
8566
|
+
log("Restoring dependencies from cache");
|
|
8567
|
+
await restoreDeps(workflowDir, dispatch.depsUrl, dispatch.depsHash);
|
|
8568
|
+
}
|
|
8569
|
+
if (dispatch.sourceTarUrl) {
|
|
8570
|
+
log("Restoring workflow source from cached tarball");
|
|
8571
|
+
await restoreSource(workflowDir, dispatch.sourceTarUrl);
|
|
8572
|
+
}
|
|
8573
|
+
const kiciDir = join(workflowDir, ".kici");
|
|
8574
|
+
if (!dispatch.depsUrl && await fileExists(join(kiciDir, "package.json"))) {
|
|
8575
|
+
log("Installing dependencies locally");
|
|
8576
|
+
await installDeps(kiciDir, {
|
|
8577
|
+
npmRegistries: dispatch.npmRegistries,
|
|
8578
|
+
installEnvSecrets: dispatch.installEnvSecrets,
|
|
8579
|
+
jobIdShort: dispatch.jobId.slice(0, 8)
|
|
8580
|
+
});
|
|
8581
|
+
}
|
|
8582
|
+
}
|
|
8583
|
+
/**
|
|
8584
|
+
* Handle a pre-run global eval round.
|
|
8585
|
+
*
|
|
8586
|
+
* The round runs once per (event × workflow repo) BEFORE any run row exists:
|
|
8587
|
+
* it checks out the workflow repo and the source repo, then runs each
|
|
8588
|
+
* candidate global workflow's `filter` and — for a survivor — its
|
|
8589
|
+
* `DynamicJobFn`s, so the orchestrator learns which workflows apply to this
|
|
8590
|
+
* source repo and which jobs each one generates.
|
|
8591
|
+
*
|
|
8592
|
+
* A candidate that fails is reported indeterminate inside the result, not as
|
|
8593
|
+
* a job failure: the round carries several unrelated org-wide workflows, and
|
|
8594
|
+
* one broken filter must not suppress the rest. The job itself fails only
|
|
8595
|
+
* when the checkout or the round machinery breaks.
|
|
8596
|
+
*/
|
|
8597
|
+
async handleGlobalEvalRound(dispatch, workDir, abortController) {
|
|
8598
|
+
const { runId, jobId, jobConfig } = dispatch;
|
|
8599
|
+
const config = jobConfig;
|
|
8600
|
+
const workflowDir = join(workDir, "workflow");
|
|
8601
|
+
const sourceDir = join(workDir, "source");
|
|
8602
|
+
logger$2.info("Starting global eval round", {
|
|
8603
|
+
jobId,
|
|
8604
|
+
candidateCount: config.candidates.length,
|
|
8605
|
+
workflowRepoIdentifier: config.workflowRepoIdentifier
|
|
8606
|
+
});
|
|
8607
|
+
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.running);
|
|
8608
|
+
const evalStreamer = this.createStepStreamer(dispatch, 0);
|
|
8609
|
+
let streamerClosed = false;
|
|
8610
|
+
const evalLog = (msg) => {
|
|
8611
|
+
if (!streamerClosed) evalStreamer.addLine(msg);
|
|
8612
|
+
};
|
|
8613
|
+
const evalStreamLine = (line, stream) => {
|
|
8614
|
+
if (!streamerClosed) evalStreamer.addLine(line, stream);
|
|
8615
|
+
};
|
|
8616
|
+
const closeStreamer = async () => {
|
|
8617
|
+
await evalStreamer.flush();
|
|
8618
|
+
evalStreamer.destroy();
|
|
8619
|
+
streamerClosed = true;
|
|
8620
|
+
};
|
|
8621
|
+
const evalSink = { addLine: (line) => evalLog(line) };
|
|
8622
|
+
this.sendStepStatus(dispatch, 0, "global-eval", ExecutionStepStatus.enum.running);
|
|
8623
|
+
const heartbeatTimer = setInterval(() => {
|
|
8624
|
+
this.send({
|
|
8625
|
+
type: "job.heartbeat",
|
|
8626
|
+
runId,
|
|
8627
|
+
jobId,
|
|
8628
|
+
timestamp: Date.now()
|
|
8629
|
+
});
|
|
8630
|
+
}, this.config.jobHeartbeatIntervalMs);
|
|
8631
|
+
try {
|
|
8632
|
+
if (abortController.signal.aborted) {
|
|
8633
|
+
await closeStreamer();
|
|
8634
|
+
this.sendStepStatus(dispatch, 0, "global-eval", ExecutionStepStatus.enum.skipped, void 0, evalStreamer.getTotalBytes());
|
|
8635
|
+
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.cancelled);
|
|
8636
|
+
return;
|
|
8637
|
+
}
|
|
8638
|
+
await this.checkoutForGlobalEvalRound(dispatch, config, workflowDir, sourceDir, evalLog);
|
|
8639
|
+
const diff = await resolveEvalChangedFiles(dispatch, config.event, sourceDir);
|
|
8640
|
+
const evalShell = await buildEvalShell(workDir, evalStreamLine);
|
|
8641
|
+
const roundResult = await runCaptured(evalSink, () => runGlobalEvalRound({
|
|
8642
|
+
workflowDir,
|
|
8643
|
+
sourceDir,
|
|
8644
|
+
repos: buildRoundRepos(dispatch, config, workflowDir, sourceDir),
|
|
8645
|
+
candidates: config.candidates,
|
|
8646
|
+
event: config.event,
|
|
8647
|
+
changedFiles: diff.files,
|
|
8648
|
+
changedFilesStatus: diff.status,
|
|
8649
|
+
roundTimeoutMs: config.roundTimeoutMs ?? DEFAULT_GLOBAL_EVAL_ROUND_TIMEOUT_MS,
|
|
8650
|
+
candidateTimeoutMs: config.candidateTimeoutMs ?? DEFAULT_GLOBAL_EVAL_CANDIDATE_TIMEOUT_MS,
|
|
8651
|
+
signal: abortController.signal,
|
|
8652
|
+
$: evalShell,
|
|
8653
|
+
log: {
|
|
8654
|
+
info: (msg) => evalLog(msg),
|
|
8655
|
+
warn: (msg) => evalLog(`WARN: ${msg}`),
|
|
8656
|
+
error: (msg) => evalLog(`ERROR: ${msg}`),
|
|
8657
|
+
debug: (msg) => evalLog(`DEBUG: ${msg}`)
|
|
8658
|
+
},
|
|
8659
|
+
kici: buildKiciApi(this._sendApiRequest ? withBootstrapInterception((method, params) => this._sendApiRequest(method, params ?? {})) : () => Promise.reject(/* @__PURE__ */ new Error("Agent API not available")))
|
|
8660
|
+
}));
|
|
8661
|
+
const running = roundResult.candidates.filter((c) => c.run).length;
|
|
8662
|
+
const indeterminate = roundResult.candidates.filter((c) => c.indeterminate).length;
|
|
8663
|
+
logger$2.info("Global eval round completed", {
|
|
8664
|
+
jobId,
|
|
8665
|
+
candidateCount: roundResult.candidates.length,
|
|
8666
|
+
running,
|
|
8667
|
+
indeterminate
|
|
8668
|
+
});
|
|
8669
|
+
evalLog(`Global eval round completed: ${running} of ${roundResult.candidates.length} workflow(s) apply` + (indeterminate > 0 ? ` (${indeterminate} indeterminate)` : ""));
|
|
8670
|
+
for (const candidate of roundResult.candidates) if (candidate.indeterminate) evalLog(` ${candidate.workflowName}: indeterminate — ${candidate.reason ?? "unknown"}`);
|
|
8671
|
+
await closeStreamer();
|
|
8672
|
+
this.sendStepStatus(dispatch, 0, "global-eval", ExecutionStepStatus.enum.success, void 0, evalStreamer.getTotalBytes());
|
|
8673
|
+
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.success, {
|
|
8674
|
+
globalEvalResult: roundResult,
|
|
8675
|
+
globalEvalComplete: true
|
|
8676
|
+
});
|
|
8677
|
+
} catch (err) {
|
|
8678
|
+
const errorMsg = toErrorMessage(err);
|
|
8679
|
+
logger$2.error("Global eval round failed", {
|
|
8680
|
+
jobId,
|
|
8681
|
+
error: errorMsg
|
|
8682
|
+
});
|
|
8683
|
+
evalLog(`Error: ${errorMsg}`);
|
|
8684
|
+
await closeStreamer();
|
|
8685
|
+
this.sendStepStatus(dispatch, 0, "global-eval", ExecutionStepStatus.enum.failed, { error: errorMsg }, evalStreamer.getTotalBytes());
|
|
8686
|
+
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.failed, {
|
|
8687
|
+
error: errorMsg,
|
|
8688
|
+
globalEvalFailed: true
|
|
8689
|
+
});
|
|
8690
|
+
} finally {
|
|
8691
|
+
clearInterval(heartbeatTimer);
|
|
8692
|
+
}
|
|
8693
|
+
}
|
|
8694
|
+
/**
|
|
7673
8695
|
* Handle an init-only job.
|
|
7674
8696
|
*
|
|
7675
8697
|
* Init jobs evaluate dynamic functions (environment, env, concurrencyGroup)
|
|
@@ -7691,8 +8713,20 @@ var init_job_runner = __esmMin((() => {
|
|
|
7691
8713
|
});
|
|
7692
8714
|
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.running);
|
|
7693
8715
|
const initStreamer = this.createStepStreamer(dispatch, 0);
|
|
7694
|
-
|
|
7695
|
-
const
|
|
8716
|
+
let streamerClosed = false;
|
|
8717
|
+
const initLog = (msg) => {
|
|
8718
|
+
if (!streamerClosed) initStreamer.addLine(msg);
|
|
8719
|
+
};
|
|
8720
|
+
const initStreamLine = (line, stream) => {
|
|
8721
|
+
if (!streamerClosed) initStreamer.addLine(line, stream);
|
|
8722
|
+
};
|
|
8723
|
+
const closeStreamer = async () => {
|
|
8724
|
+
if (streamerClosed) return;
|
|
8725
|
+
streamerClosed = true;
|
|
8726
|
+
await initStreamer.flush();
|
|
8727
|
+
initStreamer.destroy();
|
|
8728
|
+
};
|
|
8729
|
+
const initSink = { addLine: (line) => initLog(line) };
|
|
7696
8730
|
this.sendStepStatus(dispatch, 0, "init", ExecutionStepStatus.enum.running);
|
|
7697
8731
|
const heartbeatTimer = setInterval(() => {
|
|
7698
8732
|
this.send({
|
|
@@ -7704,8 +8738,7 @@ var init_job_runner = __esmMin((() => {
|
|
|
7704
8738
|
}, this.config.jobHeartbeatIntervalMs);
|
|
7705
8739
|
try {
|
|
7706
8740
|
if (abortController.signal.aborted) {
|
|
7707
|
-
await
|
|
7708
|
-
initStreamer.destroy();
|
|
8741
|
+
await closeStreamer();
|
|
7709
8742
|
this.sendStepStatus(dispatch, 0, "init", ExecutionStepStatus.enum.skipped, void 0, initStreamer.getTotalBytes());
|
|
7710
8743
|
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.cancelled);
|
|
7711
8744
|
return;
|
|
@@ -7714,22 +8747,7 @@ var init_job_runner = __esmMin((() => {
|
|
|
7714
8747
|
initLog("Restoring dependencies from cache");
|
|
7715
8748
|
await restoreDeps(workDir, dispatch.depsUrl, dispatch.depsHash);
|
|
7716
8749
|
}
|
|
7717
|
-
|
|
7718
|
-
initLog("Restoring workflow source from cached tarball");
|
|
7719
|
-
await restoreSource(workDir, dispatch.sourceTarUrl);
|
|
7720
|
-
} else {
|
|
7721
|
-
initLog(`Cloning ${dispatch.repoUrl} (ref: ${dispatch.ref})`);
|
|
7722
|
-
const cloneStart = Date.now();
|
|
7723
|
-
await gitClone({
|
|
7724
|
-
repoUrl: dispatch.repoUrl,
|
|
7725
|
-
ref: dispatch.ref,
|
|
7726
|
-
sha: dispatch.sha,
|
|
7727
|
-
workDir,
|
|
7728
|
-
gitAuth: dispatch.sourceAuth,
|
|
7729
|
-
token: dispatch.sourceAuth ? void 0 : dispatch.token
|
|
7730
|
-
});
|
|
7731
|
-
cloneDurationSeconds.record((Date.now() - cloneStart) / 1e3);
|
|
7732
|
-
}
|
|
8750
|
+
await this.materializeInitJobSource(dispatch, workDir, initLog);
|
|
7733
8751
|
const kiciDir = join(workDir, ".kici");
|
|
7734
8752
|
const hasPackage = await fileExists(join(kiciDir, "package.json"));
|
|
7735
8753
|
logger$2.info("Init job: checking deps", {
|
|
@@ -7745,26 +8763,29 @@ var init_job_runner = __esmMin((() => {
|
|
|
7745
8763
|
jobIdShort: dispatch.jobId.slice(0, 8)
|
|
7746
8764
|
});
|
|
7747
8765
|
}
|
|
8766
|
+
const filterInput = config.hasFilter ? await buildInitFilterInput(dispatch, config.event, workDir, initStreamLine) : void 0;
|
|
7748
8767
|
const initResult = await runCaptured(initSink, async () => {
|
|
7749
8768
|
const { module } = await loadWorkflowSource(workDir, config.source, config.contentHash, config.resolvedHashFiles);
|
|
7750
8769
|
const workflow = extractWorkflow(module, config.workflowName);
|
|
7751
|
-
initLog(`Evaluating dynamic fields for job '${config.targetJobName}' (env=${config.dynamicEnv} context=${config.dynamicContext} concurrencyGroup=${config.dynamicConcurrencyGroup} matrix=${config.dynamicMatrix ?? false})`);
|
|
8770
|
+
initLog(`Evaluating dynamic fields for job '${config.targetJobName}' (env=${config.dynamicEnv} context=${config.dynamicContext} concurrencyGroup=${config.dynamicConcurrencyGroup} matrix=${config.dynamicMatrix ?? false} filter=${config.hasFilter ?? false})`);
|
|
7752
8771
|
return evaluateDynamicFields(workflow, config.targetJobName, config.event, {
|
|
7753
8772
|
dynamicContext: config.dynamicContext,
|
|
7754
8773
|
dynamicEnv: config.dynamicEnv,
|
|
7755
8774
|
dynamicConcurrencyGroup: config.dynamicConcurrencyGroup,
|
|
7756
|
-
dynamicMatrix: config.dynamicMatrix ?? false
|
|
7757
|
-
|
|
8775
|
+
dynamicMatrix: config.dynamicMatrix ?? false,
|
|
8776
|
+
hasFilter: config.hasFilter ?? false
|
|
8777
|
+
}, config.timeoutMs, filterInput);
|
|
7758
8778
|
});
|
|
7759
8779
|
logger$2.info("Init job completed successfully", {
|
|
7760
8780
|
jobId,
|
|
7761
8781
|
hasContext: initResult.contextNames !== void 0,
|
|
7762
8782
|
hasEnv: initResult.env !== void 0,
|
|
7763
|
-
hasConcurrencyGroup: initResult.concurrencyGroup !== void 0
|
|
8783
|
+
hasConcurrencyGroup: initResult.concurrencyGroup !== void 0,
|
|
8784
|
+
filterPassed: initResult.filterPassed
|
|
7764
8785
|
});
|
|
8786
|
+
if (initResult.filterPassed === false) initLog(`Workflow filter returned false — '${config.workflowName}' does not apply to this event, so no job is dispatched`);
|
|
7765
8787
|
initLog("Init completed successfully");
|
|
7766
|
-
await
|
|
7767
|
-
initStreamer.destroy();
|
|
8788
|
+
await closeStreamer();
|
|
7768
8789
|
this.sendStepStatus(dispatch, 0, "init", ExecutionStepStatus.enum.success, void 0, initStreamer.getTotalBytes());
|
|
7769
8790
|
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.success, {
|
|
7770
8791
|
initResult,
|
|
@@ -7777,8 +8798,7 @@ var init_job_runner = __esmMin((() => {
|
|
|
7777
8798
|
error: errorMsg
|
|
7778
8799
|
});
|
|
7779
8800
|
initLog(`Error: ${errorMsg}`);
|
|
7780
|
-
await
|
|
7781
|
-
initStreamer.destroy();
|
|
8801
|
+
await closeStreamer();
|
|
7782
8802
|
this.sendStepStatus(dispatch, 0, "init", ExecutionStepStatus.enum.failed, { error: errorMsg }, initStreamer.getTotalBytes());
|
|
7783
8803
|
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.failed, {
|
|
7784
8804
|
error: errorMsg,
|
|
@@ -7822,36 +8842,7 @@ var init_job_runner = __esmMin((() => {
|
|
|
7822
8842
|
this.sendJobStatus(dispatch, ExecutionJobStatus.enum.cancelled);
|
|
7823
8843
|
return;
|
|
7824
8844
|
}
|
|
7825
|
-
|
|
7826
|
-
evalLog("Restoring dependencies from cache");
|
|
7827
|
-
await restoreDeps(workDir, dispatch.depsUrl, dispatch.depsHash);
|
|
7828
|
-
evalLog("Dependencies restored");
|
|
7829
|
-
}
|
|
7830
|
-
if (dispatch.sourceTarUrl) {
|
|
7831
|
-
evalLog("Restoring workflow source from cached tarball");
|
|
7832
|
-
await restoreSource(workDir, dispatch.sourceTarUrl);
|
|
7833
|
-
} else {
|
|
7834
|
-
evalLog(`Cloning ${dispatch.repoUrl} ref=${dispatch.ref}`);
|
|
7835
|
-
const cloneStart = Date.now();
|
|
7836
|
-
await gitClone({
|
|
7837
|
-
repoUrl: dispatch.repoUrl,
|
|
7838
|
-
ref: dispatch.ref,
|
|
7839
|
-
sha: dispatch.sha,
|
|
7840
|
-
workDir,
|
|
7841
|
-
gitAuth: dispatch.sourceAuth,
|
|
7842
|
-
token: dispatch.sourceAuth ? void 0 : dispatch.token
|
|
7843
|
-
});
|
|
7844
|
-
cloneDurationSeconds.record((Date.now() - cloneStart) / 1e3);
|
|
7845
|
-
}
|
|
7846
|
-
const kiciDir = join(workDir, ".kici");
|
|
7847
|
-
if (!dispatch.depsUrl && await fileExists(join(kiciDir, "package.json"))) {
|
|
7848
|
-
evalLog("Installing dependencies locally");
|
|
7849
|
-
await installDeps(kiciDir, {
|
|
7850
|
-
npmRegistries: dispatch.npmRegistries,
|
|
7851
|
-
installEnvSecrets: dispatch.installEnvSecrets,
|
|
7852
|
-
jobIdShort: dispatch.jobId.slice(0, 8)
|
|
7853
|
-
});
|
|
7854
|
-
}
|
|
8845
|
+
await materializeEvalWorkspace(dispatch, workDir, evalLog);
|
|
7855
8846
|
const { $: zx$ } = await import("zx");
|
|
7856
8847
|
const scopedDollar = zx$({
|
|
7857
8848
|
cwd: workDir,
|
|
@@ -7861,6 +8852,7 @@ var init_job_runner = __esmMin((() => {
|
|
|
7861
8852
|
log: makeStreamingZxLog((line, stream) => evalStreamer.addLine(line, stream))
|
|
7862
8853
|
});
|
|
7863
8854
|
const evalSink = { addLine: (line) => evalStreamer.addLine(line) };
|
|
8855
|
+
const filterInput = config.hasFilter ? await buildInitFilterInput(dispatch, config.event, workDir, (line, stream) => evalStreamer.addLine(line, stream)) : void 0;
|
|
7864
8856
|
const dynamicJobLogger = {
|
|
7865
8857
|
info: (msg, ..._args) => evalLog(msg),
|
|
7866
8858
|
warn: (msg, ..._args) => evalLog(`WARN: ${msg}`),
|
|
@@ -7872,20 +8864,25 @@ var init_job_runner = __esmMin((() => {
|
|
|
7872
8864
|
const { module } = await loadWorkflowSource(workDir, config.source.file, config.contentHash, config.resolvedHashFiles);
|
|
7873
8865
|
evalLog("Workflow loaded");
|
|
7874
8866
|
const { extractDynamicJobFn } = await Promise.resolve().then(() => (init_workflow_loader(), workflow_loader_exports));
|
|
7875
|
-
const
|
|
8867
|
+
const workflow = extractWorkflow(module, config.workflowName);
|
|
8868
|
+
if (config.hasFilter) {
|
|
8869
|
+
if (!await evaluateWorkflowFilter(workflow, config.event, filterInput, timeoutMs)) {
|
|
8870
|
+
evalLog(`Workflow filter returned false — '${config.workflowName}' does not apply to this event, so its generator is not run and no jobs are generated`);
|
|
8871
|
+
return [];
|
|
8872
|
+
}
|
|
8873
|
+
}
|
|
8874
|
+
const dynamicFn = extractDynamicJobFn(workflow, config.source.index);
|
|
7876
8875
|
evalLog(`Evaluating DynamicJobFn (index ${config.source.index}, timeout ${timeoutMs}ms)`);
|
|
7877
8876
|
const needs = buildEvalNeedsContext(config);
|
|
7878
|
-
const context = {
|
|
8877
|
+
const context = buildGeneratorContext({
|
|
8878
|
+
workflowName: config.workflowName,
|
|
8879
|
+
event: config.event,
|
|
8880
|
+
env: process.env,
|
|
8881
|
+
...needs && { needs },
|
|
7879
8882
|
$: scopedDollar,
|
|
7880
|
-
ctx: {
|
|
7881
|
-
workflow: { name: config.workflowName },
|
|
7882
|
-
event: config.event,
|
|
7883
|
-
...needs && { needs }
|
|
7884
|
-
},
|
|
7885
8883
|
log: dynamicJobLogger,
|
|
7886
|
-
env: process.env,
|
|
7887
8884
|
kici
|
|
7888
|
-
};
|
|
8885
|
+
});
|
|
7889
8886
|
return serializeJobsToLock(await withTimeout(() => dynamicFn(context), timeoutMs, `DynamicJobFn index ${config.source.index} in workflow '${config.workflowName}'`), {
|
|
7890
8887
|
event: config.event,
|
|
7891
8888
|
$: scopedDollar,
|
|
@@ -8111,14 +9108,14 @@ var init_job_runner = __esmMin((() => {
|
|
|
8111
9108
|
*/
|
|
8112
9109
|
init_console_capture();
|
|
8113
9110
|
init_npm_resolver();
|
|
8114
|
-
const AGENT_VERSION = "0.
|
|
8115
|
-
const BUILD_COMMIT = "
|
|
8116
|
-
const SDK_VERSION = "0.
|
|
8117
|
-
const SDK_BUNDLE_HASH = "
|
|
8118
|
-
const SHARED_VERSION = "0.
|
|
8119
|
-
const SHARED_BUNDLE_HASH = "
|
|
8120
|
-
const ENGINE_VERSION = "0.
|
|
8121
|
-
const ENGINE_BUNDLE_HASH = "
|
|
9111
|
+
const AGENT_VERSION = "0.5.0";
|
|
9112
|
+
const BUILD_COMMIT = "cb51c7d1e";
|
|
9113
|
+
const SDK_VERSION = "0.5.0";
|
|
9114
|
+
const SDK_BUNDLE_HASH = "5b85e7cffa4a08e39329ff80448a2744af8e5b5f9840ec3801fad45ed11a3de9";
|
|
9115
|
+
const SHARED_VERSION = "0.5.0";
|
|
9116
|
+
const SHARED_BUNDLE_HASH = "b6b6d1818d1fe648150422daf073dafc18b037404cacf82e0ec03b4a2863e4c4";
|
|
9117
|
+
const ENGINE_VERSION = "0.5.0";
|
|
9118
|
+
const ENGINE_BUNDLE_HASH = "35d174ce6f4748abffcad194b335b508cade5d1c8af16d9cd3bdcb744f38717e";
|
|
8122
9119
|
initTelemetry({
|
|
8123
9120
|
serviceName: "kici-agent",
|
|
8124
9121
|
otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|