@aictiq/cli 0.1.0 → 0.3.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/index.js +791 -158
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -299,7 +299,7 @@ async function promptSecret(question) {
|
|
|
299
299
|
const muted = rl;
|
|
300
300
|
const original = muted.output.write.bind(muted.output);
|
|
301
301
|
muted.output.write = (() => true);
|
|
302
|
-
const answer = await new Promise((
|
|
302
|
+
const answer = await new Promise((resolve3) => rl.question("", resolve3));
|
|
303
303
|
muted.output.write = original;
|
|
304
304
|
process.stdout.write("\n");
|
|
305
305
|
return answer.trim();
|
|
@@ -948,7 +948,7 @@ function mcpCommand(globals) {
|
|
|
948
948
|
};
|
|
949
949
|
process.on("SIGINT", () => void shutdown());
|
|
950
950
|
process.on("SIGTERM", () => void shutdown());
|
|
951
|
-
await new Promise((
|
|
951
|
+
await new Promise((resolve3) => process.stdin.once("close", resolve3));
|
|
952
952
|
await shutdown();
|
|
953
953
|
});
|
|
954
954
|
}
|
|
@@ -1146,7 +1146,7 @@ async function streamLog(ctx, orgSlug, runId, follow, pollMs) {
|
|
|
1146
1146
|
if (page.items.length < logPageSize) break;
|
|
1147
1147
|
}
|
|
1148
1148
|
if (finished) return;
|
|
1149
|
-
await new Promise((
|
|
1149
|
+
await new Promise((resolve3) => setTimeout(resolve3, pollMs));
|
|
1150
1150
|
}
|
|
1151
1151
|
}
|
|
1152
1152
|
function formatLogEntry(entry) {
|
|
@@ -1201,9 +1201,10 @@ async function resolveAgentId(ctx, agent) {
|
|
|
1201
1201
|
|
|
1202
1202
|
// src/commands/runner.ts
|
|
1203
1203
|
import { Command as Command7 } from "commander";
|
|
1204
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1204
1205
|
import { existsSync as existsSync3 } from "fs";
|
|
1205
|
-
import { arch, platform } from "os";
|
|
1206
|
-
import { resolve } from "path";
|
|
1206
|
+
import { arch, homedir as homedir4, platform } from "os";
|
|
1207
|
+
import { resolve as resolve2 } from "path";
|
|
1207
1208
|
|
|
1208
1209
|
// src/runner/client.ts
|
|
1209
1210
|
var RunnerHttpError = class extends Error {
|
|
@@ -1245,6 +1246,10 @@ var RunnerClient = class {
|
|
|
1245
1246
|
async claim(harnesses2, slots, signal2) {
|
|
1246
1247
|
return await this.send("/runner/runs/claim", { harnesses: harnesses2, slots }, signal2) ?? null;
|
|
1247
1248
|
}
|
|
1249
|
+
/** Hands a claimed run back before it starts; the instance queues it again and revokes its token. */
|
|
1250
|
+
async release(runId) {
|
|
1251
|
+
await this.send(`/runner/runs/${runId}/release`);
|
|
1252
|
+
}
|
|
1248
1253
|
async started(runId) {
|
|
1249
1254
|
await this.send(`/runner/runs/${runId}/started`);
|
|
1250
1255
|
}
|
|
@@ -1338,15 +1343,16 @@ var RunnerClient = class {
|
|
|
1338
1343
|
};
|
|
1339
1344
|
|
|
1340
1345
|
// src/runner/config.ts
|
|
1346
|
+
import { createHash, randomUUID } from "crypto";
|
|
1341
1347
|
import { chmodSync as chmodSync3, existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
1342
1348
|
import { homedir as homedir3 } from "os";
|
|
1343
1349
|
import { dirname as dirname2, join as join3 } from "path";
|
|
1344
1350
|
|
|
1345
1351
|
// src/runner/workspace.ts
|
|
1346
1352
|
import { spawn } from "child_process";
|
|
1347
|
-
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1353
|
+
import { chmodSync as chmodSync2, mkdirSync as mkdirSync2, realpathSync, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
1348
1354
|
import { homedir as homedir2 } from "os";
|
|
1349
|
-
import { join as join2 } from "path";
|
|
1355
|
+
import { isAbsolute, join as join2, relative, resolve } from "path";
|
|
1350
1356
|
|
|
1351
1357
|
// src/runner/types.ts
|
|
1352
1358
|
var RunFailure = class extends Error {
|
|
@@ -1521,18 +1527,52 @@ function uniqueFileName(value, existing) {
|
|
|
1521
1527
|
function formatBytes(value) {
|
|
1522
1528
|
return value >= 1024 * 1024 ? `${(value / (1024 * 1024)).toFixed(1)} MiB` : `${value} bytes`;
|
|
1523
1529
|
}
|
|
1530
|
+
function resolveLocalRepository(run, options) {
|
|
1531
|
+
const mapped = options.repositories[run.projectKey];
|
|
1532
|
+
if (mapped !== void 0) return { path: mapped, root: null };
|
|
1533
|
+
const hint = run.repo.localPathHint?.trim();
|
|
1534
|
+
const roots = options.repoRoots ?? [];
|
|
1535
|
+
if (hint) {
|
|
1536
|
+
const expanded = expandHome(hint);
|
|
1537
|
+
if (isAbsolute(expanded)) {
|
|
1538
|
+
const real = realpathOrSelf(expanded);
|
|
1539
|
+
for (const configured of roots) {
|
|
1540
|
+
const expandedRoot = expandHome(configured);
|
|
1541
|
+
if (!isAbsolute(expandedRoot)) continue;
|
|
1542
|
+
const root = realpathOrSelf(expandedRoot);
|
|
1543
|
+
if (isWithin(real, root)) return { path: real, root };
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
const example = `("${run.projectKey}": "/path/to/repository")`;
|
|
1548
|
+
const detail = !hint ? ` Add it to the \`workspaces\` map in runner.json ${example}, or set the project's path hint and list its parent directory in \`repoRoots\`.` : roots.length === 0 ? ` The project suggests ${hint}. Add its parent directory to \`repoRoots\` in runner.json (\`aictiq runner root <path>\`), or map it in \`workspaces\` ${example}.` : ` The project suggests ${hint}, which is not under any of this runner's repoRoots (${roots.join(", ")}). Add a root that contains it, or map it in \`workspaces\` ${example}.`;
|
|
1549
|
+
throw new RunFailure(
|
|
1550
|
+
"no-local-repository",
|
|
1551
|
+
`No local repository is mapped for project ${run.projectKey}.${detail}`
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
function expandHome(path) {
|
|
1555
|
+
if (path === "~") return homedir2();
|
|
1556
|
+
return path.startsWith("~/") ? join2(homedir2(), path.slice(2)) : path;
|
|
1557
|
+
}
|
|
1558
|
+
function realpathOrSelf(path) {
|
|
1559
|
+
const normalised = resolve(path);
|
|
1560
|
+
try {
|
|
1561
|
+
return realpathSync(normalised);
|
|
1562
|
+
} catch {
|
|
1563
|
+
return normalised;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
function isWithin(path, root) {
|
|
1567
|
+
const rel = relative(root, path);
|
|
1568
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
1569
|
+
}
|
|
1524
1570
|
function message(error) {
|
|
1525
1571
|
return error instanceof Error ? error.message : String(error);
|
|
1526
1572
|
}
|
|
1527
1573
|
async function provisionLocal(run, checkout, options, env) {
|
|
1528
|
-
const mapped = options
|
|
1529
|
-
|
|
1530
|
-
const hint = run.repo.localPathHint ? ` The project suggests ${run.repo.localPathHint}.` : "";
|
|
1531
|
-
throw new RunFailure(
|
|
1532
|
-
"no-local-repository",
|
|
1533
|
-
`No local repository is mapped for project ${run.projectKey}. Add it to the \`workspaces\` map in runner.json ("${run.projectKey}": "/path/to/repository").${hint}`
|
|
1534
|
-
);
|
|
1535
|
-
}
|
|
1574
|
+
const { path: mapped, root } = resolveLocalRepository(run, options);
|
|
1575
|
+
const origin = root === null ? "mapped for project" : "the path hint for project";
|
|
1536
1576
|
const toplevel = await git(["rev-parse", "--show-toplevel"], {
|
|
1537
1577
|
cwd: mapped,
|
|
1538
1578
|
env,
|
|
@@ -1541,9 +1581,15 @@ async function provisionLocal(run, checkout, options, env) {
|
|
|
1541
1581
|
if (isAbort(error)) throw error;
|
|
1542
1582
|
throw new RunFailure(
|
|
1543
1583
|
"no-local-repository",
|
|
1544
|
-
`${mapped},
|
|
1584
|
+
`${mapped}, ${origin} ${run.projectKey}, is not a git repository.`
|
|
1545
1585
|
);
|
|
1546
1586
|
});
|
|
1587
|
+
if (root !== null && !isWithin(realpathOrSelf(toplevel.trim()), root)) {
|
|
1588
|
+
throw new RunFailure(
|
|
1589
|
+
"no-local-repository",
|
|
1590
|
+
`${mapped}, the path hint for project ${run.projectKey}, is not a git repository under ${root}.`
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1547
1593
|
const repo = toplevel.trim();
|
|
1548
1594
|
const call = (args) => gitStep(args, { cwd: repo, env, signal: options.signal });
|
|
1549
1595
|
const hasOrigin = await git(["remote", "get-url", "origin"], {
|
|
@@ -1672,7 +1718,7 @@ ${tail}` : error.message;
|
|
|
1672
1718
|
}
|
|
1673
1719
|
}
|
|
1674
1720
|
function git(args, options) {
|
|
1675
|
-
return new Promise((
|
|
1721
|
+
return new Promise((resolve3, reject) => {
|
|
1676
1722
|
const child = spawn("git", args, {
|
|
1677
1723
|
cwd: options.cwd,
|
|
1678
1724
|
env: { ...options.env, GIT_TERMINAL_PROMPT: "0" },
|
|
@@ -1687,7 +1733,7 @@ function git(args, options) {
|
|
|
1687
1733
|
});
|
|
1688
1734
|
child.on("error", reject);
|
|
1689
1735
|
child.on("close", (code) => {
|
|
1690
|
-
if (code === 0)
|
|
1736
|
+
if (code === 0) resolve3(stdout);
|
|
1691
1737
|
else reject(new GitError(args, code, stderr));
|
|
1692
1738
|
});
|
|
1693
1739
|
});
|
|
@@ -1710,20 +1756,23 @@ function readRunnerConfig(path = runnerConfigPath()) {
|
|
|
1710
1756
|
return null;
|
|
1711
1757
|
}
|
|
1712
1758
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1713
|
-
const
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
}
|
|
1759
|
+
const raw = parsed;
|
|
1760
|
+
let profiles;
|
|
1761
|
+
if (Array.isArray(raw.profiles)) {
|
|
1762
|
+
profiles = raw.profiles.flatMap((entry) => {
|
|
1763
|
+
const profile = readProfile(entry);
|
|
1764
|
+
return profile ? [profile] : [];
|
|
1765
|
+
});
|
|
1766
|
+
} else {
|
|
1767
|
+
const legacy = readProfile(raw);
|
|
1768
|
+
if (!legacy) return null;
|
|
1769
|
+
profiles = [legacy];
|
|
1720
1770
|
}
|
|
1721
|
-
const rawLimits = typeof attachments === "object" && attachments !== null ? attachments : {};
|
|
1771
|
+
const rawLimits = typeof raw.attachments === "object" && raw.attachments !== null ? raw.attachments : {};
|
|
1722
1772
|
return {
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
workspaces: mapped,
|
|
1773
|
+
machineId: typeof raw.machineId === "string" && UuidPattern.test(raw.machineId) ? raw.machineId : randomUUID(),
|
|
1774
|
+
...typeof raw.name === "string" ? { name: raw.name } : {},
|
|
1775
|
+
profiles,
|
|
1727
1776
|
attachments: {
|
|
1728
1777
|
maxCount: validLimit(rawLimits.maxCount, DefaultAttachmentMaxCount),
|
|
1729
1778
|
maxBytes: validLimit(rawLimits.maxBytes, DefaultAttachmentMaxBytes)
|
|
@@ -1732,10 +1781,56 @@ function readRunnerConfig(path = runnerConfigPath()) {
|
|
|
1732
1781
|
}
|
|
1733
1782
|
function writeRunnerConfig(config, path = runnerConfigPath()) {
|
|
1734
1783
|
mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
|
|
1735
|
-
|
|
1784
|
+
const document = {
|
|
1785
|
+
machineId: config.machineId,
|
|
1786
|
+
...config.name ? { name: config.name } : {},
|
|
1787
|
+
attachments: config.attachments,
|
|
1788
|
+
profiles: config.profiles.map((profile) => ({
|
|
1789
|
+
...profile.organization ? { organization: profile.organization } : {},
|
|
1790
|
+
url: profile.url,
|
|
1791
|
+
token: profile.token,
|
|
1792
|
+
workspaces: profile.workspaces,
|
|
1793
|
+
repoRoots: profile.repoRoots
|
|
1794
|
+
}))
|
|
1795
|
+
};
|
|
1796
|
+
writeFileSync3(path, `${JSON.stringify(document, null, 2)}
|
|
1736
1797
|
`, { mode: 384 });
|
|
1737
1798
|
chmodSync3(path, 384);
|
|
1738
1799
|
}
|
|
1800
|
+
function profileLabel(profile) {
|
|
1801
|
+
if (profile.organization) return profile.organization;
|
|
1802
|
+
try {
|
|
1803
|
+
return new URL(profile.url).host;
|
|
1804
|
+
} catch {
|
|
1805
|
+
return profile.url;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
function profileKey(profile) {
|
|
1809
|
+
return createHash("sha256").update(profile.token).digest("hex").slice(0, 16);
|
|
1810
|
+
}
|
|
1811
|
+
function sameInstance(left, right) {
|
|
1812
|
+
return left.replace(/\/+$/, "").toLowerCase() === right.replace(/\/+$/, "").toLowerCase();
|
|
1813
|
+
}
|
|
1814
|
+
var UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1815
|
+
function readProfile(value) {
|
|
1816
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1817
|
+
const { url, token, organization, workspaces, repoRoots } = value;
|
|
1818
|
+
if (typeof url !== "string" || typeof token !== "string") return null;
|
|
1819
|
+
const mapped = {};
|
|
1820
|
+
if (typeof workspaces === "object" && workspaces !== null) {
|
|
1821
|
+
for (const [key, path] of Object.entries(workspaces)) {
|
|
1822
|
+
if (typeof path === "string") mapped[key] = path;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
const roots = Array.isArray(repoRoots) ? repoRoots.filter((root) => typeof root === "string" && root.trim() !== "") : [];
|
|
1826
|
+
return {
|
|
1827
|
+
url,
|
|
1828
|
+
token,
|
|
1829
|
+
...typeof organization === "string" && organization !== "" ? { organization } : {},
|
|
1830
|
+
workspaces: mapped,
|
|
1831
|
+
repoRoots: roots
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1739
1834
|
function validLimit(value, fallback) {
|
|
1740
1835
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
|
|
1741
1836
|
}
|
|
@@ -1872,7 +1967,7 @@ function splitByBytes(text, maxBytes) {
|
|
|
1872
1967
|
return pieces;
|
|
1873
1968
|
}
|
|
1874
1969
|
function delay(ms) {
|
|
1875
|
-
return new Promise((
|
|
1970
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
1876
1971
|
}
|
|
1877
1972
|
|
|
1878
1973
|
// src/runner/process.ts
|
|
@@ -1885,12 +1980,12 @@ function spawnSupervised(options) {
|
|
|
1885
1980
|
detached: process.platform !== "win32",
|
|
1886
1981
|
stdio: ["pipe", "pipe", "pipe"]
|
|
1887
1982
|
});
|
|
1888
|
-
const exited = new Promise((
|
|
1983
|
+
const exited = new Promise((resolve3) => {
|
|
1889
1984
|
let settled = false;
|
|
1890
1985
|
const settle = (code) => {
|
|
1891
1986
|
if (settled) return;
|
|
1892
1987
|
settled = true;
|
|
1893
|
-
|
|
1988
|
+
resolve3(code);
|
|
1894
1989
|
};
|
|
1895
1990
|
child.on("error", (error) => {
|
|
1896
1991
|
options.onLine("stderr", `Could not start ${options.command}: ${error.message}`);
|
|
@@ -2077,7 +2172,7 @@ async function executeRun(run, options) {
|
|
|
2077
2172
|
mcpServer: options.workspace.mcpServer
|
|
2078
2173
|
});
|
|
2079
2174
|
const env = {
|
|
2080
|
-
...process.env,
|
|
2175
|
+
...inheritedEnv(process.env),
|
|
2081
2176
|
...invocation.env,
|
|
2082
2177
|
...workspace.env,
|
|
2083
2178
|
// The agent reaches the instance as itself, never as the runner: `aictiq mcp` and the
|
|
@@ -2210,7 +2305,7 @@ async function sendFinish(client, run, report, local) {
|
|
|
2210
2305
|
return true;
|
|
2211
2306
|
} catch (error) {
|
|
2212
2307
|
if (error instanceof RunnerNetworkError && attempt < 30) {
|
|
2213
|
-
await new Promise((
|
|
2308
|
+
await new Promise((resolve3) => setTimeout(resolve3, Math.min(3e4, 1e3 * 2 ** attempt)));
|
|
2214
2309
|
continue;
|
|
2215
2310
|
}
|
|
2216
2311
|
if (error instanceof RunnerHttpError && error.revoked) throw error;
|
|
@@ -2220,14 +2315,14 @@ async function sendFinish(client, run, report, local) {
|
|
|
2220
2315
|
}
|
|
2221
2316
|
}
|
|
2222
2317
|
function findPullRequest(checkout, branch, env) {
|
|
2223
|
-
return new Promise((
|
|
2318
|
+
return new Promise((resolve3) => {
|
|
2224
2319
|
execFile(
|
|
2225
2320
|
"gh",
|
|
2226
2321
|
["pr", "view", branch, "--json", "url", "--jq", ".url"],
|
|
2227
2322
|
{ cwd: checkout, env, timeout: 15e3 },
|
|
2228
2323
|
(error, stdout) => {
|
|
2229
2324
|
const url = error ? "" : stdout.trim();
|
|
2230
|
-
|
|
2325
|
+
resolve3(/^https:\/\/\S+\/pull\/\d+$/.test(url) ? url : null);
|
|
2231
2326
|
}
|
|
2232
2327
|
);
|
|
2233
2328
|
});
|
|
@@ -2236,6 +2331,9 @@ function truncate(value, max) {
|
|
|
2236
2331
|
if (!value) return null;
|
|
2237
2332
|
return value.length <= max ? value : `${value.slice(0, max - 1)}\u2026`;
|
|
2238
2333
|
}
|
|
2334
|
+
function inheritedEnv(env) {
|
|
2335
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => !key.toUpperCase().startsWith("AICTIQ_")));
|
|
2336
|
+
}
|
|
2239
2337
|
function message2(error) {
|
|
2240
2338
|
return error instanceof Error ? error.message : String(error);
|
|
2241
2339
|
}
|
|
@@ -2245,12 +2343,12 @@ import { execFile as execFile2 } from "child_process";
|
|
|
2245
2343
|
var limit2 = (value, length) => value.length > length ? `${value.slice(0, Math.max(0, length - 1))}\u2026` : value;
|
|
2246
2344
|
var recordOf = (value) => typeof value === "object" && value !== null ? value : null;
|
|
2247
2345
|
function versionOf(command, env = process.env) {
|
|
2248
|
-
return new Promise((
|
|
2346
|
+
return new Promise((resolve3) => {
|
|
2249
2347
|
execFile2(command, ["--version"], { env, timeout: 5e3 }, (error, stdout, stderr) => {
|
|
2250
|
-
if (error) return
|
|
2348
|
+
if (error) return resolve3(null);
|
|
2251
2349
|
const output = String(stdout || stderr).trim();
|
|
2252
2350
|
const firstLine = output.split(/\r?\n/)[0]?.slice(0, 100) ?? "";
|
|
2253
|
-
|
|
2351
|
+
resolve3(firstLine || null);
|
|
2254
2352
|
});
|
|
2255
2353
|
});
|
|
2256
2354
|
}
|
|
@@ -2571,6 +2669,171 @@ async function probeHarnesses(env) {
|
|
|
2571
2669
|
return found.filter((info) => info !== null);
|
|
2572
2670
|
}
|
|
2573
2671
|
|
|
2672
|
+
// src/runner/service.ts
|
|
2673
|
+
import { posix } from "path";
|
|
2674
|
+
var RevokedExitCode = 5;
|
|
2675
|
+
function servicePlatform(platform2 = process.platform) {
|
|
2676
|
+
if (platform2 === "darwin") return "macos";
|
|
2677
|
+
if (platform2 === "win32") return "windows";
|
|
2678
|
+
return "linux";
|
|
2679
|
+
}
|
|
2680
|
+
function serviceDefinition(platform2, options) {
|
|
2681
|
+
switch (platform2) {
|
|
2682
|
+
case "macos":
|
|
2683
|
+
return launchdAgent(options);
|
|
2684
|
+
case "windows":
|
|
2685
|
+
return scheduledTaskInstaller(options);
|
|
2686
|
+
default:
|
|
2687
|
+
return systemdUnit(options);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
function systemdUnit({ node, entry, parallel, path }) {
|
|
2691
|
+
return `# Save as ~/.config/systemd/user/aictiq-runner.service, then:
|
|
2692
|
+
# systemctl --user daemon-reload
|
|
2693
|
+
# systemctl --user enable --now aictiq-runner
|
|
2694
|
+
# loginctl enable-linger "$USER" # keep it running after you log out
|
|
2695
|
+
#
|
|
2696
|
+
# The runner executes agents as this user, with this user's harness sign-ins and git
|
|
2697
|
+
# credentials: one runner is one trust domain. SIGTERM lets runs in flight finish;
|
|
2698
|
+
# TimeoutStopSec bounds how long systemd waits before it cancels them.
|
|
2699
|
+
[Unit]
|
|
2700
|
+
Description=Aictiq runner
|
|
2701
|
+
After=network-online.target
|
|
2702
|
+
Wants=network-online.target
|
|
2703
|
+
|
|
2704
|
+
[Service]
|
|
2705
|
+
Type=simple
|
|
2706
|
+
ExecStart=${node} ${entry} runner start --parallel ${parallel}
|
|
2707
|
+
Restart=on-failure
|
|
2708
|
+
RestartSec=10
|
|
2709
|
+
# Exit ${RevokedExitCode} means the secret was revoked; restarting cannot fix that.
|
|
2710
|
+
RestartPreventExitStatus=${RevokedExitCode}
|
|
2711
|
+
KillMode=mixed
|
|
2712
|
+
TimeoutStopSec=15min
|
|
2713
|
+
Environment=PATH=${path}
|
|
2714
|
+
|
|
2715
|
+
[Install]
|
|
2716
|
+
WantedBy=default.target
|
|
2717
|
+
`;
|
|
2718
|
+
}
|
|
2719
|
+
var LaunchdLabel = "com.aictiq.runner";
|
|
2720
|
+
var LaunchdWrapper = `"$1" "$2" runner start --parallel "$3" &
|
|
2721
|
+
child=$!
|
|
2722
|
+
trap 'kill -TERM "$child" 2>/dev/null' TERM INT
|
|
2723
|
+
wait "$child"
|
|
2724
|
+
status=$?
|
|
2725
|
+
while kill -0 "$child" 2>/dev/null; do wait "$child"; status=$?; done
|
|
2726
|
+
if [ "$status" -eq ${RevokedExitCode} ]; then exit 0; fi
|
|
2727
|
+
exit "$status"`;
|
|
2728
|
+
function launchdAgent({ node, entry, parallel, path, home }) {
|
|
2729
|
+
const log = posix.join(home, "Library", "Logs", "aictiq-runner.log");
|
|
2730
|
+
const plistPath = `~/Library/LaunchAgents/${LaunchdLabel}.plist`;
|
|
2731
|
+
const string = (value) => `<string>${xml(value)}</string>`;
|
|
2732
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2733
|
+
<!--
|
|
2734
|
+
Save as ${plistPath}, then:
|
|
2735
|
+
launchctl bootstrap gui/$(id -u) ${plistPath}
|
|
2736
|
+
Check it: launchctl print gui/$(id -u)/${LaunchdLabel}
|
|
2737
|
+
Log: tail -f ${xmlComment(log)}
|
|
2738
|
+
Remove it: launchctl bootout gui/$(id -u)/${LaunchdLabel}
|
|
2739
|
+
|
|
2740
|
+
The runner executes agents as this user, with this user's harness sign-ins and git
|
|
2741
|
+
credentials: one runner is one trust domain. It runs while this user is logged in.
|
|
2742
|
+
A stop lets runs in flight finish for up to 15 minutes before launchd kills them.
|
|
2743
|
+
-->
|
|
2744
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2745
|
+
<plist version="1.0">
|
|
2746
|
+
<dict>
|
|
2747
|
+
<key>Label</key>
|
|
2748
|
+
${string(LaunchdLabel)}
|
|
2749
|
+
<key>ProgramArguments</key>
|
|
2750
|
+
<array>
|
|
2751
|
+
${string("/bin/sh")}
|
|
2752
|
+
${string("-c")}
|
|
2753
|
+
${string(LaunchdWrapper)}
|
|
2754
|
+
${string("aictiq-runner")}
|
|
2755
|
+
${string(node)}
|
|
2756
|
+
${string(entry)}
|
|
2757
|
+
${string(parallel)}
|
|
2758
|
+
</array>
|
|
2759
|
+
<key>EnvironmentVariables</key>
|
|
2760
|
+
<dict>
|
|
2761
|
+
<key>PATH</key>
|
|
2762
|
+
${string(path)}
|
|
2763
|
+
</dict>
|
|
2764
|
+
<key>RunAtLoad</key>
|
|
2765
|
+
<true/>
|
|
2766
|
+
<key>KeepAlive</key>
|
|
2767
|
+
<dict>
|
|
2768
|
+
<key>SuccessfulExit</key>
|
|
2769
|
+
<false/>
|
|
2770
|
+
</dict>
|
|
2771
|
+
<key>ThrottleInterval</key>
|
|
2772
|
+
<integer>10</integer>
|
|
2773
|
+
<key>ExitTimeOut</key>
|
|
2774
|
+
<integer>900</integer>
|
|
2775
|
+
<key>ProcessType</key>
|
|
2776
|
+
${string("Background")}
|
|
2777
|
+
<key>StandardOutPath</key>
|
|
2778
|
+
${string(log)}
|
|
2779
|
+
<key>StandardErrorPath</key>
|
|
2780
|
+
${string(log)}
|
|
2781
|
+
</dict>
|
|
2782
|
+
</plist>
|
|
2783
|
+
`;
|
|
2784
|
+
}
|
|
2785
|
+
var ScheduledTaskName = "Aictiq runner";
|
|
2786
|
+
function scheduledTaskInstaller({ node, entry, parallel, path }) {
|
|
2787
|
+
const loop = `# Written by \`aictiq runner install-service\`; the "${ScheduledTaskName}" task runs it at logon.
|
|
2788
|
+
$ErrorActionPreference = 'Continue'
|
|
2789
|
+
$env:PATH = ${ps(path)}
|
|
2790
|
+
$log = Join-Path $env:LOCALAPPDATA 'aictiq\\runner.log'
|
|
2791
|
+
while ($true) {
|
|
2792
|
+
# "$_" turns stderr's error records back into the plain lines the runner wrote.
|
|
2793
|
+
& ${ps(node)} ${ps(entry)} runner start --parallel ${ps(parallel)} 2>&1 |
|
|
2794
|
+
ForEach-Object { "$_" } | Add-Content -Path $log -Encoding utf8
|
|
2795
|
+
if ($LASTEXITCODE -eq ${RevokedExitCode}) {
|
|
2796
|
+
Add-Content -Path $log -Encoding utf8 -Value 'The runner secret was revoked; not restarting.'
|
|
2797
|
+
break
|
|
2798
|
+
}
|
|
2799
|
+
Start-Sleep -Seconds 10
|
|
2800
|
+
}
|
|
2801
|
+
`;
|
|
2802
|
+
return `# Aictiq runner as a Windows scheduled task. Save as install-runner.ps1, then run:
|
|
2803
|
+
# powershell -NoProfile -ExecutionPolicy Bypass -File install-runner.ps1
|
|
2804
|
+
# Check it: Get-ScheduledTask -TaskName '${ScheduledTaskName}' | Get-ScheduledTaskInfo
|
|
2805
|
+
# Log: Get-Content -Wait "$env:LOCALAPPDATA\\aictiq\\runner.log"
|
|
2806
|
+
# Remove it: Unregister-ScheduledTask -TaskName '${ScheduledTaskName}' -Confirm:$false
|
|
2807
|
+
#
|
|
2808
|
+
# The task starts at logon and runs while this user is logged in, as this user, with this
|
|
2809
|
+
# user's harness sign-ins and git credentials: one runner is one trust domain. Stopping the
|
|
2810
|
+
# task ends the runner at once; runs in flight are cancelled, not finished.
|
|
2811
|
+
$ErrorActionPreference = 'Stop'
|
|
2812
|
+
$dir = Join-Path $env:LOCALAPPDATA 'aictiq'
|
|
2813
|
+
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
2814
|
+
$script = Join-Path $dir 'runner-service.ps1'
|
|
2815
|
+
Set-Content -Path $script -Encoding utf8 -Value @'
|
|
2816
|
+
${loop}'@
|
|
2817
|
+
|
|
2818
|
+
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \`"$script\`""
|
|
2819
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\\$env:USERNAME"
|
|
2820
|
+
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\\$env:USERNAME" -LogonType Interactive -RunLevel Limited
|
|
2821
|
+
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew
|
|
2822
|
+
Register-ScheduledTask -TaskName '${ScheduledTaskName}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
2823
|
+
Start-ScheduledTask -TaskName '${ScheduledTaskName}'
|
|
2824
|
+
Write-Host "Installed and started '${ScheduledTaskName}'. Log: $(Join-Path $dir 'runner.log')"
|
|
2825
|
+
`;
|
|
2826
|
+
}
|
|
2827
|
+
function xml(value) {
|
|
2828
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2829
|
+
}
|
|
2830
|
+
function xmlComment(value) {
|
|
2831
|
+
return value.replace(/--/g, "- -");
|
|
2832
|
+
}
|
|
2833
|
+
function ps(value) {
|
|
2834
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2574
2837
|
// src/runner/loop.ts
|
|
2575
2838
|
var KnownHarnesses = ["claude", "codex", "opencode"];
|
|
2576
2839
|
var RunnerRevokedError = class extends Error {
|
|
@@ -2608,6 +2871,7 @@ var RunnerLoop = class {
|
|
|
2608
2871
|
let capabilities = await this.options.probe();
|
|
2609
2872
|
const hello = await this.retrying(() => client.hello(capabilities));
|
|
2610
2873
|
if (!hello) return;
|
|
2874
|
+
this.options.onHello?.(hello);
|
|
2611
2875
|
local(
|
|
2612
2876
|
`Runner "${hello.name}" connected to ${client.baseUrl} (${hello.organizationSlug}); harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none detected"}`
|
|
2613
2877
|
);
|
|
@@ -2622,9 +2886,15 @@ var RunnerLoop = class {
|
|
|
2622
2886
|
}
|
|
2623
2887
|
})();
|
|
2624
2888
|
}, hello.heartbeatIntervalSeconds * 1e3);
|
|
2889
|
+
const { floor } = this.options;
|
|
2890
|
+
const floorKey = this.options.floorKey ?? client.baseUrl;
|
|
2625
2891
|
try {
|
|
2626
2892
|
let attempt = 0;
|
|
2627
2893
|
while (!this.stopping) {
|
|
2894
|
+
if (floor) {
|
|
2895
|
+
await floor.turn(floorKey, this.claimAbort.signal);
|
|
2896
|
+
if (this.stopping) break;
|
|
2897
|
+
}
|
|
2628
2898
|
const free = this.options.parallel - this.active.size;
|
|
2629
2899
|
if (free <= 0) {
|
|
2630
2900
|
await this.waitForSlot();
|
|
@@ -2651,6 +2921,8 @@ var RunnerLoop = class {
|
|
|
2651
2921
|
continue;
|
|
2652
2922
|
}
|
|
2653
2923
|
if (!claimed) continue;
|
|
2924
|
+
if (floor && !floor.enter(floorKey) && !await this.handBack(claimed, floor, floorKey))
|
|
2925
|
+
continue;
|
|
2654
2926
|
local(`Claimed ${claimed.itemKey} (run ${claimed.runId}, ${claimed.harness})`);
|
|
2655
2927
|
const execution = this.options.execute(claimed, hello, this.shutdown.signal).then(() => void 0).catch((error) => {
|
|
2656
2928
|
if (error instanceof RunnerHttpError && error.revoked) this.revoke(error);
|
|
@@ -2659,6 +2931,7 @@ var RunnerLoop = class {
|
|
|
2659
2931
|
`Run ${claimed.runId} ended with an error: ${error instanceof Error ? error.message : String(error)}`
|
|
2660
2932
|
);
|
|
2661
2933
|
}).finally(() => {
|
|
2934
|
+
floor?.leave(floorKey);
|
|
2662
2935
|
this.active.delete(execution);
|
|
2663
2936
|
this.wake?.();
|
|
2664
2937
|
});
|
|
@@ -2671,6 +2944,32 @@ var RunnerLoop = class {
|
|
|
2671
2944
|
}
|
|
2672
2945
|
if (this.revoked) throw this.revoked;
|
|
2673
2946
|
}
|
|
2947
|
+
/**
|
|
2948
|
+
* A run claimed while another organization took the floor. It goes back to the queue;
|
|
2949
|
+
* true only when it could not be given back and has now waited for the floor instead,
|
|
2950
|
+
* so the caller executes it.
|
|
2951
|
+
*/
|
|
2952
|
+
async handBack(run, floor, key) {
|
|
2953
|
+
const { client, local } = this.options;
|
|
2954
|
+
try {
|
|
2955
|
+
await client.release(run.runId);
|
|
2956
|
+
local(
|
|
2957
|
+
`Gave ${run.itemKey} back to the queue: another organization has runs executing on this machine`
|
|
2958
|
+
);
|
|
2959
|
+
return false;
|
|
2960
|
+
} catch (error) {
|
|
2961
|
+
if (error instanceof RunnerHttpError && error.revoked) {
|
|
2962
|
+
this.revoke(error);
|
|
2963
|
+
return false;
|
|
2964
|
+
}
|
|
2965
|
+
if (error instanceof RunnerHttpError && error.problem?.type) return false;
|
|
2966
|
+
local(
|
|
2967
|
+
`Could not give ${run.itemKey} back (${error instanceof Error ? error.message : String(error)}); it starts when the other organization's runs finish`
|
|
2968
|
+
);
|
|
2969
|
+
while (!floor.enter(key)) await floor.turn(key);
|
|
2970
|
+
return true;
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2674
2973
|
revoke(error) {
|
|
2675
2974
|
this.revoked ??= new RunnerRevokedError(
|
|
2676
2975
|
error.problem?.type?.endsWith("token-revoked") ? "This runner was disabled, deleted or rotated. Register it again with `aictiq runner register`." : `The instance refused the runner secret (${error.message}). Register it again with \`aictiq runner register\`.`
|
|
@@ -2696,31 +2995,227 @@ var RunnerLoop = class {
|
|
|
2696
2995
|
return null;
|
|
2697
2996
|
}
|
|
2698
2997
|
waitForSlot() {
|
|
2699
|
-
return new Promise((
|
|
2998
|
+
return new Promise((resolve3) => {
|
|
2700
2999
|
this.wake = () => {
|
|
2701
3000
|
this.wake = void 0;
|
|
2702
|
-
|
|
3001
|
+
resolve3();
|
|
2703
3002
|
};
|
|
2704
3003
|
});
|
|
2705
3004
|
}
|
|
2706
3005
|
sleep(ms) {
|
|
2707
|
-
return new Promise((
|
|
3006
|
+
return new Promise((resolve3) => {
|
|
2708
3007
|
const timer = setTimeout(done, ms);
|
|
2709
3008
|
this.wake = done;
|
|
2710
3009
|
function done() {
|
|
2711
3010
|
clearTimeout(timer);
|
|
2712
|
-
|
|
3011
|
+
resolve3();
|
|
2713
3012
|
}
|
|
2714
3013
|
});
|
|
2715
3014
|
}
|
|
2716
3015
|
};
|
|
2717
3016
|
|
|
3017
|
+
// src/runner/floor.ts
|
|
3018
|
+
var Floor = class {
|
|
3019
|
+
constructor(handoffMs = 2e3) {
|
|
3020
|
+
this.handoffMs = handoffMs;
|
|
3021
|
+
}
|
|
3022
|
+
handoffMs;
|
|
3023
|
+
holder = null;
|
|
3024
|
+
count = 0;
|
|
3025
|
+
last = null;
|
|
3026
|
+
freedAt = 0;
|
|
3027
|
+
waiting = /* @__PURE__ */ new Map();
|
|
3028
|
+
changed = deferred();
|
|
3029
|
+
/** The organization with runs executing, or null when the floor is free. */
|
|
3030
|
+
get current() {
|
|
3031
|
+
return this.holder;
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* Resolves when `key` may claim: the floor is free, or its own runs hold it. Also resolves
|
|
3035
|
+
* when `signal` aborts; the caller checks why.
|
|
3036
|
+
*/
|
|
3037
|
+
async turn(key, signal2) {
|
|
3038
|
+
this.waiting.set(key, (this.waiting.get(key) ?? 0) + 1);
|
|
3039
|
+
try {
|
|
3040
|
+
while (!signal2?.aborted) {
|
|
3041
|
+
const yieldFor = this.yieldFor(key);
|
|
3042
|
+
if (this.count > 0 ? this.holder === key : yieldFor === 0) return;
|
|
3043
|
+
const waits = [this.changed.promise];
|
|
3044
|
+
let timer;
|
|
3045
|
+
if (yieldFor > 0)
|
|
3046
|
+
waits.push(new Promise((resolve3) => timer = setTimeout(resolve3, yieldFor)));
|
|
3047
|
+
let onAbort;
|
|
3048
|
+
if (signal2) {
|
|
3049
|
+
waits.push(
|
|
3050
|
+
new Promise((resolve3) => {
|
|
3051
|
+
onAbort = () => resolve3();
|
|
3052
|
+
signal2.addEventListener("abort", onAbort, { once: true });
|
|
3053
|
+
})
|
|
3054
|
+
);
|
|
3055
|
+
}
|
|
3056
|
+
await Promise.race(waits);
|
|
3057
|
+
clearTimeout(timer);
|
|
3058
|
+
if (onAbort) signal2?.removeEventListener("abort", onAbort);
|
|
3059
|
+
}
|
|
3060
|
+
} finally {
|
|
3061
|
+
const left = (this.waiting.get(key) ?? 1) - 1;
|
|
3062
|
+
if (left === 0) this.waiting.delete(key);
|
|
3063
|
+
else this.waiting.set(key, left);
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
/** Takes the floor for one run of `key`; false when another organization's runs hold it. */
|
|
3067
|
+
enter(key) {
|
|
3068
|
+
if (this.count > 0 && this.holder !== key) return false;
|
|
3069
|
+
this.holder = key;
|
|
3070
|
+
this.count++;
|
|
3071
|
+
this.notify();
|
|
3072
|
+
return true;
|
|
3073
|
+
}
|
|
3074
|
+
leave(key) {
|
|
3075
|
+
if (this.holder !== key || this.count === 0) return;
|
|
3076
|
+
this.count--;
|
|
3077
|
+
if (this.count === 0) {
|
|
3078
|
+
this.holder = null;
|
|
3079
|
+
this.last = key;
|
|
3080
|
+
this.freedAt = Date.now();
|
|
3081
|
+
}
|
|
3082
|
+
this.notify();
|
|
3083
|
+
}
|
|
3084
|
+
/** How long `key` stands back from a free floor so a waiting organization goes first. */
|
|
3085
|
+
yieldFor(key) {
|
|
3086
|
+
if (this.count > 0 || key !== this.last) return 0;
|
|
3087
|
+
const othersWaiting = [...this.waiting.keys()].some((other) => other !== key);
|
|
3088
|
+
if (!othersWaiting) return 0;
|
|
3089
|
+
return Math.max(0, this.freedAt + this.handoffMs - Date.now());
|
|
3090
|
+
}
|
|
3091
|
+
notify() {
|
|
3092
|
+
const previous = this.changed;
|
|
3093
|
+
this.changed = deferred();
|
|
3094
|
+
previous.resolve();
|
|
3095
|
+
}
|
|
3096
|
+
};
|
|
3097
|
+
function deferred() {
|
|
3098
|
+
let resolve3;
|
|
3099
|
+
const promise = new Promise((done) => resolve3 = done);
|
|
3100
|
+
return { promise, resolve: resolve3 };
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
// src/runner/supervisor.ts
|
|
3104
|
+
var RunnerSupervisor = class {
|
|
3105
|
+
options;
|
|
3106
|
+
floor;
|
|
3107
|
+
running = /* @__PURE__ */ new Map();
|
|
3108
|
+
/** Secrets that failed: revoked ones for good, others until the time given. */
|
|
3109
|
+
failed = /* @__PURE__ */ new Map();
|
|
3110
|
+
stopping = false;
|
|
3111
|
+
settle;
|
|
3112
|
+
constructor(options) {
|
|
3113
|
+
this.options = options;
|
|
3114
|
+
this.floor = options.floor ?? new Floor();
|
|
3115
|
+
}
|
|
3116
|
+
get inFlight() {
|
|
3117
|
+
let total = 0;
|
|
3118
|
+
for (const { loop } of this.running.values()) total += loop.inFlight;
|
|
3119
|
+
return total;
|
|
3120
|
+
}
|
|
3121
|
+
stop() {
|
|
3122
|
+
this.stopping = true;
|
|
3123
|
+
for (const { loop } of this.running.values()) loop.stop();
|
|
3124
|
+
this.settle?.();
|
|
3125
|
+
}
|
|
3126
|
+
abort() {
|
|
3127
|
+
this.stopping = true;
|
|
3128
|
+
for (const { loop } of this.running.values()) loop.abort();
|
|
3129
|
+
this.settle?.();
|
|
3130
|
+
}
|
|
3131
|
+
async run() {
|
|
3132
|
+
const reload = setInterval(() => this.sync(), this.options.reloadMs ?? 5e3);
|
|
3133
|
+
try {
|
|
3134
|
+
this.sync();
|
|
3135
|
+
while (!this.stopping) {
|
|
3136
|
+
const revoked = this.allRevoked();
|
|
3137
|
+
if (revoked) throw revoked;
|
|
3138
|
+
await new Promise((resolve3) => this.settle = resolve3);
|
|
3139
|
+
}
|
|
3140
|
+
await Promise.all([...this.running.values()].map(({ done }) => done));
|
|
3141
|
+
} finally {
|
|
3142
|
+
clearInterval(reload);
|
|
3143
|
+
this.settle = void 0;
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
/** Every configured profile is revoked and nothing runs: the error to exit with, else null. */
|
|
3147
|
+
allRevoked() {
|
|
3148
|
+
if (this.running.size > 0) return null;
|
|
3149
|
+
const profiles = this.options.readConfig()?.profiles ?? [];
|
|
3150
|
+
if (profiles.length === 0) return null;
|
|
3151
|
+
let last = null;
|
|
3152
|
+
for (const profile of profiles) {
|
|
3153
|
+
const failure = this.failed.get(profile.token);
|
|
3154
|
+
if (!failure?.revoked) return null;
|
|
3155
|
+
last = failure.revoked;
|
|
3156
|
+
}
|
|
3157
|
+
return profiles.length === 1 || !last ? last : new RunnerRevokedError(
|
|
3158
|
+
"Every organization this runner served disabled, deleted or rotated its runner. Register it again with `aictiq runner register`."
|
|
3159
|
+
);
|
|
3160
|
+
}
|
|
3161
|
+
sync() {
|
|
3162
|
+
if (this.stopping) return;
|
|
3163
|
+
const config = this.options.readConfig();
|
|
3164
|
+
const profiles = config?.profiles ?? [];
|
|
3165
|
+
const wanted = new Map(profiles.map((profile) => [profile.token, profile]));
|
|
3166
|
+
for (const [token, running] of this.running) {
|
|
3167
|
+
if (wanted.has(token) || running.retired) continue;
|
|
3168
|
+
running.retired = true;
|
|
3169
|
+
this.options.local(
|
|
3170
|
+
`[${profileLabel(running.profile)}] Removed from runner.json; stopping after its runs finish`
|
|
3171
|
+
);
|
|
3172
|
+
running.loop.stop();
|
|
3173
|
+
}
|
|
3174
|
+
for (const profile of wanted.values()) {
|
|
3175
|
+
if (this.running.has(profile.token)) continue;
|
|
3176
|
+
const failure = this.failed.get(profile.token);
|
|
3177
|
+
if (failure && (failure.revoked || Date.now() < failure.retryAt)) continue;
|
|
3178
|
+
this.failed.delete(profile.token);
|
|
3179
|
+
this.start(profile);
|
|
3180
|
+
}
|
|
3181
|
+
this.settle?.();
|
|
3182
|
+
}
|
|
3183
|
+
start(profile) {
|
|
3184
|
+
const label = () => profileLabel(profile);
|
|
3185
|
+
const loop = this.options.createLoop(profile, this.floor, (hello) => {
|
|
3186
|
+
if (profile.organization !== hello.organizationSlug) {
|
|
3187
|
+
profile.organization = hello.organizationSlug;
|
|
3188
|
+
this.options.learned?.(profile, hello);
|
|
3189
|
+
}
|
|
3190
|
+
});
|
|
3191
|
+
const running = { profile, loop, done: Promise.resolve(), retired: false };
|
|
3192
|
+
running.done = loop.run().catch((error) => {
|
|
3193
|
+
if (error instanceof RunnerRevokedError) {
|
|
3194
|
+
this.options.local(`[${label()}] ${error.message}`);
|
|
3195
|
+
this.failed.set(profile.token, { revoked: error, retryAt: Number.POSITIVE_INFINITY });
|
|
3196
|
+
return;
|
|
3197
|
+
}
|
|
3198
|
+
const retryMs = this.options.retryMs ?? 6e4;
|
|
3199
|
+
this.options.local(
|
|
3200
|
+
`[${label()}] Stopped: ${error instanceof Error ? error.message : String(error)}; trying again in ${Math.round(retryMs / 1e3)}s`
|
|
3201
|
+
);
|
|
3202
|
+
this.failed.set(profile.token, { revoked: null, retryAt: Date.now() + retryMs });
|
|
3203
|
+
}).finally(() => {
|
|
3204
|
+
this.running.delete(profile.token);
|
|
3205
|
+
this.settle?.();
|
|
3206
|
+
});
|
|
3207
|
+
this.running.set(profile.token, running);
|
|
3208
|
+
}
|
|
3209
|
+
};
|
|
3210
|
+
|
|
2718
3211
|
// src/commands/runner.ts
|
|
2719
3212
|
function runnerCommand(globals) {
|
|
2720
3213
|
const runner = new Command7("runner").description(
|
|
2721
3214
|
"Run agent work dispatched from a Aictiq instance on this machine"
|
|
2722
3215
|
);
|
|
2723
|
-
runner.command("register").description(
|
|
3216
|
+
runner.command("register").description(
|
|
3217
|
+
"Store a runner secret (jrn_\u2026) from Factory \u2192 Runners and check it. A secret from another organization adds it to this machine"
|
|
3218
|
+
).option("--url <url>", "Base URL of the Aictiq instance").option("--token <token>", "Runner secret; read from stdin when omitted").option("--name <name>", "A local label for this machine (each instance keeps its own name)").action(async (options) => {
|
|
2724
3219
|
const global = globals();
|
|
2725
3220
|
const rawUrl = options.url ?? global.url;
|
|
2726
3221
|
if (!rawUrl) {
|
|
@@ -2737,73 +3232,150 @@ function runnerCommand(globals) {
|
|
|
2737
3232
|
);
|
|
2738
3233
|
}
|
|
2739
3234
|
const url = rawUrl.replace(/\/+$/, "");
|
|
2740
|
-
const
|
|
3235
|
+
const existing = readRunnerConfig();
|
|
3236
|
+
const machineId = existing?.machineId ?? randomUUID2();
|
|
3237
|
+
const capabilities = await probe(1, machineId);
|
|
2741
3238
|
const hello = await new RunnerClient({ baseUrl: url, token }).hello(capabilities).catch((error) => {
|
|
2742
3239
|
throw asCliError(error);
|
|
2743
3240
|
});
|
|
2744
|
-
const
|
|
2745
|
-
const
|
|
3241
|
+
const profiles = existing?.profiles ?? [];
|
|
3242
|
+
const index = await matchingProfile(profiles, url, token, hello.organizationSlug, capabilities);
|
|
3243
|
+
const previous = index >= 0 ? profiles[index] : void 0;
|
|
3244
|
+
const profile = {
|
|
2746
3245
|
url,
|
|
2747
3246
|
token,
|
|
2748
|
-
|
|
2749
|
-
// Re-registering (a rotated secret) keeps the repository map
|
|
2750
|
-
|
|
2751
|
-
|
|
3247
|
+
organization: hello.organizationSlug,
|
|
3248
|
+
// Re-registering one organization (a rotated secret) keeps the repository map
|
|
3249
|
+
// someone built up; a new organization starts with its own, empty one.
|
|
3250
|
+
workspaces: previous?.workspaces ?? {},
|
|
3251
|
+
repoRoots: previous?.repoRoots ?? []
|
|
2752
3252
|
};
|
|
2753
|
-
|
|
3253
|
+
const next = index >= 0 ? profiles.map((p, i) => i === index ? profile : p) : [...profiles, profile];
|
|
3254
|
+
const name = options.name ?? existing?.name;
|
|
3255
|
+
writeRunnerConfig({
|
|
3256
|
+
machineId,
|
|
3257
|
+
...name ? { name } : {},
|
|
3258
|
+
profiles: next,
|
|
3259
|
+
attachments: existing?.attachments ?? { maxCount: DefaultAttachmentMaxCount, maxBytes: DefaultAttachmentMaxBytes }
|
|
3260
|
+
});
|
|
2754
3261
|
if (globals().json) {
|
|
2755
|
-
printJson({
|
|
3262
|
+
printJson({
|
|
3263
|
+
url,
|
|
3264
|
+
runner: hello,
|
|
3265
|
+
capabilities,
|
|
3266
|
+
configPath: runnerConfigPath(),
|
|
3267
|
+
organizations: next.map(profileLabel)
|
|
3268
|
+
});
|
|
2756
3269
|
return;
|
|
2757
3270
|
}
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
3271
|
+
const organizations = next.map(profileLabel);
|
|
3272
|
+
const lines = [
|
|
3273
|
+
`Registered runner "${hello.name}" with ${url} (${hello.organizationSlug}).`,
|
|
3274
|
+
`Harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none found on PATH"}`,
|
|
3275
|
+
`Config written to ${runnerConfigPath()}.`
|
|
3276
|
+
];
|
|
3277
|
+
if (next.length > 1) {
|
|
3278
|
+
lines.push(
|
|
3279
|
+
"",
|
|
3280
|
+
`This machine now runs for ${next.length} organizations: ${organizations.join(", ")}.`,
|
|
3281
|
+
"Runs from different organizations never execute at the same time.",
|
|
3282
|
+
`Repository roots and mappings are per organization: \`aictiq runner root <path> --org ${hello.organizationSlug}\`.`,
|
|
3283
|
+
"A running `aictiq runner start` picks this up within a few seconds; otherwise start it."
|
|
3284
|
+
);
|
|
3285
|
+
} else {
|
|
3286
|
+
lines.push("Start it with `aictiq runner start`.");
|
|
3287
|
+
}
|
|
3288
|
+
print(lines.join("\n"));
|
|
2763
3289
|
});
|
|
2764
3290
|
runner.command("map").description(
|
|
2765
3291
|
"Map a project key to a local clone, for projects whose repository source is local"
|
|
2766
|
-
).argument("<projectKey>", "Project key, e.g. ACME").argument("[path]", "Path of the clone (default: the current directory); omit with --remove").option("--remove", "Remove the mapping").action((projectKey, path, options) => {
|
|
3292
|
+
).argument("<projectKey>", "Project key, e.g. ACME").argument("[path]", "Path of the clone (default: the current directory); omit with --remove").option("--remove", "Remove the mapping").option("--org <slug>", "The organization the mapping is for, when this machine runs for several").action((projectKey, path, options) => {
|
|
2767
3293
|
const config = requireConfig();
|
|
3294
|
+
const profile = selectProfile(config, options.org ?? globals().org);
|
|
2768
3295
|
const key = projectKey.toUpperCase();
|
|
2769
3296
|
if (options.remove) {
|
|
2770
|
-
delete
|
|
3297
|
+
delete profile.workspaces[key];
|
|
2771
3298
|
} else {
|
|
2772
|
-
const absolute =
|
|
3299
|
+
const absolute = resolve2(path ?? ".");
|
|
2773
3300
|
if (!existsSync3(absolute))
|
|
2774
3301
|
throw new CliError(`${absolute} does not exist.`, ExitCode.Validation);
|
|
2775
|
-
|
|
3302
|
+
profile.workspaces[key] = absolute;
|
|
2776
3303
|
}
|
|
2777
3304
|
writeRunnerConfig(config);
|
|
3305
|
+
const where = config.profiles.length > 1 ? ` (${profileLabel(profile)})` : "";
|
|
2778
3306
|
print(
|
|
2779
|
-
options.remove ? `Removed the mapping for ${key}.` : `${key} \u2192 ${
|
|
3307
|
+
options.remove ? `Removed the mapping for ${key}${where}.` : `${key} \u2192 ${profile.workspaces[key]}${where}`
|
|
2780
3308
|
);
|
|
2781
3309
|
});
|
|
2782
|
-
runner.command("
|
|
3310
|
+
runner.command("root").description(
|
|
3311
|
+
"Trust the web UI's path hint for local projects whose clone is under this directory"
|
|
3312
|
+
).argument("[path]", "Directory that holds your clones (default: the current directory)").option("--remove", "Stop trusting path hints under this directory").option("--org <slug>", "The organization whose path hints to trust, when this machine runs for several").action((path, options) => {
|
|
2783
3313
|
const config = requireConfig();
|
|
2784
|
-
const
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
} catch (error) {
|
|
2794
|
-
registration = { error: error instanceof Error ? error.message : String(error) };
|
|
2795
|
-
registrationText = `failing - ${error instanceof Error ? error.message : String(error)}`;
|
|
2796
|
-
if (error instanceof RunnerHttpError && error.revoked) process.exitCode = ExitCode.Auth;
|
|
3314
|
+
const profile = selectProfile(config, options.org ?? globals().org);
|
|
3315
|
+
const absolute = resolve2(path ?? ".");
|
|
3316
|
+
if (options.remove) {
|
|
3317
|
+
profile.repoRoots = profile.repoRoots.filter((root) => resolve2(root) !== absolute);
|
|
3318
|
+
} else {
|
|
3319
|
+
if (!existsSync3(absolute))
|
|
3320
|
+
throw new CliError(`${absolute} does not exist.`, ExitCode.Validation);
|
|
3321
|
+
if (!profile.repoRoots.some((root) => resolve2(root) === absolute))
|
|
3322
|
+
profile.repoRoots.push(absolute);
|
|
2797
3323
|
}
|
|
3324
|
+
writeRunnerConfig(config);
|
|
3325
|
+
const whose = config.profiles.length > 1 ? `${profileLabel(profile)}'s path hints` : "Path hints";
|
|
3326
|
+
print(
|
|
3327
|
+
options.remove ? `${whose} under ${absolute} are no longer used.` : `${whose} under ${absolute} are used for projects without a mapping.`
|
|
3328
|
+
);
|
|
3329
|
+
});
|
|
3330
|
+
runner.command("remove").description("Stop running for one organization on this machine (the other organizations are kept)").argument("<org>", "Organization slug, as `aictiq runner status` lists it").action((org) => {
|
|
3331
|
+
const config = requireConfig();
|
|
3332
|
+
const profile = selectProfile(config, org);
|
|
3333
|
+
config.profiles = config.profiles.filter((p) => p !== profile);
|
|
3334
|
+
writeRunnerConfig(config);
|
|
3335
|
+
print(
|
|
3336
|
+
`Removed ${profileLabel(profile)} from this machine. A running \`aictiq runner start\` stops taking its runs within a few seconds.
|
|
3337
|
+
Its secret still works until you disable or delete the runner in ${profileLabel(profile)}'s Factory \u2192 Runners.`
|
|
3338
|
+
);
|
|
3339
|
+
});
|
|
3340
|
+
runner.command("status").description("Show each registration, detected harnesses and mapped repositories").action(async () => {
|
|
3341
|
+
const config = requireConfig();
|
|
3342
|
+
const capabilities = await probe(1, config.machineId);
|
|
3343
|
+
const registrations = await Promise.all(
|
|
3344
|
+
config.profiles.map(async (profile) => {
|
|
3345
|
+
try {
|
|
3346
|
+
const hello = await new RunnerClient({ baseUrl: profile.url, token: profile.token }).hello(capabilities);
|
|
3347
|
+
return { profile, hello, text: `ok - "${hello.name}" in ${hello.organizationSlug}`, revoked: false };
|
|
3348
|
+
} catch (error) {
|
|
3349
|
+
const message3 = error instanceof Error ? error.message : String(error);
|
|
3350
|
+
return {
|
|
3351
|
+
profile,
|
|
3352
|
+
error: message3,
|
|
3353
|
+
text: `failing - ${message3}`,
|
|
3354
|
+
revoked: error instanceof RunnerHttpError && error.revoked
|
|
3355
|
+
};
|
|
3356
|
+
}
|
|
3357
|
+
})
|
|
3358
|
+
);
|
|
3359
|
+
if (registrations.some((r) => r.revoked)) process.exitCode = ExitCode.Auth;
|
|
2798
3360
|
if (globals().json) {
|
|
2799
|
-
printJson({
|
|
3361
|
+
printJson({
|
|
3362
|
+
machineId: config.machineId,
|
|
3363
|
+
capabilities,
|
|
3364
|
+
attachments: config.attachments,
|
|
3365
|
+
profiles: registrations.map((r) => ({
|
|
3366
|
+
url: r.profile.url,
|
|
3367
|
+
organization: r.hello?.organizationSlug ?? r.profile.organization ?? null,
|
|
3368
|
+
registration: r.hello ?? { error: r.error },
|
|
3369
|
+
workspaces: r.profile.workspaces,
|
|
3370
|
+
repoRoots: r.profile.repoRoots
|
|
3371
|
+
}))
|
|
3372
|
+
});
|
|
2800
3373
|
return;
|
|
2801
3374
|
}
|
|
2802
3375
|
print(
|
|
2803
3376
|
renderFields([
|
|
2804
|
-
["Instance", config.url],
|
|
2805
|
-
["Registration", registrationText],
|
|
2806
3377
|
["Config", runnerConfigPath()],
|
|
3378
|
+
["Machine", config.machineId],
|
|
2807
3379
|
["Workspaces", defaultWorkspaceRoot()],
|
|
2808
3380
|
["Attachments", `${config.attachments.maxCount} files / ${formatBytes2(config.attachments.maxBytes)} per run`],
|
|
2809
3381
|
["CLI", `${version} (${capabilities.os}/${capabilities.arch})`]
|
|
@@ -2816,67 +3388,103 @@ Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
|
|
|
2816
3388
|
{ header: "VERSION", value: (h) => h.version ?? "" }
|
|
2817
3389
|
])
|
|
2818
3390
|
);
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
3391
|
+
for (const { profile, hello, text } of registrations) {
|
|
3392
|
+
const org = hello?.organizationSlug ?? profileLabel(profile);
|
|
3393
|
+
print("");
|
|
3394
|
+
print(
|
|
3395
|
+
renderFields([
|
|
3396
|
+
["Organization", org],
|
|
3397
|
+
["Instance", profile.url],
|
|
3398
|
+
["Registration", text]
|
|
3399
|
+
])
|
|
3400
|
+
);
|
|
3401
|
+
const mapped = Object.entries(profile.workspaces);
|
|
3402
|
+
print(
|
|
3403
|
+
mapped.length === 0 ? `No local repositories mapped (\`aictiq runner map <KEY> <path> --org ${org}\`).` : renderTable(mapped, [
|
|
3404
|
+
{ header: "PROJECT", value: ([key]) => key },
|
|
3405
|
+
{ header: "REPOSITORY", value: ([, path]) => path }
|
|
3406
|
+
])
|
|
3407
|
+
);
|
|
3408
|
+
print(
|
|
3409
|
+
profile.repoRoots.length === 0 ? `No repository roots; path hints from ${org} are not used (\`aictiq runner root <path> --org ${org}\`).` : renderTable(profile.repoRoots, [{ header: "REPOSITORY ROOT", value: (root) => root }])
|
|
3410
|
+
);
|
|
3411
|
+
}
|
|
2827
3412
|
});
|
|
2828
|
-
runner.command("start").description("Poll for runs and execute them until interrupted").option("--parallel <n>", "Runs executed at the same time", "1").option("--keep-workspaces", "Leave each run\u2019s checkout on disk after it finishes").option("--workspace-root <path>", "Where run checkouts are created").action(
|
|
3413
|
+
runner.command("start").description("Poll every registered organization for runs and execute them until interrupted").option("--parallel <n>", "Runs of one organization executed at the same time", "1").option("--keep-workspaces", "Leave each run\u2019s checkout on disk after it finishes").option("--workspace-root <path>", "Where run checkouts are created").action(
|
|
2829
3414
|
async (options) => {
|
|
2830
3415
|
const config = requireConfig();
|
|
2831
3416
|
const parallel = Number.parseInt(options.parallel, 10);
|
|
2832
3417
|
if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
|
|
2833
3418
|
throw new CliError("--parallel is between 1 and 16.", ExitCode.Validation);
|
|
2834
3419
|
}
|
|
2835
|
-
|
|
2836
|
-
const
|
|
3420
|
+
writeRunnerConfig(config);
|
|
3421
|
+
const log = (message3) => process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${message3}
|
|
2837
3422
|
`);
|
|
2838
|
-
const workspaceRoot =
|
|
2839
|
-
const
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
3423
|
+
const workspaceRoot = resolve2(options.workspaceRoot ?? defaultWorkspaceRoot());
|
|
3424
|
+
const supervisor = new RunnerSupervisor({
|
|
3425
|
+
readConfig: () => readRunnerConfig(),
|
|
3426
|
+
local: log,
|
|
3427
|
+
learned: (profile) => {
|
|
3428
|
+
const current = readRunnerConfig();
|
|
3429
|
+
const stored = current?.profiles.find((p) => p.token === profile.token);
|
|
3430
|
+
if (!current || !stored || stored.organization === profile.organization) return;
|
|
3431
|
+
stored.organization = profile.organization;
|
|
3432
|
+
writeRunnerConfig(current);
|
|
3433
|
+
},
|
|
3434
|
+
createLoop: (profile, floor, onHello) => {
|
|
3435
|
+
const client = new RunnerClient({ baseUrl: profile.url, token: profile.token });
|
|
3436
|
+
const local = (message3) => log(`[${profileLabel(profile)}] ${message3}`);
|
|
3437
|
+
return new RunnerLoop({
|
|
3438
|
+
client,
|
|
3439
|
+
parallel,
|
|
3440
|
+
probe: () => probe(parallel, config.machineId),
|
|
3441
|
+
local,
|
|
3442
|
+
floor,
|
|
3443
|
+
floorKey: profileKey(profile),
|
|
3444
|
+
onHello,
|
|
3445
|
+
execute: (run, hello, shutdown) => {
|
|
3446
|
+
const current = readRunnerConfig();
|
|
3447
|
+
const own = current?.profiles.find((p) => p.token === profile.token) ?? profile;
|
|
3448
|
+
return executeRun(run, {
|
|
3449
|
+
client,
|
|
3450
|
+
hello,
|
|
3451
|
+
adapters: harnesses,
|
|
3452
|
+
runnerToken: profile.token,
|
|
3453
|
+
shutdown,
|
|
3454
|
+
local,
|
|
3455
|
+
workspace: {
|
|
3456
|
+
root: workspaceRoot,
|
|
3457
|
+
repositories: own.workspaces,
|
|
3458
|
+
repoRoots: own.repoRoots,
|
|
3459
|
+
keep: options.keepWorkspaces === true,
|
|
3460
|
+
mcpServer: mcpServerCommand(),
|
|
3461
|
+
attachmentMaxCount: (current ?? config).attachments.maxCount,
|
|
3462
|
+
attachmentMaxBytes: (current ?? config).attachments.maxBytes
|
|
3463
|
+
}
|
|
3464
|
+
});
|
|
3465
|
+
}
|
|
3466
|
+
});
|
|
3467
|
+
}
|
|
2860
3468
|
});
|
|
2861
3469
|
let interrupts = 0;
|
|
2862
3470
|
const onSignal = (signal2) => {
|
|
2863
3471
|
interrupts++;
|
|
2864
|
-
if (interrupts === 1 &&
|
|
2865
|
-
|
|
2866
|
-
`${signal2}: finishing ${
|
|
3472
|
+
if (interrupts === 1 && supervisor.inFlight > 0) {
|
|
3473
|
+
log(
|
|
3474
|
+
`${signal2}: finishing ${supervisor.inFlight} run(s) in flight; send it again to cancel them`
|
|
2867
3475
|
);
|
|
2868
|
-
|
|
3476
|
+
supervisor.stop();
|
|
2869
3477
|
} else if (interrupts === 1) {
|
|
2870
|
-
|
|
3478
|
+
supervisor.stop();
|
|
2871
3479
|
} else {
|
|
2872
|
-
|
|
2873
|
-
|
|
3480
|
+
log(`${signal2}: cancelling runs in flight`);
|
|
3481
|
+
supervisor.abort();
|
|
2874
3482
|
}
|
|
2875
3483
|
};
|
|
2876
3484
|
process.on("SIGINT", onSignal);
|
|
2877
3485
|
process.on("SIGTERM", onSignal);
|
|
2878
3486
|
try {
|
|
2879
|
-
await
|
|
3487
|
+
await supervisor.run();
|
|
2880
3488
|
} catch (error) {
|
|
2881
3489
|
throw asCliError(error);
|
|
2882
3490
|
} finally {
|
|
@@ -2885,52 +3493,74 @@ Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
|
|
|
2885
3493
|
}
|
|
2886
3494
|
}
|
|
2887
3495
|
);
|
|
2888
|
-
runner.command("install-service").description(
|
|
2889
|
-
|
|
3496
|
+
runner.command("install-service").description(
|
|
3497
|
+
"Print a service definition that keeps `aictiq runner start` running: a systemd user unit (Linux), a launchd agent (macOS) or a Task Scheduler installer (Windows)"
|
|
3498
|
+
).option("--parallel <n>", "Runs executed at the same time", "1").option("--platform <platform>", "linux, macos or windows (default: this machine)").action((options) => {
|
|
3499
|
+
const platform2 = options.platform ?? servicePlatform();
|
|
3500
|
+
if (!isServicePlatform(platform2)) {
|
|
3501
|
+
throw new CliError(
|
|
3502
|
+
`Unknown platform "${platform2}". Use linux, macos or windows.`,
|
|
3503
|
+
ExitCode.Validation
|
|
3504
|
+
);
|
|
3505
|
+
}
|
|
3506
|
+
print(
|
|
3507
|
+
serviceDefinition(platform2, {
|
|
3508
|
+
node: process.execPath,
|
|
3509
|
+
entry: cliEntry(),
|
|
3510
|
+
parallel: options.parallel,
|
|
3511
|
+
path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
3512
|
+
home: homedir4()
|
|
3513
|
+
})
|
|
3514
|
+
);
|
|
2890
3515
|
});
|
|
2891
3516
|
return runner;
|
|
2892
3517
|
}
|
|
2893
|
-
function
|
|
2894
|
-
return `# Save as ~/.config/systemd/user/aictiq-runner.service, then:
|
|
2895
|
-
# systemctl --user daemon-reload
|
|
2896
|
-
# systemctl --user enable --now aictiq-runner
|
|
2897
|
-
# loginctl enable-linger "$USER" # keep it running after you log out
|
|
2898
|
-
#
|
|
2899
|
-
# The runner executes agents as this user, with this user's harness sign-ins and git
|
|
2900
|
-
# credentials: one runner is one trust domain. SIGTERM lets runs in flight finish;
|
|
2901
|
-
# TimeoutStopSec bounds how long systemd waits before it cancels them.
|
|
2902
|
-
[Unit]
|
|
2903
|
-
Description=Aictiq runner
|
|
2904
|
-
After=network-online.target
|
|
2905
|
-
Wants=network-online.target
|
|
2906
|
-
|
|
2907
|
-
[Service]
|
|
2908
|
-
Type=simple
|
|
2909
|
-
ExecStart=${node} ${entry} runner start --parallel ${parallel}
|
|
2910
|
-
Restart=on-failure
|
|
2911
|
-
RestartSec=10
|
|
2912
|
-
# Exit 5 means the secret was revoked; restarting cannot fix that.
|
|
2913
|
-
RestartPreventExitStatus=5
|
|
2914
|
-
KillMode=mixed
|
|
2915
|
-
TimeoutStopSec=15min
|
|
2916
|
-
Environment=PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}
|
|
2917
|
-
|
|
2918
|
-
[Install]
|
|
2919
|
-
WantedBy=default.target
|
|
2920
|
-
`;
|
|
2921
|
-
}
|
|
2922
|
-
async function probe(maxParallel) {
|
|
3518
|
+
async function probe(maxParallel, machineId) {
|
|
2923
3519
|
return {
|
|
2924
3520
|
v: 1,
|
|
2925
3521
|
harnesses: await probeHarnesses(),
|
|
2926
3522
|
os: platform(),
|
|
2927
3523
|
arch: arch(),
|
|
2928
3524
|
cliVersion: version,
|
|
2929
|
-
maxParallel
|
|
3525
|
+
maxParallel,
|
|
3526
|
+
machineId
|
|
2930
3527
|
};
|
|
2931
3528
|
}
|
|
3529
|
+
async function matchingProfile(profiles, url, token, organization, capabilities) {
|
|
3530
|
+
const exact = profiles.findIndex(
|
|
3531
|
+
(p) => p.token === token || sameInstance(p.url, url) && p.organization === organization
|
|
3532
|
+
);
|
|
3533
|
+
if (exact >= 0) return exact;
|
|
3534
|
+
for (const [index, profile] of profiles.entries()) {
|
|
3535
|
+
if (profile.organization || !sameInstance(profile.url, url)) continue;
|
|
3536
|
+
try {
|
|
3537
|
+
const hello = await new RunnerClient({ baseUrl: profile.url, token: profile.token }).hello(capabilities);
|
|
3538
|
+
profile.organization = hello.organizationSlug;
|
|
3539
|
+
if (hello.organizationSlug === organization) return index;
|
|
3540
|
+
} catch (error) {
|
|
3541
|
+
if (error instanceof RunnerHttpError && error.revoked && profiles.length === 1) return index;
|
|
3542
|
+
}
|
|
3543
|
+
}
|
|
3544
|
+
return -1;
|
|
3545
|
+
}
|
|
3546
|
+
function selectProfile(config, org) {
|
|
3547
|
+
const serves = config.profiles.map(profileLabel).join(", ");
|
|
3548
|
+
if (org) {
|
|
3549
|
+
const wanted = org.toLowerCase();
|
|
3550
|
+
const found = config.profiles.find((p) => profileLabel(p).toLowerCase() === wanted);
|
|
3551
|
+
if (!found) {
|
|
3552
|
+
throw new CliError(`This machine does not run for ${org}. It runs for: ${serves}.`, ExitCode.Validation);
|
|
3553
|
+
}
|
|
3554
|
+
return found;
|
|
3555
|
+
}
|
|
3556
|
+
if (config.profiles.length === 1) return config.profiles[0];
|
|
3557
|
+
throw new CliError(
|
|
3558
|
+
`This machine runs for several organizations (${serves}). Say which with --org <slug>.`,
|
|
3559
|
+
ExitCode.Validation
|
|
3560
|
+
);
|
|
3561
|
+
}
|
|
2932
3562
|
function cliEntry() {
|
|
2933
|
-
return
|
|
3563
|
+
return resolve2(process.argv[1] ?? "aictiq");
|
|
2934
3564
|
}
|
|
2935
3565
|
function mcpServerCommand() {
|
|
2936
3566
|
return { command: process.execPath, args: [cliEntry(), "mcp"] };
|
|
@@ -2940,7 +3570,7 @@ function formatBytes2(value) {
|
|
|
2940
3570
|
}
|
|
2941
3571
|
function requireConfig() {
|
|
2942
3572
|
const config = readRunnerConfig();
|
|
2943
|
-
if (!config) {
|
|
3573
|
+
if (!config || config.profiles.length === 0) {
|
|
2944
3574
|
throw new CliError(
|
|
2945
3575
|
`This machine is not registered as a runner (${runnerConfigPath()}). Run \`aictiq runner register --url <url> --token <jrn_\u2026>\`.`,
|
|
2946
3576
|
ExitCode.Auth
|
|
@@ -2958,6 +3588,9 @@ function asCliError(error) {
|
|
|
2958
3588
|
}
|
|
2959
3589
|
return error;
|
|
2960
3590
|
}
|
|
3591
|
+
function isServicePlatform(value) {
|
|
3592
|
+
return value === "linux" || value === "macos" || value === "windows";
|
|
3593
|
+
}
|
|
2961
3594
|
|
|
2962
3595
|
// src/commands/sprint.ts
|
|
2963
3596
|
import { Command as Command8 } from "commander";
|