@aictiq/cli 0.2.0 → 0.4.1
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 +727 -141
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1201,8 +1201,9 @@ 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 { arch, homedir as homedir4, platform } from "os";
|
|
1206
1207
|
import { resolve as resolve2 } from "path";
|
|
1207
1208
|
|
|
1208
1209
|
// src/runner/client.ts
|
|
@@ -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,6 +1343,7 @@ 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";
|
|
@@ -1597,12 +1603,30 @@ async function provisionLocal(run, checkout, options, env) {
|
|
|
1597
1603
|
return false;
|
|
1598
1604
|
}
|
|
1599
1605
|
);
|
|
1606
|
+
if (run.workOnDefaultBranch && !hasOrigin) {
|
|
1607
|
+
throw new RunFailure(
|
|
1608
|
+
"no-remote",
|
|
1609
|
+
`${repo}, ${origin} ${run.projectKey}, has no origin remote to push ${run.branchName} to. Direct delivery needs one; add it with \`git remote add origin <url>\` or use branch-and-pull-request delivery.`
|
|
1610
|
+
);
|
|
1611
|
+
}
|
|
1600
1612
|
let base = run.defaultBranch;
|
|
1601
1613
|
if (hasOrigin) {
|
|
1602
1614
|
options.event(`Fetching origin in ${repo}`);
|
|
1603
1615
|
await call(["fetch", "origin"]);
|
|
1604
1616
|
base = `origin/${run.defaultBranch}`;
|
|
1605
1617
|
}
|
|
1618
|
+
if (run.workOnDefaultBranch) {
|
|
1619
|
+
await call(["clone", "--no-hardlinks", "--", repo, checkout]);
|
|
1620
|
+
const isolated = (args) => gitStep(args, { cwd: checkout, env, signal: options.signal });
|
|
1621
|
+
const remote = (await git(["remote", "get-url", "origin"], { cwd: repo, env })).trim();
|
|
1622
|
+
const remoteUrl = isAbsolute(remote) || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(remote) || /^[^/]+:/.test(remote) ? remote : resolve(repo, remote);
|
|
1623
|
+
await isolated(["remote", "set-url", "origin", remoteUrl]);
|
|
1624
|
+
await isolated(["fetch", "origin"]);
|
|
1625
|
+
await isolated(["checkout", "-B", run.branchName, `origin/${run.branchName}`]);
|
|
1626
|
+
await isolated(["branch", "--set-upstream-to", `origin/${run.branchName}`, run.branchName]);
|
|
1627
|
+
options.event(`Created isolated clone ${checkout} on default branch ${run.branchName}`);
|
|
1628
|
+
return null;
|
|
1629
|
+
}
|
|
1606
1630
|
const branchExists = await git(
|
|
1607
1631
|
["rev-parse", "--verify", "--quiet", `refs/heads/${run.branchName}`],
|
|
1608
1632
|
{
|
|
@@ -1667,8 +1691,10 @@ async function provisionGithub(run, runDir, checkout, options, env) {
|
|
|
1667
1691
|
);
|
|
1668
1692
|
options.event(`Cloned ${full} (depth 50)`);
|
|
1669
1693
|
const call = (args) => gitStep(args, { cwd: checkout, env, signal: options.signal }, token);
|
|
1670
|
-
|
|
1671
|
-
|
|
1694
|
+
if (!run.workOnDefaultBranch) {
|
|
1695
|
+
await call(["checkout", "-b", run.branchName]);
|
|
1696
|
+
options.event(`Created branch ${run.branchName} from ${run.defaultBranch}`);
|
|
1697
|
+
}
|
|
1672
1698
|
await call(["config", "--local", "--replace-all", "credential.helper", ""]);
|
|
1673
1699
|
await call(["config", "--local", "--add", "credential.helper", CREDENTIAL_HELPER]);
|
|
1674
1700
|
return { [TOKEN_ENV]: token };
|
|
@@ -1750,22 +1776,23 @@ function readRunnerConfig(path = runnerConfigPath()) {
|
|
|
1750
1776
|
return null;
|
|
1751
1777
|
}
|
|
1752
1778
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1753
|
-
const
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
}
|
|
1779
|
+
const raw = parsed;
|
|
1780
|
+
let profiles;
|
|
1781
|
+
if (Array.isArray(raw.profiles)) {
|
|
1782
|
+
profiles = raw.profiles.flatMap((entry) => {
|
|
1783
|
+
const profile = readProfile(entry);
|
|
1784
|
+
return profile ? [profile] : [];
|
|
1785
|
+
});
|
|
1786
|
+
} else {
|
|
1787
|
+
const legacy = readProfile(raw);
|
|
1788
|
+
if (!legacy) return null;
|
|
1789
|
+
profiles = [legacy];
|
|
1760
1790
|
}
|
|
1761
|
-
const
|
|
1762
|
-
const rawLimits = typeof attachments === "object" && attachments !== null ? attachments : {};
|
|
1791
|
+
const rawLimits = typeof raw.attachments === "object" && raw.attachments !== null ? raw.attachments : {};
|
|
1763
1792
|
return {
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
workspaces: mapped,
|
|
1768
|
-
repoRoots: roots,
|
|
1793
|
+
machineId: typeof raw.machineId === "string" && UuidPattern.test(raw.machineId) ? raw.machineId : randomUUID(),
|
|
1794
|
+
...typeof raw.name === "string" ? { name: raw.name } : {},
|
|
1795
|
+
profiles,
|
|
1769
1796
|
attachments: {
|
|
1770
1797
|
maxCount: validLimit(rawLimits.maxCount, DefaultAttachmentMaxCount),
|
|
1771
1798
|
maxBytes: validLimit(rawLimits.maxBytes, DefaultAttachmentMaxBytes)
|
|
@@ -1774,10 +1801,56 @@ function readRunnerConfig(path = runnerConfigPath()) {
|
|
|
1774
1801
|
}
|
|
1775
1802
|
function writeRunnerConfig(config, path = runnerConfigPath()) {
|
|
1776
1803
|
mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
|
|
1777
|
-
|
|
1804
|
+
const document = {
|
|
1805
|
+
machineId: config.machineId,
|
|
1806
|
+
...config.name ? { name: config.name } : {},
|
|
1807
|
+
attachments: config.attachments,
|
|
1808
|
+
profiles: config.profiles.map((profile) => ({
|
|
1809
|
+
...profile.organization ? { organization: profile.organization } : {},
|
|
1810
|
+
url: profile.url,
|
|
1811
|
+
token: profile.token,
|
|
1812
|
+
workspaces: profile.workspaces,
|
|
1813
|
+
repoRoots: profile.repoRoots
|
|
1814
|
+
}))
|
|
1815
|
+
};
|
|
1816
|
+
writeFileSync3(path, `${JSON.stringify(document, null, 2)}
|
|
1778
1817
|
`, { mode: 384 });
|
|
1779
1818
|
chmodSync3(path, 384);
|
|
1780
1819
|
}
|
|
1820
|
+
function profileLabel(profile) {
|
|
1821
|
+
if (profile.organization) return profile.organization;
|
|
1822
|
+
try {
|
|
1823
|
+
return new URL(profile.url).host;
|
|
1824
|
+
} catch {
|
|
1825
|
+
return profile.url;
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
function profileKey(profile) {
|
|
1829
|
+
return createHash("sha256").update(profile.token).digest("hex").slice(0, 16);
|
|
1830
|
+
}
|
|
1831
|
+
function sameInstance(left, right) {
|
|
1832
|
+
return left.replace(/\/+$/, "").toLowerCase() === right.replace(/\/+$/, "").toLowerCase();
|
|
1833
|
+
}
|
|
1834
|
+
var UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1835
|
+
function readProfile(value) {
|
|
1836
|
+
if (typeof value !== "object" || value === null) return null;
|
|
1837
|
+
const { url, token, organization, workspaces, repoRoots } = value;
|
|
1838
|
+
if (typeof url !== "string" || typeof token !== "string") return null;
|
|
1839
|
+
const mapped = {};
|
|
1840
|
+
if (typeof workspaces === "object" && workspaces !== null) {
|
|
1841
|
+
for (const [key, path] of Object.entries(workspaces)) {
|
|
1842
|
+
if (typeof path === "string") mapped[key] = path;
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
const roots = Array.isArray(repoRoots) ? repoRoots.filter((root) => typeof root === "string" && root.trim() !== "") : [];
|
|
1846
|
+
return {
|
|
1847
|
+
url,
|
|
1848
|
+
token,
|
|
1849
|
+
...typeof organization === "string" && organization !== "" ? { organization } : {},
|
|
1850
|
+
workspaces: mapped,
|
|
1851
|
+
repoRoots: roots
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1781
1854
|
function validLimit(value, fallback) {
|
|
1782
1855
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : fallback;
|
|
1783
1856
|
}
|
|
@@ -2119,7 +2192,7 @@ async function executeRun(run, options) {
|
|
|
2119
2192
|
mcpServer: options.workspace.mcpServer
|
|
2120
2193
|
});
|
|
2121
2194
|
const env = {
|
|
2122
|
-
...process.env,
|
|
2195
|
+
...inheritedEnv(process.env),
|
|
2123
2196
|
...invocation.env,
|
|
2124
2197
|
...workspace.env,
|
|
2125
2198
|
// The agent reaches the instance as itself, never as the runner: `aictiq mcp` and the
|
|
@@ -2149,7 +2222,9 @@ async function executeRun(run, options) {
|
|
|
2149
2222
|
...invocation.stdin === void 0 ? {} : { stdin: invocation.stdin },
|
|
2150
2223
|
onLine: (stream, raw) => {
|
|
2151
2224
|
const line = stripVTControlCharacters(raw);
|
|
2152
|
-
|
|
2225
|
+
if (!run.workOnDefaultBranch) {
|
|
2226
|
+
for (const match of line.matchAll(PullRequestUrl)) pullRequestUrl = match[0];
|
|
2227
|
+
}
|
|
2153
2228
|
if (stream === "stderr") {
|
|
2154
2229
|
log.push("stderr", line);
|
|
2155
2230
|
remember(line);
|
|
@@ -2173,7 +2248,7 @@ async function executeRun(run, options) {
|
|
|
2173
2248
|
const usage = { costUsd, inputTokens, outputTokens, exitCode };
|
|
2174
2249
|
if (stopReason) return stopped(exitCode, lastLines, usage);
|
|
2175
2250
|
const verdict = adapter.outcome(exitCode, lastLines, lastResult);
|
|
2176
|
-
if (verdict.outcome === "succeeded" && !pullRequestUrl) {
|
|
2251
|
+
if (verdict.outcome === "succeeded" && !run.workOnDefaultBranch && !pullRequestUrl) {
|
|
2177
2252
|
pullRequestUrl = await (options.findPullRequest ?? findPullRequest)(
|
|
2178
2253
|
workspace.checkout,
|
|
2179
2254
|
run.branchName,
|
|
@@ -2278,6 +2353,9 @@ function truncate(value, max) {
|
|
|
2278
2353
|
if (!value) return null;
|
|
2279
2354
|
return value.length <= max ? value : `${value.slice(0, max - 1)}\u2026`;
|
|
2280
2355
|
}
|
|
2356
|
+
function inheritedEnv(env) {
|
|
2357
|
+
return Object.fromEntries(Object.entries(env).filter(([key]) => !key.toUpperCase().startsWith("AICTIQ_")));
|
|
2358
|
+
}
|
|
2281
2359
|
function message2(error) {
|
|
2282
2360
|
return error instanceof Error ? error.message : String(error);
|
|
2283
2361
|
}
|
|
@@ -2613,6 +2691,171 @@ async function probeHarnesses(env) {
|
|
|
2613
2691
|
return found.filter((info) => info !== null);
|
|
2614
2692
|
}
|
|
2615
2693
|
|
|
2694
|
+
// src/runner/service.ts
|
|
2695
|
+
import { posix } from "path";
|
|
2696
|
+
var RevokedExitCode = 5;
|
|
2697
|
+
function servicePlatform(platform2 = process.platform) {
|
|
2698
|
+
if (platform2 === "darwin") return "macos";
|
|
2699
|
+
if (platform2 === "win32") return "windows";
|
|
2700
|
+
return "linux";
|
|
2701
|
+
}
|
|
2702
|
+
function serviceDefinition(platform2, options) {
|
|
2703
|
+
switch (platform2) {
|
|
2704
|
+
case "macos":
|
|
2705
|
+
return launchdAgent(options);
|
|
2706
|
+
case "windows":
|
|
2707
|
+
return scheduledTaskInstaller(options);
|
|
2708
|
+
default:
|
|
2709
|
+
return systemdUnit(options);
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
function systemdUnit({ node, entry, parallel, path }) {
|
|
2713
|
+
return `# Save as ~/.config/systemd/user/aictiq-runner.service, then:
|
|
2714
|
+
# systemctl --user daemon-reload
|
|
2715
|
+
# systemctl --user enable --now aictiq-runner
|
|
2716
|
+
# loginctl enable-linger "$USER" # keep it running after you log out
|
|
2717
|
+
#
|
|
2718
|
+
# The runner executes agents as this user, with this user's harness sign-ins and git
|
|
2719
|
+
# credentials: one runner is one trust domain. SIGTERM lets runs in flight finish;
|
|
2720
|
+
# TimeoutStopSec bounds how long systemd waits before it cancels them.
|
|
2721
|
+
[Unit]
|
|
2722
|
+
Description=Aictiq runner
|
|
2723
|
+
After=network-online.target
|
|
2724
|
+
Wants=network-online.target
|
|
2725
|
+
|
|
2726
|
+
[Service]
|
|
2727
|
+
Type=simple
|
|
2728
|
+
ExecStart=${node} ${entry} runner start --parallel ${parallel}
|
|
2729
|
+
Restart=on-failure
|
|
2730
|
+
RestartSec=10
|
|
2731
|
+
# Exit ${RevokedExitCode} means the secret was revoked; restarting cannot fix that.
|
|
2732
|
+
RestartPreventExitStatus=${RevokedExitCode}
|
|
2733
|
+
KillMode=mixed
|
|
2734
|
+
TimeoutStopSec=15min
|
|
2735
|
+
Environment=PATH=${path}
|
|
2736
|
+
|
|
2737
|
+
[Install]
|
|
2738
|
+
WantedBy=default.target
|
|
2739
|
+
`;
|
|
2740
|
+
}
|
|
2741
|
+
var LaunchdLabel = "com.aictiq.runner";
|
|
2742
|
+
var LaunchdWrapper = `"$1" "$2" runner start --parallel "$3" &
|
|
2743
|
+
child=$!
|
|
2744
|
+
trap 'kill -TERM "$child" 2>/dev/null' TERM INT
|
|
2745
|
+
wait "$child"
|
|
2746
|
+
status=$?
|
|
2747
|
+
while kill -0 "$child" 2>/dev/null; do wait "$child"; status=$?; done
|
|
2748
|
+
if [ "$status" -eq ${RevokedExitCode} ]; then exit 0; fi
|
|
2749
|
+
exit "$status"`;
|
|
2750
|
+
function launchdAgent({ node, entry, parallel, path, home }) {
|
|
2751
|
+
const log = posix.join(home, "Library", "Logs", "aictiq-runner.log");
|
|
2752
|
+
const plistPath = `~/Library/LaunchAgents/${LaunchdLabel}.plist`;
|
|
2753
|
+
const string = (value) => `<string>${xml(value)}</string>`;
|
|
2754
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
2755
|
+
<!--
|
|
2756
|
+
Save as ${plistPath}, then:
|
|
2757
|
+
launchctl bootstrap gui/$(id -u) ${plistPath}
|
|
2758
|
+
Check it: launchctl print gui/$(id -u)/${LaunchdLabel}
|
|
2759
|
+
Log: tail -f ${xmlComment(log)}
|
|
2760
|
+
Remove it: launchctl bootout gui/$(id -u)/${LaunchdLabel}
|
|
2761
|
+
|
|
2762
|
+
The runner executes agents as this user, with this user's harness sign-ins and git
|
|
2763
|
+
credentials: one runner is one trust domain. It runs while this user is logged in.
|
|
2764
|
+
A stop lets runs in flight finish for up to 15 minutes before launchd kills them.
|
|
2765
|
+
-->
|
|
2766
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2767
|
+
<plist version="1.0">
|
|
2768
|
+
<dict>
|
|
2769
|
+
<key>Label</key>
|
|
2770
|
+
${string(LaunchdLabel)}
|
|
2771
|
+
<key>ProgramArguments</key>
|
|
2772
|
+
<array>
|
|
2773
|
+
${string("/bin/sh")}
|
|
2774
|
+
${string("-c")}
|
|
2775
|
+
${string(LaunchdWrapper)}
|
|
2776
|
+
${string("aictiq-runner")}
|
|
2777
|
+
${string(node)}
|
|
2778
|
+
${string(entry)}
|
|
2779
|
+
${string(parallel)}
|
|
2780
|
+
</array>
|
|
2781
|
+
<key>EnvironmentVariables</key>
|
|
2782
|
+
<dict>
|
|
2783
|
+
<key>PATH</key>
|
|
2784
|
+
${string(path)}
|
|
2785
|
+
</dict>
|
|
2786
|
+
<key>RunAtLoad</key>
|
|
2787
|
+
<true/>
|
|
2788
|
+
<key>KeepAlive</key>
|
|
2789
|
+
<dict>
|
|
2790
|
+
<key>SuccessfulExit</key>
|
|
2791
|
+
<false/>
|
|
2792
|
+
</dict>
|
|
2793
|
+
<key>ThrottleInterval</key>
|
|
2794
|
+
<integer>10</integer>
|
|
2795
|
+
<key>ExitTimeOut</key>
|
|
2796
|
+
<integer>900</integer>
|
|
2797
|
+
<key>ProcessType</key>
|
|
2798
|
+
${string("Background")}
|
|
2799
|
+
<key>StandardOutPath</key>
|
|
2800
|
+
${string(log)}
|
|
2801
|
+
<key>StandardErrorPath</key>
|
|
2802
|
+
${string(log)}
|
|
2803
|
+
</dict>
|
|
2804
|
+
</plist>
|
|
2805
|
+
`;
|
|
2806
|
+
}
|
|
2807
|
+
var ScheduledTaskName = "Aictiq runner";
|
|
2808
|
+
function scheduledTaskInstaller({ node, entry, parallel, path }) {
|
|
2809
|
+
const loop = `# Written by \`aictiq runner install-service\`; the "${ScheduledTaskName}" task runs it at logon.
|
|
2810
|
+
$ErrorActionPreference = 'Continue'
|
|
2811
|
+
$env:PATH = ${ps(path)}
|
|
2812
|
+
$log = Join-Path $env:LOCALAPPDATA 'aictiq\\runner.log'
|
|
2813
|
+
while ($true) {
|
|
2814
|
+
# "$_" turns stderr's error records back into the plain lines the runner wrote.
|
|
2815
|
+
& ${ps(node)} ${ps(entry)} runner start --parallel ${ps(parallel)} 2>&1 |
|
|
2816
|
+
ForEach-Object { "$_" } | Add-Content -Path $log -Encoding utf8
|
|
2817
|
+
if ($LASTEXITCODE -eq ${RevokedExitCode}) {
|
|
2818
|
+
Add-Content -Path $log -Encoding utf8 -Value 'The runner secret was revoked; not restarting.'
|
|
2819
|
+
break
|
|
2820
|
+
}
|
|
2821
|
+
Start-Sleep -Seconds 10
|
|
2822
|
+
}
|
|
2823
|
+
`;
|
|
2824
|
+
return `# Aictiq runner as a Windows scheduled task. Save as install-runner.ps1, then run:
|
|
2825
|
+
# powershell -NoProfile -ExecutionPolicy Bypass -File install-runner.ps1
|
|
2826
|
+
# Check it: Get-ScheduledTask -TaskName '${ScheduledTaskName}' | Get-ScheduledTaskInfo
|
|
2827
|
+
# Log: Get-Content -Wait "$env:LOCALAPPDATA\\aictiq\\runner.log"
|
|
2828
|
+
# Remove it: Unregister-ScheduledTask -TaskName '${ScheduledTaskName}' -Confirm:$false
|
|
2829
|
+
#
|
|
2830
|
+
# The task starts at logon and runs while this user is logged in, as this user, with this
|
|
2831
|
+
# user's harness sign-ins and git credentials: one runner is one trust domain. Stopping the
|
|
2832
|
+
# task ends the runner at once; runs in flight are cancelled, not finished.
|
|
2833
|
+
$ErrorActionPreference = 'Stop'
|
|
2834
|
+
$dir = Join-Path $env:LOCALAPPDATA 'aictiq'
|
|
2835
|
+
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
|
2836
|
+
$script = Join-Path $dir 'runner-service.ps1'
|
|
2837
|
+
Set-Content -Path $script -Encoding utf8 -Value @'
|
|
2838
|
+
${loop}'@
|
|
2839
|
+
|
|
2840
|
+
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File \`"$script\`""
|
|
2841
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\\$env:USERNAME"
|
|
2842
|
+
$principal = New-ScheduledTaskPrincipal -UserId "$env:USERDOMAIN\\$env:USERNAME" -LogonType Interactive -RunLevel Limited
|
|
2843
|
+
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -MultipleInstances IgnoreNew
|
|
2844
|
+
Register-ScheduledTask -TaskName '${ScheduledTaskName}' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
2845
|
+
Start-ScheduledTask -TaskName '${ScheduledTaskName}'
|
|
2846
|
+
Write-Host "Installed and started '${ScheduledTaskName}'. Log: $(Join-Path $dir 'runner.log')"
|
|
2847
|
+
`;
|
|
2848
|
+
}
|
|
2849
|
+
function xml(value) {
|
|
2850
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2851
|
+
}
|
|
2852
|
+
function xmlComment(value) {
|
|
2853
|
+
return value.replace(/--/g, "- -");
|
|
2854
|
+
}
|
|
2855
|
+
function ps(value) {
|
|
2856
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
2616
2859
|
// src/runner/loop.ts
|
|
2617
2860
|
var KnownHarnesses = ["claude", "codex", "opencode"];
|
|
2618
2861
|
var RunnerRevokedError = class extends Error {
|
|
@@ -2650,6 +2893,7 @@ var RunnerLoop = class {
|
|
|
2650
2893
|
let capabilities = await this.options.probe();
|
|
2651
2894
|
const hello = await this.retrying(() => client.hello(capabilities));
|
|
2652
2895
|
if (!hello) return;
|
|
2896
|
+
this.options.onHello?.(hello);
|
|
2653
2897
|
local(
|
|
2654
2898
|
`Runner "${hello.name}" connected to ${client.baseUrl} (${hello.organizationSlug}); harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none detected"}`
|
|
2655
2899
|
);
|
|
@@ -2664,9 +2908,15 @@ var RunnerLoop = class {
|
|
|
2664
2908
|
}
|
|
2665
2909
|
})();
|
|
2666
2910
|
}, hello.heartbeatIntervalSeconds * 1e3);
|
|
2911
|
+
const { floor } = this.options;
|
|
2912
|
+
const floorKey = this.options.floorKey ?? client.baseUrl;
|
|
2667
2913
|
try {
|
|
2668
2914
|
let attempt = 0;
|
|
2669
2915
|
while (!this.stopping) {
|
|
2916
|
+
if (floor) {
|
|
2917
|
+
await floor.turn(floorKey, this.claimAbort.signal);
|
|
2918
|
+
if (this.stopping) break;
|
|
2919
|
+
}
|
|
2670
2920
|
const free = this.options.parallel - this.active.size;
|
|
2671
2921
|
if (free <= 0) {
|
|
2672
2922
|
await this.waitForSlot();
|
|
@@ -2693,6 +2943,8 @@ var RunnerLoop = class {
|
|
|
2693
2943
|
continue;
|
|
2694
2944
|
}
|
|
2695
2945
|
if (!claimed) continue;
|
|
2946
|
+
if (floor && !floor.enter(floorKey) && !await this.handBack(claimed, floor, floorKey))
|
|
2947
|
+
continue;
|
|
2696
2948
|
local(`Claimed ${claimed.itemKey} (run ${claimed.runId}, ${claimed.harness})`);
|
|
2697
2949
|
const execution = this.options.execute(claimed, hello, this.shutdown.signal).then(() => void 0).catch((error) => {
|
|
2698
2950
|
if (error instanceof RunnerHttpError && error.revoked) this.revoke(error);
|
|
@@ -2701,6 +2953,7 @@ var RunnerLoop = class {
|
|
|
2701
2953
|
`Run ${claimed.runId} ended with an error: ${error instanceof Error ? error.message : String(error)}`
|
|
2702
2954
|
);
|
|
2703
2955
|
}).finally(() => {
|
|
2956
|
+
floor?.leave(floorKey);
|
|
2704
2957
|
this.active.delete(execution);
|
|
2705
2958
|
this.wake?.();
|
|
2706
2959
|
});
|
|
@@ -2713,6 +2966,32 @@ var RunnerLoop = class {
|
|
|
2713
2966
|
}
|
|
2714
2967
|
if (this.revoked) throw this.revoked;
|
|
2715
2968
|
}
|
|
2969
|
+
/**
|
|
2970
|
+
* A run claimed while another organization took the floor. It goes back to the queue;
|
|
2971
|
+
* true only when it could not be given back and has now waited for the floor instead,
|
|
2972
|
+
* so the caller executes it.
|
|
2973
|
+
*/
|
|
2974
|
+
async handBack(run, floor, key) {
|
|
2975
|
+
const { client, local } = this.options;
|
|
2976
|
+
try {
|
|
2977
|
+
await client.release(run.runId);
|
|
2978
|
+
local(
|
|
2979
|
+
`Gave ${run.itemKey} back to the queue: another organization has runs executing on this machine`
|
|
2980
|
+
);
|
|
2981
|
+
return false;
|
|
2982
|
+
} catch (error) {
|
|
2983
|
+
if (error instanceof RunnerHttpError && error.revoked) {
|
|
2984
|
+
this.revoke(error);
|
|
2985
|
+
return false;
|
|
2986
|
+
}
|
|
2987
|
+
if (error instanceof RunnerHttpError && error.problem?.type) return false;
|
|
2988
|
+
local(
|
|
2989
|
+
`Could not give ${run.itemKey} back (${error instanceof Error ? error.message : String(error)}); it starts when the other organization's runs finish`
|
|
2990
|
+
);
|
|
2991
|
+
while (!floor.enter(key)) await floor.turn(key);
|
|
2992
|
+
return true;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2716
2995
|
revoke(error) {
|
|
2717
2996
|
this.revoked ??= new RunnerRevokedError(
|
|
2718
2997
|
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\`.`
|
|
@@ -2757,12 +3036,208 @@ var RunnerLoop = class {
|
|
|
2757
3036
|
}
|
|
2758
3037
|
};
|
|
2759
3038
|
|
|
3039
|
+
// src/runner/floor.ts
|
|
3040
|
+
var Floor = class {
|
|
3041
|
+
constructor(handoffMs = 2e3) {
|
|
3042
|
+
this.handoffMs = handoffMs;
|
|
3043
|
+
}
|
|
3044
|
+
handoffMs;
|
|
3045
|
+
holder = null;
|
|
3046
|
+
count = 0;
|
|
3047
|
+
last = null;
|
|
3048
|
+
freedAt = 0;
|
|
3049
|
+
waiting = /* @__PURE__ */ new Map();
|
|
3050
|
+
changed = deferred();
|
|
3051
|
+
/** The organization with runs executing, or null when the floor is free. */
|
|
3052
|
+
get current() {
|
|
3053
|
+
return this.holder;
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Resolves when `key` may claim: the floor is free, or its own runs hold it. Also resolves
|
|
3057
|
+
* when `signal` aborts; the caller checks why.
|
|
3058
|
+
*/
|
|
3059
|
+
async turn(key, signal2) {
|
|
3060
|
+
this.waiting.set(key, (this.waiting.get(key) ?? 0) + 1);
|
|
3061
|
+
try {
|
|
3062
|
+
while (!signal2?.aborted) {
|
|
3063
|
+
const yieldFor = this.yieldFor(key);
|
|
3064
|
+
if (this.count > 0 ? this.holder === key : yieldFor === 0) return;
|
|
3065
|
+
const waits = [this.changed.promise];
|
|
3066
|
+
let timer;
|
|
3067
|
+
if (yieldFor > 0)
|
|
3068
|
+
waits.push(new Promise((resolve3) => timer = setTimeout(resolve3, yieldFor)));
|
|
3069
|
+
let onAbort;
|
|
3070
|
+
if (signal2) {
|
|
3071
|
+
waits.push(
|
|
3072
|
+
new Promise((resolve3) => {
|
|
3073
|
+
onAbort = () => resolve3();
|
|
3074
|
+
signal2.addEventListener("abort", onAbort, { once: true });
|
|
3075
|
+
})
|
|
3076
|
+
);
|
|
3077
|
+
}
|
|
3078
|
+
await Promise.race(waits);
|
|
3079
|
+
clearTimeout(timer);
|
|
3080
|
+
if (onAbort) signal2?.removeEventListener("abort", onAbort);
|
|
3081
|
+
}
|
|
3082
|
+
} finally {
|
|
3083
|
+
const left = (this.waiting.get(key) ?? 1) - 1;
|
|
3084
|
+
if (left === 0) this.waiting.delete(key);
|
|
3085
|
+
else this.waiting.set(key, left);
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
/** Takes the floor for one run of `key`; false when another organization's runs hold it. */
|
|
3089
|
+
enter(key) {
|
|
3090
|
+
if (this.count > 0 && this.holder !== key) return false;
|
|
3091
|
+
this.holder = key;
|
|
3092
|
+
this.count++;
|
|
3093
|
+
this.notify();
|
|
3094
|
+
return true;
|
|
3095
|
+
}
|
|
3096
|
+
leave(key) {
|
|
3097
|
+
if (this.holder !== key || this.count === 0) return;
|
|
3098
|
+
this.count--;
|
|
3099
|
+
if (this.count === 0) {
|
|
3100
|
+
this.holder = null;
|
|
3101
|
+
this.last = key;
|
|
3102
|
+
this.freedAt = Date.now();
|
|
3103
|
+
}
|
|
3104
|
+
this.notify();
|
|
3105
|
+
}
|
|
3106
|
+
/** How long `key` stands back from a free floor so a waiting organization goes first. */
|
|
3107
|
+
yieldFor(key) {
|
|
3108
|
+
if (this.count > 0 || key !== this.last) return 0;
|
|
3109
|
+
const othersWaiting = [...this.waiting.keys()].some((other) => other !== key);
|
|
3110
|
+
if (!othersWaiting) return 0;
|
|
3111
|
+
return Math.max(0, this.freedAt + this.handoffMs - Date.now());
|
|
3112
|
+
}
|
|
3113
|
+
notify() {
|
|
3114
|
+
const previous = this.changed;
|
|
3115
|
+
this.changed = deferred();
|
|
3116
|
+
previous.resolve();
|
|
3117
|
+
}
|
|
3118
|
+
};
|
|
3119
|
+
function deferred() {
|
|
3120
|
+
let resolve3;
|
|
3121
|
+
const promise = new Promise((done) => resolve3 = done);
|
|
3122
|
+
return { promise, resolve: resolve3 };
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
// src/runner/supervisor.ts
|
|
3126
|
+
var RunnerSupervisor = class {
|
|
3127
|
+
options;
|
|
3128
|
+
floor;
|
|
3129
|
+
running = /* @__PURE__ */ new Map();
|
|
3130
|
+
/** Secrets that failed: revoked ones for good, others until the time given. */
|
|
3131
|
+
failed = /* @__PURE__ */ new Map();
|
|
3132
|
+
stopping = false;
|
|
3133
|
+
settle;
|
|
3134
|
+
constructor(options) {
|
|
3135
|
+
this.options = options;
|
|
3136
|
+
this.floor = options.floor ?? new Floor();
|
|
3137
|
+
}
|
|
3138
|
+
get inFlight() {
|
|
3139
|
+
let total = 0;
|
|
3140
|
+
for (const { loop } of this.running.values()) total += loop.inFlight;
|
|
3141
|
+
return total;
|
|
3142
|
+
}
|
|
3143
|
+
stop() {
|
|
3144
|
+
this.stopping = true;
|
|
3145
|
+
for (const { loop } of this.running.values()) loop.stop();
|
|
3146
|
+
this.settle?.();
|
|
3147
|
+
}
|
|
3148
|
+
abort() {
|
|
3149
|
+
this.stopping = true;
|
|
3150
|
+
for (const { loop } of this.running.values()) loop.abort();
|
|
3151
|
+
this.settle?.();
|
|
3152
|
+
}
|
|
3153
|
+
async run() {
|
|
3154
|
+
const reload = setInterval(() => this.sync(), this.options.reloadMs ?? 5e3);
|
|
3155
|
+
try {
|
|
3156
|
+
this.sync();
|
|
3157
|
+
while (!this.stopping) {
|
|
3158
|
+
const revoked = this.allRevoked();
|
|
3159
|
+
if (revoked) throw revoked;
|
|
3160
|
+
await new Promise((resolve3) => this.settle = resolve3);
|
|
3161
|
+
}
|
|
3162
|
+
await Promise.all([...this.running.values()].map(({ done }) => done));
|
|
3163
|
+
} finally {
|
|
3164
|
+
clearInterval(reload);
|
|
3165
|
+
this.settle = void 0;
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3168
|
+
/** Every configured profile is revoked and nothing runs: the error to exit with, else null. */
|
|
3169
|
+
allRevoked() {
|
|
3170
|
+
if (this.running.size > 0) return null;
|
|
3171
|
+
const profiles = this.options.readConfig()?.profiles ?? [];
|
|
3172
|
+
if (profiles.length === 0) return null;
|
|
3173
|
+
let last = null;
|
|
3174
|
+
for (const profile of profiles) {
|
|
3175
|
+
const failure = this.failed.get(profile.token);
|
|
3176
|
+
if (!failure?.revoked) return null;
|
|
3177
|
+
last = failure.revoked;
|
|
3178
|
+
}
|
|
3179
|
+
return profiles.length === 1 || !last ? last : new RunnerRevokedError(
|
|
3180
|
+
"Every organization this runner served disabled, deleted or rotated its runner. Register it again with `aictiq runner register`."
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
sync() {
|
|
3184
|
+
if (this.stopping) return;
|
|
3185
|
+
const config = this.options.readConfig();
|
|
3186
|
+
const profiles = config?.profiles ?? [];
|
|
3187
|
+
const wanted = new Map(profiles.map((profile) => [profile.token, profile]));
|
|
3188
|
+
for (const [token, running] of this.running) {
|
|
3189
|
+
if (wanted.has(token) || running.retired) continue;
|
|
3190
|
+
running.retired = true;
|
|
3191
|
+
this.options.local(
|
|
3192
|
+
`[${profileLabel(running.profile)}] Removed from runner.json; stopping after its runs finish`
|
|
3193
|
+
);
|
|
3194
|
+
running.loop.stop();
|
|
3195
|
+
}
|
|
3196
|
+
for (const profile of wanted.values()) {
|
|
3197
|
+
if (this.running.has(profile.token)) continue;
|
|
3198
|
+
const failure = this.failed.get(profile.token);
|
|
3199
|
+
if (failure && (failure.revoked || Date.now() < failure.retryAt)) continue;
|
|
3200
|
+
this.failed.delete(profile.token);
|
|
3201
|
+
this.start(profile);
|
|
3202
|
+
}
|
|
3203
|
+
this.settle?.();
|
|
3204
|
+
}
|
|
3205
|
+
start(profile) {
|
|
3206
|
+
const label = () => profileLabel(profile);
|
|
3207
|
+
const loop = this.options.createLoop(profile, this.floor, (hello) => {
|
|
3208
|
+
if (profile.organization !== hello.organizationSlug) {
|
|
3209
|
+
profile.organization = hello.organizationSlug;
|
|
3210
|
+
this.options.learned?.(profile, hello);
|
|
3211
|
+
}
|
|
3212
|
+
});
|
|
3213
|
+
const running = { profile, loop, done: Promise.resolve(), retired: false };
|
|
3214
|
+
running.done = loop.run().catch((error) => {
|
|
3215
|
+
if (error instanceof RunnerRevokedError) {
|
|
3216
|
+
this.options.local(`[${label()}] ${error.message}`);
|
|
3217
|
+
this.failed.set(profile.token, { revoked: error, retryAt: Number.POSITIVE_INFINITY });
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
const retryMs = this.options.retryMs ?? 6e4;
|
|
3221
|
+
this.options.local(
|
|
3222
|
+
`[${label()}] Stopped: ${error instanceof Error ? error.message : String(error)}; trying again in ${Math.round(retryMs / 1e3)}s`
|
|
3223
|
+
);
|
|
3224
|
+
this.failed.set(profile.token, { revoked: null, retryAt: Date.now() + retryMs });
|
|
3225
|
+
}).finally(() => {
|
|
3226
|
+
this.running.delete(profile.token);
|
|
3227
|
+
this.settle?.();
|
|
3228
|
+
});
|
|
3229
|
+
this.running.set(profile.token, running);
|
|
3230
|
+
}
|
|
3231
|
+
};
|
|
3232
|
+
|
|
2760
3233
|
// src/commands/runner.ts
|
|
2761
3234
|
function runnerCommand(globals) {
|
|
2762
3235
|
const runner = new Command7("runner").description(
|
|
2763
3236
|
"Run agent work dispatched from a Aictiq instance on this machine"
|
|
2764
3237
|
);
|
|
2765
|
-
runner.command("register").description(
|
|
3238
|
+
runner.command("register").description(
|
|
3239
|
+
"Store a runner secret (jrn_\u2026) from Factory \u2192 Runners and check it. A secret from another organization adds it to this machine"
|
|
3240
|
+
).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) => {
|
|
2766
3241
|
const global = globals();
|
|
2767
3242
|
const rawUrl = options.url ?? global.url;
|
|
2768
3243
|
if (!rawUrl) {
|
|
@@ -2779,92 +3254,150 @@ function runnerCommand(globals) {
|
|
|
2779
3254
|
);
|
|
2780
3255
|
}
|
|
2781
3256
|
const url = rawUrl.replace(/\/+$/, "");
|
|
2782
|
-
const
|
|
3257
|
+
const existing = readRunnerConfig();
|
|
3258
|
+
const machineId = existing?.machineId ?? randomUUID2();
|
|
3259
|
+
const capabilities = await probe(1, machineId);
|
|
2783
3260
|
const hello = await new RunnerClient({ baseUrl: url, token }).hello(capabilities).catch((error) => {
|
|
2784
3261
|
throw asCliError(error);
|
|
2785
3262
|
});
|
|
2786
|
-
const
|
|
2787
|
-
const
|
|
3263
|
+
const profiles = existing?.profiles ?? [];
|
|
3264
|
+
const index = await matchingProfile(profiles, url, token, hello.organizationSlug, capabilities);
|
|
3265
|
+
const previous = index >= 0 ? profiles[index] : void 0;
|
|
3266
|
+
const profile = {
|
|
2788
3267
|
url,
|
|
2789
3268
|
token,
|
|
2790
|
-
|
|
2791
|
-
// Re-registering (a rotated secret) keeps the repository map
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
3269
|
+
organization: hello.organizationSlug,
|
|
3270
|
+
// Re-registering one organization (a rotated secret) keeps the repository map
|
|
3271
|
+
// someone built up; a new organization starts with its own, empty one.
|
|
3272
|
+
workspaces: previous?.workspaces ?? {},
|
|
3273
|
+
repoRoots: previous?.repoRoots ?? []
|
|
2795
3274
|
};
|
|
2796
|
-
|
|
3275
|
+
const next = index >= 0 ? profiles.map((p, i) => i === index ? profile : p) : [...profiles, profile];
|
|
3276
|
+
const name = options.name ?? existing?.name;
|
|
3277
|
+
writeRunnerConfig({
|
|
3278
|
+
machineId,
|
|
3279
|
+
...name ? { name } : {},
|
|
3280
|
+
profiles: next,
|
|
3281
|
+
attachments: existing?.attachments ?? { maxCount: DefaultAttachmentMaxCount, maxBytes: DefaultAttachmentMaxBytes }
|
|
3282
|
+
});
|
|
2797
3283
|
if (globals().json) {
|
|
2798
|
-
printJson({
|
|
3284
|
+
printJson({
|
|
3285
|
+
url,
|
|
3286
|
+
runner: hello,
|
|
3287
|
+
capabilities,
|
|
3288
|
+
configPath: runnerConfigPath(),
|
|
3289
|
+
organizations: next.map(profileLabel)
|
|
3290
|
+
});
|
|
2799
3291
|
return;
|
|
2800
3292
|
}
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
3293
|
+
const organizations = next.map(profileLabel);
|
|
3294
|
+
const lines = [
|
|
3295
|
+
`Registered runner "${hello.name}" with ${url} (${hello.organizationSlug}).`,
|
|
3296
|
+
`Harnesses: ${capabilities.harnesses.map((h) => h.name).join(", ") || "none found on PATH"}`,
|
|
3297
|
+
`Config written to ${runnerConfigPath()}.`
|
|
3298
|
+
];
|
|
3299
|
+
if (next.length > 1) {
|
|
3300
|
+
lines.push(
|
|
3301
|
+
"",
|
|
3302
|
+
`This machine now runs for ${next.length} organizations: ${organizations.join(", ")}.`,
|
|
3303
|
+
"Runs from different organizations never execute at the same time.",
|
|
3304
|
+
`Repository roots and mappings are per organization: \`aictiq runner root <path> --org ${hello.organizationSlug}\`.`,
|
|
3305
|
+
"A running `aictiq runner start` picks this up within a few seconds; otherwise start it."
|
|
3306
|
+
);
|
|
3307
|
+
} else {
|
|
3308
|
+
lines.push("Start it with `aictiq runner start`.");
|
|
3309
|
+
}
|
|
3310
|
+
print(lines.join("\n"));
|
|
2806
3311
|
});
|
|
2807
3312
|
runner.command("map").description(
|
|
2808
3313
|
"Map a project key to a local clone, for projects whose repository source is local"
|
|
2809
|
-
).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) => {
|
|
3314
|
+
).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) => {
|
|
2810
3315
|
const config = requireConfig();
|
|
3316
|
+
const profile = selectProfile(config, options.org ?? globals().org);
|
|
2811
3317
|
const key = projectKey.toUpperCase();
|
|
2812
3318
|
if (options.remove) {
|
|
2813
|
-
delete
|
|
3319
|
+
delete profile.workspaces[key];
|
|
2814
3320
|
} else {
|
|
2815
3321
|
const absolute = resolve2(path ?? ".");
|
|
2816
3322
|
if (!existsSync3(absolute))
|
|
2817
3323
|
throw new CliError(`${absolute} does not exist.`, ExitCode.Validation);
|
|
2818
|
-
|
|
3324
|
+
profile.workspaces[key] = absolute;
|
|
2819
3325
|
}
|
|
2820
3326
|
writeRunnerConfig(config);
|
|
3327
|
+
const where = config.profiles.length > 1 ? ` (${profileLabel(profile)})` : "";
|
|
2821
3328
|
print(
|
|
2822
|
-
options.remove ? `Removed the mapping for ${key}.` : `${key} \u2192 ${
|
|
3329
|
+
options.remove ? `Removed the mapping for ${key}${where}.` : `${key} \u2192 ${profile.workspaces[key]}${where}`
|
|
2823
3330
|
);
|
|
2824
3331
|
});
|
|
2825
3332
|
runner.command("root").description(
|
|
2826
3333
|
"Trust the web UI's path hint for local projects whose clone is under this directory"
|
|
2827
|
-
).argument("[path]", "Directory that holds your clones (default: the current directory)").option("--remove", "Stop trusting path hints under this directory").action((path, options) => {
|
|
3334
|
+
).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) => {
|
|
2828
3335
|
const config = requireConfig();
|
|
3336
|
+
const profile = selectProfile(config, options.org ?? globals().org);
|
|
2829
3337
|
const absolute = resolve2(path ?? ".");
|
|
2830
3338
|
if (options.remove) {
|
|
2831
|
-
|
|
3339
|
+
profile.repoRoots = profile.repoRoots.filter((root) => resolve2(root) !== absolute);
|
|
2832
3340
|
} else {
|
|
2833
3341
|
if (!existsSync3(absolute))
|
|
2834
3342
|
throw new CliError(`${absolute} does not exist.`, ExitCode.Validation);
|
|
2835
|
-
if (!
|
|
2836
|
-
|
|
3343
|
+
if (!profile.repoRoots.some((root) => resolve2(root) === absolute))
|
|
3344
|
+
profile.repoRoots.push(absolute);
|
|
2837
3345
|
}
|
|
2838
3346
|
writeRunnerConfig(config);
|
|
3347
|
+
const whose = config.profiles.length > 1 ? `${profileLabel(profile)}'s path hints` : "Path hints";
|
|
2839
3348
|
print(
|
|
2840
|
-
options.remove ?
|
|
3349
|
+
options.remove ? `${whose} under ${absolute} are no longer used.` : `${whose} under ${absolute} are used for projects without a mapping.`
|
|
2841
3350
|
);
|
|
2842
3351
|
});
|
|
2843
|
-
runner.command("
|
|
3352
|
+
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) => {
|
|
2844
3353
|
const config = requireConfig();
|
|
2845
|
-
const
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
3354
|
+
const profile = selectProfile(config, org);
|
|
3355
|
+
config.profiles = config.profiles.filter((p) => p !== profile);
|
|
3356
|
+
writeRunnerConfig(config);
|
|
3357
|
+
print(
|
|
3358
|
+
`Removed ${profileLabel(profile)} from this machine. A running \`aictiq runner start\` stops taking its runs within a few seconds.
|
|
3359
|
+
Its secret still works until you disable or delete the runner in ${profileLabel(profile)}'s Factory \u2192 Runners.`
|
|
3360
|
+
);
|
|
3361
|
+
});
|
|
3362
|
+
runner.command("status").description("Show each registration, detected harnesses and mapped repositories").action(async () => {
|
|
3363
|
+
const config = requireConfig();
|
|
3364
|
+
const capabilities = await probe(1, config.machineId);
|
|
3365
|
+
const registrations = await Promise.all(
|
|
3366
|
+
config.profiles.map(async (profile) => {
|
|
3367
|
+
try {
|
|
3368
|
+
const hello = await new RunnerClient({ baseUrl: profile.url, token: profile.token }).hello(capabilities);
|
|
3369
|
+
return { profile, hello, text: `ok - "${hello.name}" in ${hello.organizationSlug}`, revoked: false };
|
|
3370
|
+
} catch (error) {
|
|
3371
|
+
const message3 = error instanceof Error ? error.message : String(error);
|
|
3372
|
+
return {
|
|
3373
|
+
profile,
|
|
3374
|
+
error: message3,
|
|
3375
|
+
text: `failing - ${message3}`,
|
|
3376
|
+
revoked: error instanceof RunnerHttpError && error.revoked
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
})
|
|
3380
|
+
);
|
|
3381
|
+
if (registrations.some((r) => r.revoked)) process.exitCode = ExitCode.Auth;
|
|
2859
3382
|
if (globals().json) {
|
|
2860
|
-
printJson({
|
|
3383
|
+
printJson({
|
|
3384
|
+
machineId: config.machineId,
|
|
3385
|
+
capabilities,
|
|
3386
|
+
attachments: config.attachments,
|
|
3387
|
+
profiles: registrations.map((r) => ({
|
|
3388
|
+
url: r.profile.url,
|
|
3389
|
+
organization: r.hello?.organizationSlug ?? r.profile.organization ?? null,
|
|
3390
|
+
registration: r.hello ?? { error: r.error },
|
|
3391
|
+
workspaces: r.profile.workspaces,
|
|
3392
|
+
repoRoots: r.profile.repoRoots
|
|
3393
|
+
}))
|
|
3394
|
+
});
|
|
2861
3395
|
return;
|
|
2862
3396
|
}
|
|
2863
3397
|
print(
|
|
2864
3398
|
renderFields([
|
|
2865
|
-
["Instance", config.url],
|
|
2866
|
-
["Registration", registrationText],
|
|
2867
3399
|
["Config", runnerConfigPath()],
|
|
3400
|
+
["Machine", config.machineId],
|
|
2868
3401
|
["Workspaces", defaultWorkspaceRoot()],
|
|
2869
3402
|
["Attachments", `${config.attachments.maxCount} files / ${formatBytes2(config.attachments.maxBytes)} per run`],
|
|
2870
3403
|
["CLI", `${version} (${capabilities.os}/${capabilities.arch})`]
|
|
@@ -2877,52 +3410,80 @@ Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
|
|
|
2877
3410
|
{ header: "VERSION", value: (h) => h.version ?? "" }
|
|
2878
3411
|
])
|
|
2879
3412
|
);
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
3413
|
+
for (const { profile, hello, text } of registrations) {
|
|
3414
|
+
const org = hello?.organizationSlug ?? profileLabel(profile);
|
|
3415
|
+
print("");
|
|
3416
|
+
print(
|
|
3417
|
+
renderFields([
|
|
3418
|
+
["Organization", org],
|
|
3419
|
+
["Instance", profile.url],
|
|
3420
|
+
["Registration", text]
|
|
3421
|
+
])
|
|
3422
|
+
);
|
|
3423
|
+
const mapped = Object.entries(profile.workspaces);
|
|
3424
|
+
print(
|
|
3425
|
+
mapped.length === 0 ? `No local repositories mapped (\`aictiq runner map <KEY> <path> --org ${org}\`).` : renderTable(mapped, [
|
|
3426
|
+
{ header: "PROJECT", value: ([key]) => key },
|
|
3427
|
+
{ header: "REPOSITORY", value: ([, path]) => path }
|
|
3428
|
+
])
|
|
3429
|
+
);
|
|
3430
|
+
print(
|
|
3431
|
+
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 }])
|
|
3432
|
+
);
|
|
3433
|
+
}
|
|
2892
3434
|
});
|
|
2893
|
-
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(
|
|
3435
|
+
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(
|
|
2894
3436
|
async (options) => {
|
|
2895
3437
|
const config = requireConfig();
|
|
2896
3438
|
const parallel = Number.parseInt(options.parallel, 10);
|
|
2897
3439
|
if (!Number.isInteger(parallel) || parallel < 1 || parallel > 16) {
|
|
2898
3440
|
throw new CliError("--parallel is between 1 and 16.", ExitCode.Validation);
|
|
2899
3441
|
}
|
|
2900
|
-
|
|
2901
|
-
const
|
|
3442
|
+
writeRunnerConfig(config);
|
|
3443
|
+
const log = (message3) => process.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${message3}
|
|
2902
3444
|
`);
|
|
2903
3445
|
const workspaceRoot = resolve2(options.workspaceRoot ?? defaultWorkspaceRoot());
|
|
2904
|
-
const
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
|
|
3446
|
+
const supervisor = new RunnerSupervisor({
|
|
3447
|
+
readConfig: () => readRunnerConfig(),
|
|
3448
|
+
local: log,
|
|
3449
|
+
learned: (profile) => {
|
|
3450
|
+
const current = readRunnerConfig();
|
|
3451
|
+
const stored = current?.profiles.find((p) => p.token === profile.token);
|
|
3452
|
+
if (!current || !stored || stored.organization === profile.organization) return;
|
|
3453
|
+
stored.organization = profile.organization;
|
|
3454
|
+
writeRunnerConfig(current);
|
|
3455
|
+
},
|
|
3456
|
+
createLoop: (profile, floor, onHello) => {
|
|
3457
|
+
const client = new RunnerClient({ baseUrl: profile.url, token: profile.token });
|
|
3458
|
+
const local = (message3) => log(`[${profileLabel(profile)}] ${message3}`);
|
|
3459
|
+
return new RunnerLoop({
|
|
2912
3460
|
client,
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
runnerToken: config.token,
|
|
2916
|
-
shutdown,
|
|
3461
|
+
parallel,
|
|
3462
|
+
probe: () => probe(parallel, config.machineId),
|
|
2917
3463
|
local,
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
3464
|
+
floor,
|
|
3465
|
+
floorKey: profileKey(profile),
|
|
3466
|
+
onHello,
|
|
3467
|
+
execute: (run, hello, shutdown) => {
|
|
3468
|
+
const current = readRunnerConfig();
|
|
3469
|
+
const own = current?.profiles.find((p) => p.token === profile.token) ?? profile;
|
|
3470
|
+
return executeRun(run, {
|
|
3471
|
+
client,
|
|
3472
|
+
hello,
|
|
3473
|
+
adapters: harnesses,
|
|
3474
|
+
runnerToken: profile.token,
|
|
3475
|
+
shutdown,
|
|
3476
|
+
local,
|
|
3477
|
+
workspace: {
|
|
3478
|
+
root: workspaceRoot,
|
|
3479
|
+
repositories: own.workspaces,
|
|
3480
|
+
repoRoots: own.repoRoots,
|
|
3481
|
+
keep: options.keepWorkspaces === true,
|
|
3482
|
+
mcpServer: mcpServerCommand(),
|
|
3483
|
+
attachmentMaxCount: (current ?? config).attachments.maxCount,
|
|
3484
|
+
attachmentMaxBytes: (current ?? config).attachments.maxBytes
|
|
3485
|
+
}
|
|
3486
|
+
});
|
|
2926
3487
|
}
|
|
2927
3488
|
});
|
|
2928
3489
|
}
|
|
@@ -2930,22 +3491,22 @@ Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
|
|
|
2930
3491
|
let interrupts = 0;
|
|
2931
3492
|
const onSignal = (signal2) => {
|
|
2932
3493
|
interrupts++;
|
|
2933
|
-
if (interrupts === 1 &&
|
|
2934
|
-
|
|
2935
|
-
`${signal2}: finishing ${
|
|
3494
|
+
if (interrupts === 1 && supervisor.inFlight > 0) {
|
|
3495
|
+
log(
|
|
3496
|
+
`${signal2}: finishing ${supervisor.inFlight} run(s) in flight; send it again to cancel them`
|
|
2936
3497
|
);
|
|
2937
|
-
|
|
3498
|
+
supervisor.stop();
|
|
2938
3499
|
} else if (interrupts === 1) {
|
|
2939
|
-
|
|
3500
|
+
supervisor.stop();
|
|
2940
3501
|
} else {
|
|
2941
|
-
|
|
2942
|
-
|
|
3502
|
+
log(`${signal2}: cancelling runs in flight`);
|
|
3503
|
+
supervisor.abort();
|
|
2943
3504
|
}
|
|
2944
3505
|
};
|
|
2945
3506
|
process.on("SIGINT", onSignal);
|
|
2946
3507
|
process.on("SIGTERM", onSignal);
|
|
2947
3508
|
try {
|
|
2948
|
-
await
|
|
3509
|
+
await supervisor.run();
|
|
2949
3510
|
} catch (error) {
|
|
2950
3511
|
throw asCliError(error);
|
|
2951
3512
|
} finally {
|
|
@@ -2954,50 +3515,72 @@ Config written to ${runnerConfigPath()}. Start it with \`aictiq runner start\`.`
|
|
|
2954
3515
|
}
|
|
2955
3516
|
}
|
|
2956
3517
|
);
|
|
2957
|
-
runner.command("install-service").description(
|
|
2958
|
-
|
|
3518
|
+
runner.command("install-service").description(
|
|
3519
|
+
"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)"
|
|
3520
|
+
).option("--parallel <n>", "Runs executed at the same time", "1").option("--platform <platform>", "linux, macos or windows (default: this machine)").action((options) => {
|
|
3521
|
+
const platform2 = options.platform ?? servicePlatform();
|
|
3522
|
+
if (!isServicePlatform(platform2)) {
|
|
3523
|
+
throw new CliError(
|
|
3524
|
+
`Unknown platform "${platform2}". Use linux, macos or windows.`,
|
|
3525
|
+
ExitCode.Validation
|
|
3526
|
+
);
|
|
3527
|
+
}
|
|
3528
|
+
print(
|
|
3529
|
+
serviceDefinition(platform2, {
|
|
3530
|
+
node: process.execPath,
|
|
3531
|
+
entry: cliEntry(),
|
|
3532
|
+
parallel: options.parallel,
|
|
3533
|
+
path: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
|
|
3534
|
+
home: homedir4()
|
|
3535
|
+
})
|
|
3536
|
+
);
|
|
2959
3537
|
});
|
|
2960
3538
|
return runner;
|
|
2961
3539
|
}
|
|
2962
|
-
function
|
|
2963
|
-
return `# Save as ~/.config/systemd/user/aictiq-runner.service, then:
|
|
2964
|
-
# systemctl --user daemon-reload
|
|
2965
|
-
# systemctl --user enable --now aictiq-runner
|
|
2966
|
-
# loginctl enable-linger "$USER" # keep it running after you log out
|
|
2967
|
-
#
|
|
2968
|
-
# The runner executes agents as this user, with this user's harness sign-ins and git
|
|
2969
|
-
# credentials: one runner is one trust domain. SIGTERM lets runs in flight finish;
|
|
2970
|
-
# TimeoutStopSec bounds how long systemd waits before it cancels them.
|
|
2971
|
-
[Unit]
|
|
2972
|
-
Description=Aictiq runner
|
|
2973
|
-
After=network-online.target
|
|
2974
|
-
Wants=network-online.target
|
|
2975
|
-
|
|
2976
|
-
[Service]
|
|
2977
|
-
Type=simple
|
|
2978
|
-
ExecStart=${node} ${entry} runner start --parallel ${parallel}
|
|
2979
|
-
Restart=on-failure
|
|
2980
|
-
RestartSec=10
|
|
2981
|
-
# Exit 5 means the secret was revoked; restarting cannot fix that.
|
|
2982
|
-
RestartPreventExitStatus=5
|
|
2983
|
-
KillMode=mixed
|
|
2984
|
-
TimeoutStopSec=15min
|
|
2985
|
-
Environment=PATH=${process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin"}
|
|
2986
|
-
|
|
2987
|
-
[Install]
|
|
2988
|
-
WantedBy=default.target
|
|
2989
|
-
`;
|
|
2990
|
-
}
|
|
2991
|
-
async function probe(maxParallel) {
|
|
3540
|
+
async function probe(maxParallel, machineId) {
|
|
2992
3541
|
return {
|
|
2993
3542
|
v: 1,
|
|
2994
3543
|
harnesses: await probeHarnesses(),
|
|
2995
3544
|
os: platform(),
|
|
2996
3545
|
arch: arch(),
|
|
2997
3546
|
cliVersion: version,
|
|
2998
|
-
maxParallel
|
|
3547
|
+
maxParallel,
|
|
3548
|
+
machineId
|
|
2999
3549
|
};
|
|
3000
3550
|
}
|
|
3551
|
+
async function matchingProfile(profiles, url, token, organization, capabilities) {
|
|
3552
|
+
const exact = profiles.findIndex(
|
|
3553
|
+
(p) => p.token === token || sameInstance(p.url, url) && p.organization === organization
|
|
3554
|
+
);
|
|
3555
|
+
if (exact >= 0) return exact;
|
|
3556
|
+
for (const [index, profile] of profiles.entries()) {
|
|
3557
|
+
if (profile.organization || !sameInstance(profile.url, url)) continue;
|
|
3558
|
+
try {
|
|
3559
|
+
const hello = await new RunnerClient({ baseUrl: profile.url, token: profile.token }).hello(capabilities);
|
|
3560
|
+
profile.organization = hello.organizationSlug;
|
|
3561
|
+
if (hello.organizationSlug === organization) return index;
|
|
3562
|
+
} catch (error) {
|
|
3563
|
+
if (error instanceof RunnerHttpError && error.revoked && profiles.length === 1) return index;
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
return -1;
|
|
3567
|
+
}
|
|
3568
|
+
function selectProfile(config, org) {
|
|
3569
|
+
const serves = config.profiles.map(profileLabel).join(", ");
|
|
3570
|
+
if (org) {
|
|
3571
|
+
const wanted = org.toLowerCase();
|
|
3572
|
+
const found = config.profiles.find((p) => profileLabel(p).toLowerCase() === wanted);
|
|
3573
|
+
if (!found) {
|
|
3574
|
+
throw new CliError(`This machine does not run for ${org}. It runs for: ${serves}.`, ExitCode.Validation);
|
|
3575
|
+
}
|
|
3576
|
+
return found;
|
|
3577
|
+
}
|
|
3578
|
+
if (config.profiles.length === 1) return config.profiles[0];
|
|
3579
|
+
throw new CliError(
|
|
3580
|
+
`This machine runs for several organizations (${serves}). Say which with --org <slug>.`,
|
|
3581
|
+
ExitCode.Validation
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3001
3584
|
function cliEntry() {
|
|
3002
3585
|
return resolve2(process.argv[1] ?? "aictiq");
|
|
3003
3586
|
}
|
|
@@ -3009,7 +3592,7 @@ function formatBytes2(value) {
|
|
|
3009
3592
|
}
|
|
3010
3593
|
function requireConfig() {
|
|
3011
3594
|
const config = readRunnerConfig();
|
|
3012
|
-
if (!config) {
|
|
3595
|
+
if (!config || config.profiles.length === 0) {
|
|
3013
3596
|
throw new CliError(
|
|
3014
3597
|
`This machine is not registered as a runner (${runnerConfigPath()}). Run \`aictiq runner register --url <url> --token <jrn_\u2026>\`.`,
|
|
3015
3598
|
ExitCode.Auth
|
|
@@ -3027,6 +3610,9 @@ function asCliError(error) {
|
|
|
3027
3610
|
}
|
|
3028
3611
|
return error;
|
|
3029
3612
|
}
|
|
3613
|
+
function isServicePlatform(value) {
|
|
3614
|
+
return value === "linux" || value === "macos" || value === "windows";
|
|
3615
|
+
}
|
|
3030
3616
|
|
|
3031
3617
|
// src/commands/sprint.ts
|
|
3032
3618
|
import { Command as Command8 } from "commander";
|