@refleet-it/runner 0.1.186
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/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/backend-client.js +76 -0
- package/dist/backends/claude.js +142 -0
- package/dist/backends/kiro.js +224 -0
- package/dist/backends/types.js +2 -0
- package/dist/cli.js +165 -0
- package/dist/config.js +57 -0
- package/dist/detect.js +71 -0
- package/dist/fleet/api.js +117 -0
- package/dist/fleet/execute.js +33 -0
- package/dist/fleet/git.js +34 -0
- package/dist/fleet/gitlab.js +66 -0
- package/dist/fleet/job.js +108 -0
- package/dist/fleet/log.js +4 -0
- package/dist/fleet/loop.js +128 -0
- package/dist/fleet/workspace.js +124 -0
- package/dist/prompt.js +110 -0
- package/dist/run.js +77 -0
- package/package.json +51 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { hostname } from 'node:os';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { clearStoredConfig, loadStoredConfig, resolveFleetConfig, saveStoredConfig } from './config.js';
|
|
6
|
+
import { createApiKey, listApiKeys, login, revokeApiKey } from './backend-client.js';
|
|
7
|
+
import { FleetStartupError, fleetOptionsFromEnv, runFleet } from './fleet/loop.js';
|
|
8
|
+
import { createPrompter } from './prompt.js';
|
|
9
|
+
const DEFAULT_API_URL = 'http://localhost/api';
|
|
10
|
+
function errorMessage(err) {
|
|
11
|
+
return err instanceof Error ? err.message : String(err);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Interactive login: email+password against /identity/login, then mints a fresh
|
|
15
|
+
* long-lived API key via /identity/api-keys and saves it locally — the "CLI mode"
|
|
16
|
+
* counterpart to setting REFLEET_API_URL/REFLEET_API_KEY by hand for automatic/cloud
|
|
17
|
+
* mode. Revokes a previous local login's key first (best-effort) so re-running
|
|
18
|
+
* `login` doesn't litter the account's key list every time.
|
|
19
|
+
*/
|
|
20
|
+
async function runLogin() {
|
|
21
|
+
const existing = loadStoredConfig();
|
|
22
|
+
const prompter = createPrompter();
|
|
23
|
+
let apiUrl, email, password;
|
|
24
|
+
try {
|
|
25
|
+
const apiUrlInput = await prompter.question(`API URL [${DEFAULT_API_URL}]: `);
|
|
26
|
+
apiUrl = '' === apiUrlInput ? DEFAULT_API_URL : apiUrlInput;
|
|
27
|
+
email = await prompter.question('Email: ');
|
|
28
|
+
password = await prompter.questionHidden('Password: ');
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
prompter.close();
|
|
32
|
+
}
|
|
33
|
+
const { jwtToken } = await login(apiUrl, email, password);
|
|
34
|
+
const created = await createApiKey(apiUrl, jwtToken, `refleet-runner (${hostname()})`);
|
|
35
|
+
if (existing) {
|
|
36
|
+
try {
|
|
37
|
+
await revokeApiKey(existing.apiUrl, existing.apiKey, existing.apiKeyId);
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
process.stderr.write(`warning: failed to revoke the previous local API key: ${errorMessage(err)}\n`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
saveStoredConfig({
|
|
44
|
+
apiUrl,
|
|
45
|
+
apiKey: created.token,
|
|
46
|
+
apiKeyId: created.id,
|
|
47
|
+
accountEmail: email,
|
|
48
|
+
createdAt: created.createdAt,
|
|
49
|
+
});
|
|
50
|
+
process.stdout.write(`Logged in as ${email}. API key ${created.prefix}… saved to your local config.\n`);
|
|
51
|
+
process.stdout.write("Run 'refleet-runner run' to start the runner.\n");
|
|
52
|
+
}
|
|
53
|
+
/** Revokes the locally saved key on the backend (best-effort) and clears the local config. */
|
|
54
|
+
async function runLogout() {
|
|
55
|
+
const existing = loadStoredConfig();
|
|
56
|
+
if (!existing) {
|
|
57
|
+
process.stdout.write('Not logged in.\n');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await revokeApiKey(existing.apiUrl, existing.apiKey, existing.apiKeyId);
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
process.stderr.write(`warning: failed to revoke the API key on the backend: ${errorMessage(err)}\n`);
|
|
65
|
+
}
|
|
66
|
+
clearStoredConfig();
|
|
67
|
+
process.stdout.write('Logged out.\n');
|
|
68
|
+
}
|
|
69
|
+
/** Shows which credentials are currently effective and whether they still work. */
|
|
70
|
+
async function runWhoami() {
|
|
71
|
+
const resolved = resolveFleetConfig(process.env);
|
|
72
|
+
if (!resolved) {
|
|
73
|
+
process.stdout.write("Not logged in, and REFLEET_API_URL/REFLEET_API_KEY aren't set. Run 'refleet-runner login'.\n");
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const sourceLabel = 'env' === resolved.source ? 'environment variables (automatic mode)' : 'refleet-runner login';
|
|
78
|
+
process.stdout.write(`API URL: ${resolved.apiUrl}\n`);
|
|
79
|
+
process.stdout.write(`API key: ${resolved.apiKey.slice(0, 12)}…\n`);
|
|
80
|
+
process.stdout.write(`Source: ${sourceLabel}\n`);
|
|
81
|
+
try {
|
|
82
|
+
const keys = await listApiKeys(resolved.apiUrl, resolved.apiKey);
|
|
83
|
+
process.stdout.write(`Connected (${String(keys.length)} API key(s) on this account).\n`);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
process.stdout.write(`Could not reach the backend, or this key is no longer valid: ${errorMessage(err)}\n`);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The fleet loop itself: REFLEET_API_URL/REFLEET_API_KEY (automatic/cloud mode: Docker,
|
|
92
|
+
* CI, any headless deployment) win over a locally saved `login`; without either there
|
|
93
|
+
* is no idle mode — the process exits so a misconfigured runner is noticed, not left
|
|
94
|
+
* silently waiting. SIGINT/SIGTERM finish the job in progress and then return.
|
|
95
|
+
*/
|
|
96
|
+
async function runRunner() {
|
|
97
|
+
const credentials = resolveFleetConfig(process.env);
|
|
98
|
+
if (!credentials) {
|
|
99
|
+
throw new Error("REFLEET_API_URL and REFLEET_API_KEY are both required — this runner only supports fleet mode. Run 'refleet-runner login' (or set them directly) first.");
|
|
100
|
+
}
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const stop = () => {
|
|
103
|
+
process.stdout.write('refleet-runner: shutting down after the current job\n');
|
|
104
|
+
controller.abort();
|
|
105
|
+
};
|
|
106
|
+
process.once('SIGINT', stop);
|
|
107
|
+
process.once('SIGTERM', stop);
|
|
108
|
+
try {
|
|
109
|
+
await runFleet(fleetOptionsFromEnv(process.env, credentials), controller.signal);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (err instanceof FleetStartupError) {
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
// Reached only through an unexpected exception inside a poll loop: exit non-zero so
|
|
116
|
+
// the supervisor (Docker restart policy, systemd) restarts the whole runner.
|
|
117
|
+
process.stderr.write(`refleet-runner: fleet loop died: ${errorMessage(err)}\n`);
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function main() {
|
|
122
|
+
const command = process.argv[2];
|
|
123
|
+
switch (command) {
|
|
124
|
+
case 'login':
|
|
125
|
+
await runLogin();
|
|
126
|
+
return;
|
|
127
|
+
case 'logout':
|
|
128
|
+
await runLogout();
|
|
129
|
+
return;
|
|
130
|
+
case 'whoami':
|
|
131
|
+
await runWhoami();
|
|
132
|
+
return;
|
|
133
|
+
case 'run':
|
|
134
|
+
await runRunner();
|
|
135
|
+
return;
|
|
136
|
+
default:
|
|
137
|
+
process.stderr.write(`refleet-runner: unknown command ${JSON.stringify(command ?? '')}\n`);
|
|
138
|
+
process.stderr.write('Usage: refleet-runner <login|logout|whoami|run>\n');
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* npm links bin entries as symlinks, so argv[1] is node_modules/.bin/refleet-runner while
|
|
144
|
+
* import.meta.url is the resolved dist/cli.js — comparing them raw made the installed CLI a
|
|
145
|
+
* silent no-op. Resolve the symlink, and build the URL rather than concatenating it so paths
|
|
146
|
+
* with spaces compare correctly too.
|
|
147
|
+
*/
|
|
148
|
+
function isMainModule() {
|
|
149
|
+
const entry = process.argv[1];
|
|
150
|
+
if (undefined === entry) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (isMainModule()) {
|
|
161
|
+
main().catch((err) => {
|
|
162
|
+
process.stderr.write(`refleet-runner: ${errorMessage(err)}\n`);
|
|
163
|
+
process.exitCode = 1;
|
|
164
|
+
});
|
|
165
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Local login credentials saved by `refleet-runner login`, read back by `whoami`/
|
|
6
|
+
* `logout` and used by `run` when REFLEET_API_URL/REFLEET_API_KEY aren't set.
|
|
7
|
+
* Lives under XDG_CONFIG_HOME (falling back to ~/.config, the convention
|
|
8
|
+
* most CLI tools on the host already follow) rather than inside this repo, since
|
|
9
|
+
* it's per-machine/per-person, not project state. `baseDir` overrides the whole
|
|
10
|
+
* resolution for tests, same DI shape as detectAgents' injectable `isExecutable`.
|
|
11
|
+
*/
|
|
12
|
+
function configDir(baseDir) {
|
|
13
|
+
if (baseDir) {
|
|
14
|
+
return baseDir;
|
|
15
|
+
}
|
|
16
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
17
|
+
const base = xdgConfigHome && '' !== xdgConfigHome ? xdgConfigHome : join(homedir(), '.config');
|
|
18
|
+
return join(base, 'refleet');
|
|
19
|
+
}
|
|
20
|
+
function configPath(baseDir) {
|
|
21
|
+
return join(configDir(baseDir), 'runner.json');
|
|
22
|
+
}
|
|
23
|
+
export function loadStoredConfig(baseDir) {
|
|
24
|
+
const path = configPath(baseDir);
|
|
25
|
+
if (!existsSync(path)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
29
|
+
}
|
|
30
|
+
export function saveStoredConfig(config, baseDir) {
|
|
31
|
+
mkdirSync(configDir(baseDir), { recursive: true, mode: 0o700 });
|
|
32
|
+
writeFileSync(configPath(baseDir), JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
33
|
+
}
|
|
34
|
+
export function clearStoredConfig(baseDir) {
|
|
35
|
+
const path = configPath(baseDir);
|
|
36
|
+
if (existsSync(path)) {
|
|
37
|
+
rmSync(path);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* REFLEET_API_URL/REFLEET_API_KEY (automatic/cloud mode: Docker, CI, any headless
|
|
42
|
+
* deployment) always win over a locally saved `login`; the stored file is only
|
|
43
|
+
* consulted when both env vars are absent. Returns null when neither source has
|
|
44
|
+
* credentials.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveFleetConfig(env, baseDir) {
|
|
47
|
+
const envUrl = env.REFLEET_API_URL;
|
|
48
|
+
const envKey = env.REFLEET_API_KEY;
|
|
49
|
+
if (envUrl && envKey) {
|
|
50
|
+
return { apiUrl: envUrl, apiKey: envKey, source: 'env' };
|
|
51
|
+
}
|
|
52
|
+
const stored = loadStoredConfig(baseDir);
|
|
53
|
+
if (stored) {
|
|
54
|
+
return { apiUrl: stored.apiUrl, apiKey: stored.apiKey, source: 'file' };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { accessSync, constants } from 'node:fs';
|
|
2
|
+
import { delimiter, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* kiro-cli is Kiro's CLI binary name; claude is the Claude Code CLI. Scoped to these
|
|
5
|
+
* two agents for now (see the Runner redesign plan) — extending to more agents means
|
|
6
|
+
* adding a spec here, nothing else.
|
|
7
|
+
*/
|
|
8
|
+
const AGENT_SPECS = [
|
|
9
|
+
{ engine: 'claude', defaultCommand: 'claude', pathEnvVar: 'REFLEET_CLAUDE_PATH' },
|
|
10
|
+
{ engine: 'kiro', defaultCommand: 'kiro-cli', pathEnvVar: 'REFLEET_KIRO_PATH' },
|
|
11
|
+
];
|
|
12
|
+
export function defaultIsExecutable(path) {
|
|
13
|
+
try {
|
|
14
|
+
accessSync(path, constants.X_OK);
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolves a command to an executable path the same way a Unix shell would: if it
|
|
23
|
+
* already contains a path separator, it's used as-is (only checked for
|
|
24
|
+
* executability); otherwise every directory on PATH is scanned in order. Returns null
|
|
25
|
+
* when nothing executable is found — the caller treats that engine as not installed.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveExecutable(command, env, isExecutable = defaultIsExecutable) {
|
|
28
|
+
const trimmed = command.trim();
|
|
29
|
+
if ('' === trimmed) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
if (trimmed.includes('/')) {
|
|
33
|
+
return isExecutable(trimmed) ? trimmed : null;
|
|
34
|
+
}
|
|
35
|
+
const pathEnv = env.PATH ?? '';
|
|
36
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
37
|
+
if ('' === dir) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const candidate = join(dir, trimmed);
|
|
41
|
+
if (isExecutable(candidate)) {
|
|
42
|
+
return candidate;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Detects which of the supported agent CLIs are available on this host — the
|
|
49
|
+
* auto-detection this whole runner redesign is for. Called once when `refleet-runner
|
|
50
|
+
* run` starts (see fleet/loop.ts); a CLI installed after that requires a restart, not
|
|
51
|
+
* a live re-probe.
|
|
52
|
+
*/
|
|
53
|
+
export function detectAgents(env = process.env, isExecutable = defaultIsExecutable) {
|
|
54
|
+
const detected = [];
|
|
55
|
+
for (const spec of AGENT_SPECS) {
|
|
56
|
+
const override = env[spec.pathEnvVar];
|
|
57
|
+
const command = override && '' !== override.trim() ? override : spec.defaultCommand;
|
|
58
|
+
const path = resolveExecutable(command, env, isExecutable);
|
|
59
|
+
if (null !== path) {
|
|
60
|
+
detected.push({ engine: spec.engine, path });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return detected;
|
|
64
|
+
}
|
|
65
|
+
function isMainModule() {
|
|
66
|
+
return undefined !== process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
|
67
|
+
}
|
|
68
|
+
if (isMainModule()) {
|
|
69
|
+
const agents = detectAgents();
|
|
70
|
+
process.stdout.write(agents.map(a => a.engine).join(' '));
|
|
71
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
function withList(key, values) {
|
|
2
|
+
return values && values.length > 0 ? { [key]: values } : {};
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* The runner-facing slice of the Refleet API. Nothing here throws on a network error:
|
|
6
|
+
* a transient outage must skip one poll cycle, not kill the loop.
|
|
7
|
+
*/
|
|
8
|
+
export class FleetApi {
|
|
9
|
+
apiKey;
|
|
10
|
+
log;
|
|
11
|
+
fetchFn;
|
|
12
|
+
baseUrl;
|
|
13
|
+
constructor(apiUrl, apiKey, log, fetchFn = fetch) {
|
|
14
|
+
this.apiKey = apiKey;
|
|
15
|
+
this.log = log;
|
|
16
|
+
this.fetchFn = fetchFn;
|
|
17
|
+
this.baseUrl = apiUrl.replace(/\/+$/, '');
|
|
18
|
+
}
|
|
19
|
+
async request(method, path, body) {
|
|
20
|
+
try {
|
|
21
|
+
const response = await this.fetchFn(`${this.baseUrl}${path}`, {
|
|
22
|
+
method,
|
|
23
|
+
headers: {
|
|
24
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
},
|
|
27
|
+
body: undefined === body ? undefined : JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
return { status: response.status, body: await response.text() };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return { status: 0, body: '' };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async heartbeat(identity) {
|
|
36
|
+
const body = {
|
|
37
|
+
name: identity.name,
|
|
38
|
+
...withList('supportedEngines', identity.supportedEngines),
|
|
39
|
+
...withList('supportedModels', identity.supportedModels),
|
|
40
|
+
};
|
|
41
|
+
const response = await this.request('POST', '/runner/heartbeat', body);
|
|
42
|
+
if (!isSuccess(response.status)) {
|
|
43
|
+
this.log(`heartbeat request failed (status ${String(response.status)})`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Returns null when no job is available (204) or the claim failed for any reason. */
|
|
47
|
+
async claimJob(identity) {
|
|
48
|
+
const body = {
|
|
49
|
+
runnerId: identity.name,
|
|
50
|
+
...withList('supportedKinds', identity.supportedKinds),
|
|
51
|
+
...withList('supportedModes', identity.supportedModes),
|
|
52
|
+
...withList('supportedEngines', identity.supportedEngines),
|
|
53
|
+
};
|
|
54
|
+
const response = await this.request('POST', '/runner/jobs/claim', body);
|
|
55
|
+
if (200 !== response.status) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
const claimed = JSON.parse(response.body);
|
|
60
|
+
return claimed.jobId ? claimed : null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async reportJob(identity, jobId, report) {
|
|
67
|
+
const body = {
|
|
68
|
+
runnerId: identity.name,
|
|
69
|
+
outcome: report.outcome,
|
|
70
|
+
summary: report.summary,
|
|
71
|
+
...(report.errorMessage ? { errorMessage: report.errorMessage } : {}),
|
|
72
|
+
...(undefined !== report.qualified ? { qualified: report.qualified } : {}),
|
|
73
|
+
...(report.branchName ? { branchName: report.branchName } : {}),
|
|
74
|
+
};
|
|
75
|
+
const response = await this.request('POST', `/runner/jobs/${jobId}/report`, body);
|
|
76
|
+
if (!isSuccess(response.status)) {
|
|
77
|
+
this.log(`report failed for job ${jobId} (status ${String(response.status)})`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reports the real GitLab merge request URL once one has been opened (see
|
|
82
|
+
* ReportShiftMergeRequestStatusController), so the shift detail page can link to
|
|
83
|
+
* it instead of showing a bare "Open" status.
|
|
84
|
+
*/
|
|
85
|
+
async reportMergeRequestOpened(shiftId, shiftTargetId, url) {
|
|
86
|
+
const response = await this.request('POST', `/shifts/${shiftId}/targets/${shiftTargetId}/merge-request`, {
|
|
87
|
+
status: 'opened',
|
|
88
|
+
url,
|
|
89
|
+
});
|
|
90
|
+
if (!isSuccess(response.status)) {
|
|
91
|
+
this.log(`failed to report merge request URL for target ${shiftTargetId} (status ${String(response.status)})`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Fetched per job rather than once at startup so a reconnected/rotated GitLab
|
|
96
|
+
* token is picked up without restarting the runner.
|
|
97
|
+
*/
|
|
98
|
+
async fetchGitLabCredentials() {
|
|
99
|
+
const response = await this.request('GET', '/runner/gitlab-credentials');
|
|
100
|
+
if (200 !== response.status) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const body = JSON.parse(response.body);
|
|
105
|
+
if (!body.baseUrl || !body.accessToken) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
return { baseUrl: body.baseUrl, accessToken: body.accessToken };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function isSuccess(status) {
|
|
116
|
+
return status >= 200 && status < 300;
|
|
117
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { detectAgents } from '../detect.js';
|
|
2
|
+
import { ClaudeBackend } from '../backends/claude.js';
|
|
3
|
+
import { KiroBackend } from '../backends/kiro.js';
|
|
4
|
+
import { AgentExecutionError } from '../backends/types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Re-runs the same PATH-based detection the fleet loop used to decide which engines to
|
|
7
|
+
* register, so a job can never make this host execute a caller-supplied path — only an
|
|
8
|
+
* engine it actually has installed.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveBackend(engine) {
|
|
11
|
+
const detected = detectAgents().find(agent => agent.engine === engine);
|
|
12
|
+
if (!detected) {
|
|
13
|
+
throw new AgentExecutionError(`engine "${engine}" was not detected on this host`);
|
|
14
|
+
}
|
|
15
|
+
return 'claude' === engine ? new ClaudeBackend(detected.path) : new KiroBackend(detected.path);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Extracts the prompt/engine/model an AI-mode job carries (see
|
|
19
|
+
* StartQualificationHandler/StartShiftChangeHandler's buildPayload) and hands it to
|
|
20
|
+
* the matching backend, which spawns the CLI and speaks its native protocol.
|
|
21
|
+
*/
|
|
22
|
+
export const executeJob = async (kind, payload, repoDir) => {
|
|
23
|
+
const engine = payload.engine ?? 'claude';
|
|
24
|
+
if ('claude' !== engine && 'kiro' !== engine) {
|
|
25
|
+
throw new AgentExecutionError(`unsupported engine "${engine}"`);
|
|
26
|
+
}
|
|
27
|
+
const prompt = (payload.prompt ?? '').trim();
|
|
28
|
+
if ('' === prompt) {
|
|
29
|
+
throw new AgentExecutionError('job payload has no prompt');
|
|
30
|
+
}
|
|
31
|
+
const result = await resolveBackend(engine).execute(prompt, { cwd: repoDir, kind, model: payload.model });
|
|
32
|
+
return result.output;
|
|
33
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
export class GitError extends Error {
|
|
3
|
+
args;
|
|
4
|
+
exitCode;
|
|
5
|
+
stderr;
|
|
6
|
+
constructor(args, exitCode, stderr) {
|
|
7
|
+
super(`git ${args.join(' ')} exited with ${String(exitCode)}: ${stderr.trim()}`);
|
|
8
|
+
this.args = args;
|
|
9
|
+
this.exitCode = exitCode;
|
|
10
|
+
this.stderr = stderr;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* GitLab's git-http backend (unlike its REST API) does not accept the PRIVATE-TOKEN
|
|
15
|
+
* header — it only honors HTTP Basic auth, so the token is sent as a Basic credential
|
|
16
|
+
* (any non-empty username works). Passed per invocation as `-c http.extraHeader` so it
|
|
17
|
+
* is never written into the cached checkout's git config.
|
|
18
|
+
*/
|
|
19
|
+
export function gitAuthConfig(accessToken) {
|
|
20
|
+
const basic = Buffer.from(`oauth2:${accessToken}`).toString('base64');
|
|
21
|
+
return ['-c', `http.extraHeader=Authorization: Basic ${basic}`];
|
|
22
|
+
}
|
|
23
|
+
export function createGitRunner(execFileFn = execFile) {
|
|
24
|
+
return (args, cwd) => new Promise((resolve, reject) => {
|
|
25
|
+
execFileFn('git', args, { cwd, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }, (error, stdout, stderr) => {
|
|
26
|
+
if (error) {
|
|
27
|
+
const code = 'code' in error && 'number' === typeof error.code ? error.code : null;
|
|
28
|
+
reject(new GitError(args, code, String(stderr)));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
resolve({ stdout: String(stdout), stderr: String(stderr) });
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export class GitLabRequestError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
constructor(message, status) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.status = status;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function apiBase(credentials) {
|
|
9
|
+
return `${credentials.baseUrl.replace(/\/+$/, '')}/api/v4`;
|
|
10
|
+
}
|
|
11
|
+
async function gitlabRequest(credentials, fetchFn, method, path, body) {
|
|
12
|
+
return fetchFn(`${apiBase(credentials)}${path}`, {
|
|
13
|
+
method,
|
|
14
|
+
headers: {
|
|
15
|
+
'PRIVATE-TOKEN': credentials.accessToken,
|
|
16
|
+
'Content-Type': 'application/json',
|
|
17
|
+
},
|
|
18
|
+
body: undefined === body ? undefined : JSON.stringify(body),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
async function findOpenMergeRequest(credentials, projectId, spec, fetchFn) {
|
|
22
|
+
const query = new URLSearchParams({
|
|
23
|
+
source_branch: spec.sourceBranch,
|
|
24
|
+
target_branch: spec.targetBranch,
|
|
25
|
+
state: 'opened',
|
|
26
|
+
});
|
|
27
|
+
const response = await gitlabRequest(credentials, fetchFn, 'GET', `/projects/${projectId}/merge_requests?${query.toString()}`);
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
const list = (await response.json());
|
|
32
|
+
return list[0]?.web_url ?? null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Opens a merge request straight through the GitLab REST API — the branch has already
|
|
36
|
+
* been pushed by publishChange, so unlike `glab mr create --push` this never needs
|
|
37
|
+
* git credentials of its own.
|
|
38
|
+
*
|
|
39
|
+
* A retried job force-pushes the same deterministic branch, which used to fail here
|
|
40
|
+
* with GitLab's 409 "merge request already exists"; the existing open MR is looked up
|
|
41
|
+
* and reused instead, so the retry ends up reporting the URL the shift already has.
|
|
42
|
+
*/
|
|
43
|
+
export async function createMergeRequest(credentials, projectId, spec, fetchFn = fetch) {
|
|
44
|
+
const response = await gitlabRequest(credentials, fetchFn, 'POST', `/projects/${projectId}/merge_requests`, {
|
|
45
|
+
source_branch: spec.sourceBranch,
|
|
46
|
+
target_branch: spec.targetBranch,
|
|
47
|
+
title: spec.title,
|
|
48
|
+
description: spec.description,
|
|
49
|
+
remove_source_branch: true,
|
|
50
|
+
});
|
|
51
|
+
if (409 === response.status) {
|
|
52
|
+
const existing = await findOpenMergeRequest(credentials, projectId, spec, fetchFn);
|
|
53
|
+
if (existing) {
|
|
54
|
+
return existing;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const detail = (await response.text()).slice(0, 500);
|
|
59
|
+
throw new GitLabRequestError(`merge request creation failed (status ${String(response.status)}): ${detail}`, response.status);
|
|
60
|
+
}
|
|
61
|
+
const created = (await response.json());
|
|
62
|
+
if (!created.web_url) {
|
|
63
|
+
throw new GitLabRequestError('merge request created but the response carried no web_url', response.status);
|
|
64
|
+
}
|
|
65
|
+
return created.web_url;
|
|
66
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { publishChange as defaultPublishChange, syncRepo as defaultSyncRepo } from './workspace.js';
|
|
2
|
+
/** The backend's summary/errorMessage columns are bounded; keep the tail, where the verdict is. */
|
|
3
|
+
const REPORT_TAIL_CHARS = 4000;
|
|
4
|
+
/**
|
|
5
|
+
* The last line matching "QUALIFICATION_DECISION: true|false" wins, so a decision
|
|
6
|
+
* restated mid-explanation doesn't confuse the match. The backend bakes this
|
|
7
|
+
* instruction into the AI prompt (see StartQualificationHandler::buildAiQualificationPrompt).
|
|
8
|
+
* Returns null if the job never committed to one.
|
|
9
|
+
*/
|
|
10
|
+
export function extractQualificationDecision(output) {
|
|
11
|
+
const matches = output.match(/QUALIFICATION_DECISION:\s*(true|false)/g);
|
|
12
|
+
if (!matches) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const last = matches[matches.length - 1];
|
|
16
|
+
return undefined !== last && last.endsWith('true');
|
|
17
|
+
}
|
|
18
|
+
function tail(text) {
|
|
19
|
+
return text.length > REPORT_TAIL_CHARS ? text.slice(-REPORT_TAIL_CHARS) : text;
|
|
20
|
+
}
|
|
21
|
+
function errorMessage(err) {
|
|
22
|
+
return err instanceof Error ? err.message : String(err);
|
|
23
|
+
}
|
|
24
|
+
function projectOf(job) {
|
|
25
|
+
const project = job.payload?.project;
|
|
26
|
+
if (!project?.externalId || !project.path) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return { externalId: project.externalId, path: project.path, defaultBranch: project.defaultBranch ?? 'main' };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Claims at most one job and runs it to completion. Every failure mode after a
|
|
33
|
+
* successful claim is reported back as a job failure and swallowed — the poll loop
|
|
34
|
+
* must keep going — so only a genuinely unexpected exception escapes.
|
|
35
|
+
*/
|
|
36
|
+
export async function claimAndRunJob(deps) {
|
|
37
|
+
const job = await deps.api.claimJob(deps.identity);
|
|
38
|
+
if (!job) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const report = (outcome, summary, extra = {}) => deps.api.reportJob(deps.identity, job.jobId, { outcome, summary, ...extra });
|
|
42
|
+
const project = projectOf(job);
|
|
43
|
+
if (!project) {
|
|
44
|
+
deps.log(`job ${job.jobId} is missing project info, reporting failure`);
|
|
45
|
+
await report('failure', 'Runner job payload is missing project information', { errorMessage: 'missing project info' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const credentials = await deps.api.fetchGitLabCredentials();
|
|
49
|
+
if (!credentials) {
|
|
50
|
+
deps.log(`job ${job.jobId}: failed to fetch GitLab credentials from Slipway`);
|
|
51
|
+
await report('failure', 'Failed to fetch GitLab credentials from Slipway', { errorMessage: 'gitlab credentials fetch failed' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
let repoDir;
|
|
55
|
+
try {
|
|
56
|
+
repoDir = await (deps.syncRepo ?? defaultSyncRepo)(deps.workspace, credentials, project);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
deps.log(`job ${job.jobId}: failed to check out ${project.path}: ${errorMessage(err)}`);
|
|
60
|
+
await report('failure', `Failed to clone/update ${project.path}`, { errorMessage: tail(`git sync failed: ${errorMessage(err)}`) });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const kind = 'qualification' === job.kind ? 'qualification' : 'change';
|
|
64
|
+
deps.log(`claimed job ${job.jobId} (${kind}) for project ${project.path}, running in ${repoDir}`);
|
|
65
|
+
let output;
|
|
66
|
+
try {
|
|
67
|
+
output = await deps.execute(kind, job.payload ?? {}, repoDir);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
deps.log(`job ${job.jobId} failed`);
|
|
71
|
+
await report('failure', 'Task execution failed', { errorMessage: tail(errorMessage(err)) });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if ('qualification' === kind) {
|
|
75
|
+
const decision = extractQualificationDecision(output);
|
|
76
|
+
if (null === decision) {
|
|
77
|
+
deps.log(`job ${job.jobId} succeeded but returned no qualification decision`);
|
|
78
|
+
await report('failure', 'Agent did not return a clear qualification decision', { errorMessage: tail(output) });
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
deps.log(`job ${job.jobId} succeeded, decision=${String(decision)}`);
|
|
82
|
+
await report('success', tail(output), { qualified: decision });
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await publishAndReport(deps, job, credentials, repoDir, project, output, report);
|
|
87
|
+
}
|
|
88
|
+
async function publishAndReport(deps, job, credentials, repoDir, project, output, report) {
|
|
89
|
+
let published;
|
|
90
|
+
try {
|
|
91
|
+
published = await (deps.publishChange ?? defaultPublishChange)(deps.workspace, credentials, repoDir, project, job.ownerTargetId ?? '', `Apply Slipway shift ${job.ownerId ?? ''}`, tail(output), deps.fetchFn);
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
deps.log(`job ${job.jobId}: failed to push the change / open a merge request: ${errorMessage(err)}`);
|
|
95
|
+
await report('failure', 'Failed to push the change and open a GitLab merge request', { errorMessage: tail(output) });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (!published) {
|
|
99
|
+
deps.log(`job ${job.jobId} succeeded with no changes to publish`);
|
|
100
|
+
await report('success', tail(output));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
deps.log(`job ${job.jobId} succeeded, opened merge request ${published.mergeRequestUrl}`);
|
|
104
|
+
await report('success', tail(output), { branchName: published.branchName });
|
|
105
|
+
if (job.ownerId && job.ownerTargetId) {
|
|
106
|
+
await deps.api.reportMergeRequestOpened(job.ownerId, job.ownerTargetId, published.mergeRequestUrl);
|
|
107
|
+
}
|
|
108
|
+
}
|