@delegoapp/runner 0.1.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.
@@ -0,0 +1,33 @@
1
+ import { spawn } from 'node:child_process';
2
+ export const maxCapturedOutputBytes = 64_000;
3
+ export function appendBoundedOutput(current, chunk) {
4
+ const next = current + chunk.toString('utf8');
5
+ if (Buffer.byteLength(next, 'utf8') <= maxCapturedOutputBytes) {
6
+ return next;
7
+ }
8
+ return next.slice(-maxCapturedOutputBytes);
9
+ }
10
+ export function runCommand(command, args, cwd) {
11
+ return new Promise((resolveCommand, reject) => {
12
+ const child = spawn(command, args, {
13
+ cwd,
14
+ env: {
15
+ ...process.env,
16
+ GIT_TERMINAL_PROMPT: '0',
17
+ },
18
+ stdio: ['ignore', 'pipe', 'pipe'],
19
+ });
20
+ let stdout = '';
21
+ let stderr = '';
22
+ child.stdout.on('data', (chunk) => {
23
+ stdout = appendBoundedOutput(stdout, chunk);
24
+ });
25
+ child.stderr.on('data', (chunk) => {
26
+ stderr = appendBoundedOutput(stderr, chunk);
27
+ });
28
+ child.on('error', reject);
29
+ child.on('close', (code) => {
30
+ resolveCommand({ code, stdout, stderr });
31
+ });
32
+ });
33
+ }
@@ -0,0 +1,82 @@
1
+ import { runCommand } from './command.js';
2
+ import { changedFilePaths } from './workspace.js';
3
+ async function readGitValue(path, args) {
4
+ const result = await runCommand('git', args, path);
5
+ if (result.code !== 0) {
6
+ throw new Error(`Unable to read git value (${args.join(' ')}): ${result.stderr || result.stdout || 'unknown git error'}`);
7
+ }
8
+ return result.stdout.trim();
9
+ }
10
+ async function ensureGitCommitIdentity(path) {
11
+ const [name, email] = await Promise.all([
12
+ runCommand('git', ['config', 'user.name'], path),
13
+ runCommand('git', ['config', 'user.email'], path),
14
+ ]);
15
+ if (name.code !== 0 || !name.stdout.trim()) {
16
+ const setName = await runCommand('git', ['config', 'user.name', 'Delego Runner'], path);
17
+ if (setName.code !== 0) {
18
+ throw new Error(`Unable to configure git user.name: ${setName.stderr || setName.stdout || 'unknown git error'}`);
19
+ }
20
+ }
21
+ if (email.code !== 0 || !email.stdout.trim()) {
22
+ const setEmail = await runCommand('git', ['config', 'user.email', 'runner@delego.local'], path);
23
+ if (setEmail.code !== 0) {
24
+ throw new Error(`Unable to configure git user.email: ${setEmail.stderr || setEmail.stdout || 'unknown git error'}`);
25
+ }
26
+ }
27
+ }
28
+ export function commitSubjectForJob(job) {
29
+ const title = job.linearIssue.title
30
+ ?.replace(/\s+/g, ' ')
31
+ .trim()
32
+ .slice(0, 120);
33
+ return title
34
+ ? `${job.linearIssue.identifier}: ${title}`
35
+ : `${job.linearIssue.identifier}: apply delegated changes`;
36
+ }
37
+ function commitBodyForJob(job, changedFiles) {
38
+ return [
39
+ `Linear issue: ${job.linearIssue.identifier}`,
40
+ job.linearIssue.url ? `Linear URL: ${job.linearIssue.url}` : null,
41
+ '',
42
+ 'Delegated through Delego local runner.',
43
+ '',
44
+ 'Changed files:',
45
+ ...changedFiles.slice(0, 50).map((file) => `- ${file}`),
46
+ ]
47
+ .filter((part) => part !== null)
48
+ .join('\n');
49
+ }
50
+ export async function createLocalCommit(repositoryPath, job, branchName, statusLines) {
51
+ const changedFiles = changedFilePaths(statusLines);
52
+ if (changedFiles.length === 0) {
53
+ throw new Error('Refusing to create an empty commit because no repository changes were detected.');
54
+ }
55
+ await ensureGitCommitIdentity(repositoryPath);
56
+ const add = await runCommand('git', ['add', '--all'], repositoryPath);
57
+ if (add.code !== 0) {
58
+ throw new Error(`Unable to stage repository changes: ${add.stderr || add.stdout || 'unknown git error'}`);
59
+ }
60
+ const subject = commitSubjectForJob(job);
61
+ const commit = await runCommand('git', ['commit', '-m', subject, '-m', commitBodyForJob(job, changedFiles)], repositoryPath);
62
+ if (commit.code !== 0) {
63
+ throw new Error(`Unable to create local commit: ${commit.stderr || commit.stdout || 'unknown git error'}`);
64
+ }
65
+ const [sha, authorName, authorEmail, committedAt] = await Promise.all([
66
+ readGitValue(repositoryPath, ['rev-parse', 'HEAD']),
67
+ readGitValue(repositoryPath, ['show', '-s', '--format=%an', 'HEAD']),
68
+ readGitValue(repositoryPath, ['show', '-s', '--format=%ae', 'HEAD']),
69
+ readGitValue(repositoryPath, ['show', '-s', '--format=%cI', 'HEAD']),
70
+ ]);
71
+ return {
72
+ sha,
73
+ shortSha: sha.slice(0, 12),
74
+ subject,
75
+ branchName,
76
+ authorName,
77
+ authorEmail,
78
+ committedAt,
79
+ changedFileCount: changedFiles.length,
80
+ changedFiles,
81
+ };
82
+ }
@@ -0,0 +1,70 @@
1
+ import { validatePublishingPolicy } from '../config.js';
2
+ import { runCommand } from './command.js';
3
+ import { commitSubjectForJob } from './commit.js';
4
+ function prTitleForJob(job) {
5
+ return commitSubjectForJob(job);
6
+ }
7
+ function prBodyForJob(job, commit) {
8
+ return [
9
+ `Linear issue: ${job.linearIssue.identifier}`,
10
+ job.linearIssue.url ? `Linear URL: ${job.linearIssue.url}` : null,
11
+ '',
12
+ `Local commit: ${commit.shortSha}`,
13
+ '',
14
+ 'Created by Delego local runner.',
15
+ ]
16
+ .filter((part) => part !== null)
17
+ .join('\n');
18
+ }
19
+ function parsePrNumberFromUrl(url) {
20
+ const value = Number(url.match(/\/pull\/(\d+)(?:\b|$)/)?.[1]);
21
+ return Number.isFinite(value) ? value : null;
22
+ }
23
+ export async function publishBranchAndMaybePr(repositoryPath, job, branchName, commit) {
24
+ validatePublishingPolicy(job.publishingPolicy);
25
+ if (!job.publishingPolicy.autoPush) {
26
+ return null;
27
+ }
28
+ const push = await runCommand('git', ['push', '-u', 'origin', branchName], repositoryPath);
29
+ if (push.code !== 0) {
30
+ throw new Error(`Unable to push branch ${branchName}: ${push.stderr || push.stdout || 'unknown git error'}`);
31
+ }
32
+ if (!job.publishingPolicy.autoCreatePr) {
33
+ return null;
34
+ }
35
+ const title = prTitleForJob(job);
36
+ const args = [
37
+ 'pr',
38
+ 'create',
39
+ '--title',
40
+ title,
41
+ '--body',
42
+ prBodyForJob(job, commit),
43
+ '--head',
44
+ branchName,
45
+ ];
46
+ if (job.publishingPolicy.prDraft) {
47
+ args.push('--draft');
48
+ }
49
+ const pr = await runCommand('gh', args, repositoryPath);
50
+ if (pr.code !== 0) {
51
+ throw new Error(`Unable to create GitHub pull request: ${pr.stderr || pr.stdout || 'unknown gh error'}`);
52
+ }
53
+ const url = pr.stdout
54
+ .trim()
55
+ .split(/\r?\n/)
56
+ .find((line) => /^https?:\/\//.test(line.trim()))
57
+ ?.trim();
58
+ if (!url) {
59
+ throw new Error('GitHub CLI did not return a pull request URL.');
60
+ }
61
+ return {
62
+ url,
63
+ number: parsePrNumberFromUrl(url),
64
+ title,
65
+ branchName,
66
+ draft: job.publishingPolicy.prDraft,
67
+ repositorySlug: job.repositorySlug,
68
+ pushed: true,
69
+ };
70
+ }
@@ -0,0 +1,213 @@
1
+ import { runCommand } from './command.js';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ async function assertGitRepository(path) {
5
+ const result = await runCommand('git', ['rev-parse', '--show-toplevel'], path);
6
+ if (result.code !== 0) {
7
+ throw new Error(`Workspace is not a git repository: ${path}`);
8
+ }
9
+ }
10
+ async function isDirty(path) {
11
+ const result = await runCommand('git', ['status', '--porcelain'], path);
12
+ if (result.code !== 0) {
13
+ throw new Error(`Unable to inspect git status: ${result.stderr || result.stdout || 'unknown git error'}`);
14
+ }
15
+ return result.stdout.trim().length > 0;
16
+ }
17
+ export async function changedFiles(path) {
18
+ const result = await runCommand('git', ['status', '--porcelain'], path);
19
+ if (result.code !== 0) {
20
+ throw new Error(`Unable to inspect git changes: ${result.stderr || result.stdout || 'unknown git error'}`);
21
+ }
22
+ return result.stdout
23
+ .split(/\r?\n/)
24
+ .map((line) => line.trim())
25
+ .filter(Boolean);
26
+ }
27
+ function filePathFromPorcelainLine(line) {
28
+ const value = line.slice(3).trim();
29
+ const renameIndex = value.indexOf(' -> ');
30
+ return renameIndex === -1 ? value : value.slice(renameIndex + 4);
31
+ }
32
+ export function changedFilePaths(statusLines) {
33
+ return statusLines.map(filePathFromPorcelainLine).filter(Boolean);
34
+ }
35
+ function candidateRepositoryPaths(workspaceRoot, repositorySlug) {
36
+ const [owner, repo] = repositorySlug.split('/');
37
+ if (!owner || !repo) {
38
+ return [workspaceRoot];
39
+ }
40
+ return [
41
+ join(workspaceRoot, owner, repo),
42
+ join(workspaceRoot, repo),
43
+ workspaceRoot,
44
+ ];
45
+ }
46
+ async function remoteMatchesRepository(path, repositorySlug) {
47
+ const result = await runCommand('git', ['remote', 'get-url', 'origin'], path);
48
+ if (result.code !== 0) {
49
+ return false;
50
+ }
51
+ const normalized = result.stdout
52
+ .trim()
53
+ .replace(/\.git$/, '')
54
+ .toLowerCase();
55
+ return normalized.endsWith(`/${repositorySlug.toLowerCase()}`);
56
+ }
57
+ export async function resolveRepositoryPath(workspaceRoot, repositorySlug) {
58
+ for (const candidate of candidateRepositoryPaths(workspaceRoot, repositorySlug)) {
59
+ if (!existsSync(candidate)) {
60
+ continue;
61
+ }
62
+ try {
63
+ await assertGitRepository(candidate);
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ if (candidate !== workspaceRoot ||
69
+ (await remoteMatchesRepository(candidate, repositorySlug))) {
70
+ console.log(`repository: using existing checkout ${candidate}`);
71
+ return candidate;
72
+ }
73
+ }
74
+ throw new Error(`No local checkout found for ${repositorySlug} under ${workspaceRoot}.`);
75
+ }
76
+ export function cachedSourceRepositoryPath(workspaceRoot, repositorySlug) {
77
+ const [owner, repo] = repositorySlug.split('/');
78
+ if (!owner || !repo) {
79
+ throw new Error(`Invalid repository slug: ${repositorySlug}`);
80
+ }
81
+ return join(workspaceRoot, 'sources', owner, `${repo}.git`);
82
+ }
83
+ async function assertBareGitRepository(path, cwd) {
84
+ const result = await runCommand('git', ['--git-dir', path, 'rev-parse', '--is-bare-repository'], cwd);
85
+ if (result.code !== 0 || result.stdout.trim() !== 'true') {
86
+ throw new Error(`Source cache is not a bare git repository: ${path}`);
87
+ }
88
+ }
89
+ export function jobWorktreePath(workspaceRoot, job) {
90
+ const [owner, repo] = job.repositorySlug.split('/');
91
+ if (!owner || !repo) {
92
+ throw new Error(`Invalid repository slug: ${job.repositorySlug}`);
93
+ }
94
+ return join(workspaceRoot, 'worktrees', owner, repo, job.id);
95
+ }
96
+ function repositoryCloneUrl(config, repositorySlug) {
97
+ if (config.gitCloneBaseUrl.endsWith(':')) {
98
+ return `${config.gitCloneBaseUrl}${repositorySlug}.git`;
99
+ }
100
+ return `${config.gitCloneBaseUrl}/${repositorySlug}.git`;
101
+ }
102
+ async function cloneBareSourceRepository(config, repositorySlug, target) {
103
+ let source = repositoryCloneUrl(config, repositorySlug);
104
+ try {
105
+ source = await resolveRepositoryPath(config.workspaceRoot, repositorySlug);
106
+ console.log(`repository: seeding bare source cache for ${repositorySlug} from ${source}`);
107
+ }
108
+ catch (error) {
109
+ const message = error instanceof Error ? error.message : String(error);
110
+ console.log(`${message} Cloning bare source cache from ${source}.`);
111
+ }
112
+ const parent = join(target, '..');
113
+ mkdirSync(parent, { recursive: true });
114
+ const clone = await runCommand('git', ['clone', '--bare', source, target], config.workspaceRoot);
115
+ if (clone.code !== 0) {
116
+ throw new Error(`Unable to clone bare source cache for ${repositorySlug}: ${clone.stderr || clone.stdout || 'unknown git error'}`);
117
+ }
118
+ console.log(`repository: cached bare source for ${repositorySlug} at ${target}`);
119
+ }
120
+ async function resolveOrCloneSourceRepositoryPath(config, repositorySlug) {
121
+ mkdirSync(config.workspaceRoot, { recursive: true });
122
+ const sourceRepositoryPath = cachedSourceRepositoryPath(config.workspaceRoot, repositorySlug);
123
+ if (!existsSync(sourceRepositoryPath)) {
124
+ await cloneBareSourceRepository(config, repositorySlug, sourceRepositoryPath);
125
+ }
126
+ await assertBareGitRepository(sourceRepositoryPath, config.workspaceRoot);
127
+ return sourceRepositoryPath;
128
+ }
129
+ export function branchNameForJob(job) {
130
+ const issueKey = job.linearIssue.identifier
131
+ .toLowerCase()
132
+ .replace(/[^a-z0-9]+/g, '-')
133
+ .replace(/^-+|-+$/g, '');
134
+ const suffix = job.id.slice(0, 8).toLowerCase();
135
+ return `delego/${issueKey || 'linear-issue'}-${suffix}`;
136
+ }
137
+ async function currentBranch(path) {
138
+ const result = await runCommand('git', ['rev-parse', '--abbrev-ref', 'HEAD'], path);
139
+ if (result.code !== 0) {
140
+ return null;
141
+ }
142
+ const branch = result.stdout.trim();
143
+ return branch ? branch : null;
144
+ }
145
+ async function branchExistsInBareRepo(sourceRepositoryPath, cwd, branchName) {
146
+ const result = await runCommand('git', [
147
+ '--git-dir',
148
+ sourceRepositoryPath,
149
+ 'rev-parse',
150
+ '--verify',
151
+ '--quiet',
152
+ `refs/heads/${branchName}`,
153
+ ], cwd);
154
+ return result.code === 0;
155
+ }
156
+ export async function prepareWorkspace(config, job) {
157
+ const sourceRepositoryPath = await resolveOrCloneSourceRepositoryPath(config, job.repositorySlug);
158
+ const worktreePath = jobWorktreePath(config.workspaceRoot, job);
159
+ const branchName = branchNameForJob(job);
160
+ if (existsSync(worktreePath)) {
161
+ // Linear follow-up prompts on a terminal job restart the same job id, so the
162
+ // worktree from the previous run is already present. Reuse it when it's a
163
+ // valid checkout on the expected branch so the executor can continue the work.
164
+ try {
165
+ await assertGitRepository(worktreePath);
166
+ const head = await currentBranch(worktreePath);
167
+ if (head === branchName) {
168
+ console.log(`workspace: reusing ${worktreePath} on ${branchName}`);
169
+ return { sourceRepositoryPath, worktreePath, branchName };
170
+ }
171
+ }
172
+ catch {
173
+ // fall through to the preserve-for-inspection error below
174
+ }
175
+ throw new Error(`Job worktree already exists; preserving it for inspection at ${worktreePath}`);
176
+ }
177
+ mkdirSync(join(worktreePath, '..'), { recursive: true });
178
+ // Drop stale worktree records whose directories were removed off-disk so
179
+ // `git worktree add` doesn't refuse to check out the existing branch.
180
+ await runCommand('git', ['--git-dir', sourceRepositoryPath, 'worktree', 'prune'], config.workspaceRoot);
181
+ // If the branch already exists in the bare repo (e.g., the worktree from a
182
+ // prior run on this same job was deleted but the branch was kept), attach to
183
+ // it instead of trying to create a new branch.
184
+ const branchAlreadyExists = await branchExistsInBareRepo(sourceRepositoryPath, config.workspaceRoot, branchName);
185
+ const worktreeAddArgs = branchAlreadyExists
186
+ ? [
187
+ '--git-dir',
188
+ sourceRepositoryPath,
189
+ 'worktree',
190
+ 'add',
191
+ worktreePath,
192
+ branchName,
193
+ ]
194
+ : [
195
+ '--git-dir',
196
+ sourceRepositoryPath,
197
+ 'worktree',
198
+ 'add',
199
+ '-b',
200
+ branchName,
201
+ worktreePath,
202
+ 'HEAD',
203
+ ];
204
+ const worktree = await runCommand('git', worktreeAddArgs, config.workspaceRoot);
205
+ if (worktree.code !== 0) {
206
+ throw new Error(`Unable to create worktree ${worktreePath}: ${worktree.stderr || worktree.stdout}`);
207
+ }
208
+ if (await isDirty(worktreePath)) {
209
+ throw new Error(`Prepared worktree has uncommitted changes; refusing to run in ${worktreePath}`);
210
+ }
211
+ console.log(`workspace: created ${worktreePath} from ${sourceRepositoryPath} on ${branchName}`);
212
+ return { sourceRepositoryPath, worktreePath, branchName };
213
+ }
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { readConfig, validatePublishingPolicy } from './config.js';
2
+ export { normalizeExecutionPreferences } from './execution-prefs.js';
3
+ export { ADAPTERS, ADAPTERS_BY_ID, claudeAdapter, codexAdapter, } from './executor/adapters/index.js';
4
+ export { createLocalCommit } from './git/commit.js';
5
+ export { branchNameForJob, cachedSourceRepositoryPath, jobWorktreePath, prepareWorkspace, } from './git/workspace.js';
6
+ export { run, resolveRunnerConfig } from './run.js';
@@ -0,0 +1,164 @@
1
+ import { validatePublishingPolicy } from './config.js';
2
+ import { normalizeExecutionPreferences } from './execution-prefs.js';
3
+ import { ADAPTERS_BY_ID } from './executor/adapters/index.js';
4
+ import { runExecutor, summarizeExecutorFailure } from './executor/process.js';
5
+ import { createLocalCommit } from './git/commit.js';
6
+ import { publishBranchAndMaybePr, } from './git/publish.js';
7
+ import { changedFilePaths, changedFiles, prepareWorkspace, } from './git/workspace.js';
8
+ import { postJson, postRunnerEvent, reportCompletion, reportFailure, reportProgress, } from './relay-client.js';
9
+ export async function pollOnce(config) {
10
+ const poll = await postJson(config, '/api/runner/jobs/poll', {
11
+ version: config.version,
12
+ supportedExecutors: config.supportedExecutors,
13
+ });
14
+ if (!poll.job) {
15
+ console.log(`poll: no job (${poll.reason ?? 'empty'})`);
16
+ return null;
17
+ }
18
+ return {
19
+ ...poll.job,
20
+ executionPreferences: normalizeExecutionPreferences(poll.job),
21
+ };
22
+ }
23
+ export async function runRealExecutionOnce(config) {
24
+ const job = await pollOnce(config);
25
+ if (!job) {
26
+ return false;
27
+ }
28
+ console.log(`claimed ${job.id} for ${job.repositorySlug}`);
29
+ await postRunnerEvent(config, '/api/runner/heartbeat', job.id);
30
+ let workspace = null;
31
+ let commitMetadata = null;
32
+ try {
33
+ validatePublishingPolicy(job.publishingPolicy);
34
+ workspace = await prepareWorkspace(config, job);
35
+ await reportProgress(config, job, `Prepared local workspace for ${job.executionPreferences.executor} execution.`, {
36
+ executor: job.executionPreferences.executor,
37
+ model: job.executionPreferences.model,
38
+ thinking: job.executionPreferences.thinking,
39
+ repositoryPath: workspace.worktreePath,
40
+ sourceRepositoryPath: workspace.sourceRepositoryPath,
41
+ worktreePath: workspace.worktreePath,
42
+ branchName: workspace.branchName,
43
+ });
44
+ const result = await runExecutor(config, job, workspace.worktreePath);
45
+ const changes = await changedFiles(workspace.worktreePath);
46
+ let prMetadata = null;
47
+ let pushed = false;
48
+ if (result.exitCode === 0 &&
49
+ !result.cancelled &&
50
+ !result.timedOut &&
51
+ changes.length > 0 &&
52
+ config.createCommit) {
53
+ commitMetadata = await createLocalCommit(workspace.worktreePath, job, workspace.branchName, changes);
54
+ console.log(`commit: created ${commitMetadata.shortSha} on ${workspace.branchName}`);
55
+ if (job.publishingPolicy.autoPush) {
56
+ await reportProgress(config, job, 'Publishing branch with local git credentials.', {
57
+ branchName: workspace.branchName,
58
+ repositorySlug: job.repositorySlug,
59
+ publishingPolicy: job.publishingPolicy,
60
+ });
61
+ prMetadata = await publishBranchAndMaybePr(workspace.worktreePath, job, workspace.branchName, commitMetadata);
62
+ pushed = true;
63
+ if (prMetadata) {
64
+ console.log(`publish: created ${prMetadata.draft ? 'draft ' : ''}PR ${prMetadata.url}`);
65
+ }
66
+ else {
67
+ console.log(`publish: pushed ${workspace.branchName} without creating a PR`);
68
+ }
69
+ }
70
+ }
71
+ const remainingChanges = await changedFiles(workspace.worktreePath);
72
+ const metadata = {
73
+ attemptId: job.attemptId,
74
+ repositorySlug: job.repositorySlug,
75
+ branchName: workspace.branchName,
76
+ repositoryPath: workspace.worktreePath,
77
+ sourceRepositoryPath: workspace.sourceRepositoryPath,
78
+ worktreePath: workspace.worktreePath,
79
+ exitCode: result.exitCode,
80
+ signal: result.signal,
81
+ timedOut: result.timedOut,
82
+ cancelled: result.cancelled,
83
+ createCommit: config.createCommit,
84
+ executionPreferences: job.executionPreferences,
85
+ changedFileCount: changes.length,
86
+ changedFiles: changedFilePaths(changes).slice(0, 50),
87
+ remainingChangeCount: remainingChanges.length,
88
+ remainingChanges: changedFilePaths(remainingChanges).slice(0, 50),
89
+ commitMetadata,
90
+ prMetadata,
91
+ publishingPolicy: job.publishingPolicy,
92
+ pushed,
93
+ assistantResponse: result.finalReply,
94
+ stdoutTail: result.stdout,
95
+ stderrTail: result.stderr,
96
+ };
97
+ if (result.exitCode === 0 && !result.cancelled && !result.timedOut) {
98
+ const executorLabel = ADAPTERS_BY_ID[job.executionPreferences.executor].displayName;
99
+ if (changes.length === 0) {
100
+ await reportCompletion(config, job, `${executorLabel} executor completed successfully with no repository changes.`, metadata);
101
+ console.log(`complete: reported no-change success for ${job.id}`);
102
+ if (result.stdout.trim()) {
103
+ console.log(`executor stdout tail:\n${result.stdout.trim()}`);
104
+ }
105
+ if (result.stderr.trim()) {
106
+ console.log(`executor stderr tail:\n${result.stderr.trim()}`);
107
+ }
108
+ }
109
+ else {
110
+ const summary = prMetadata
111
+ ? `${executorLabel} executor completed successfully and created pull request ${prMetadata.url}.`
112
+ : commitMetadata
113
+ ? job.publishingPolicy.autoPush
114
+ ? `${executorLabel} executor completed successfully, created local commit ${commitMetadata.shortSha}, and pushed ${workspace.branchName}.`
115
+ : `${executorLabel} executor completed successfully and created local commit ${commitMetadata.shortSha}; publishing is manual.`
116
+ : `${executorLabel} executor completed successfully with ${changes.length} changed file(s); local commit creation is disabled.`;
117
+ await reportCompletion(config, job, summary, metadata);
118
+ console.log(`changes: ${changes.join(', ')}`);
119
+ console.log(`complete: reported success for ${job.id}`);
120
+ }
121
+ }
122
+ else {
123
+ const summary = summarizeExecutorFailure(result);
124
+ await reportFailure(config, job, summary, metadata, result.cancelled);
125
+ console.log(`terminal: reported ${result.cancelled ? 'cancellation' : 'failure'} for ${job.id}`);
126
+ }
127
+ }
128
+ catch (error) {
129
+ const message = error instanceof Error ? error.message : String(error);
130
+ await reportFailure(config, job, message, {
131
+ attemptId: job.attemptId,
132
+ repositorySlug: job.repositorySlug,
133
+ branchName: workspace?.branchName,
134
+ repositoryPath: workspace?.worktreePath,
135
+ sourceRepositoryPath: workspace?.sourceRepositoryPath,
136
+ worktreePath: workspace?.worktreePath,
137
+ commitMetadata,
138
+ publishingPolicy: job.publishingPolicy,
139
+ });
140
+ console.error(`failed ${job.id}: ${message}`);
141
+ }
142
+ finally {
143
+ await postRunnerEvent(config, '/api/runner/heartbeat', null);
144
+ }
145
+ return true;
146
+ }
147
+ export async function runMockExecutionOnce(config) {
148
+ const job = await pollOnce(config);
149
+ if (!job) {
150
+ return;
151
+ }
152
+ console.log('claimed job request payload:');
153
+ console.log(JSON.stringify(job, null, 2));
154
+ await reportProgress(config, job, 'Mock runner received the queued job payload.', {
155
+ repositorySlug: job.repositorySlug,
156
+ });
157
+ console.log(`progress: reported for ${job.id}`);
158
+ await reportCompletion(config, job, 'Mock runner completed without touching git, workspaces, executors, or GitHub.', {
159
+ attemptId: job.attemptId,
160
+ mode: 'mock',
161
+ });
162
+ console.log(`complete: reported success for ${job.id}`);
163
+ await postRunnerEvent(config, '/api/runner/heartbeat', null);
164
+ }
@@ -0,0 +1,65 @@
1
+ import { getProfile, readStore, upsertProfile, } from './credentials-store.js';
2
+ function normalizeRelayUrl(value) {
3
+ return value.replace(/\/+$/, '');
4
+ }
5
+ export async function pairAndStore(input) {
6
+ const fetchImpl = input.fetchImpl ?? fetch;
7
+ const now = input.now ?? (() => new Date());
8
+ const relayUrl = normalizeRelayUrl(input.relayUrl);
9
+ let store;
10
+ try {
11
+ store = readStore(input.credentialsPath);
12
+ }
13
+ catch (error) {
14
+ if (error.code === 'ENOENT') {
15
+ store = { version: 1, profiles: {} };
16
+ }
17
+ else {
18
+ throw error;
19
+ }
20
+ }
21
+ const existing = getProfile(store, input.profileName);
22
+ const orphanedRunnerId = existing?.runnerId ?? null;
23
+ let response;
24
+ try {
25
+ response = await fetchImpl(`${relayUrl}/api/runner/register`, {
26
+ method: 'POST',
27
+ headers: {
28
+ 'content-type': 'application/json',
29
+ authorization: `Bearer ${input.pairingToken}`,
30
+ },
31
+ body: JSON.stringify({
32
+ pairingToken: input.pairingToken,
33
+ version: input.version,
34
+ supportedExecutors: input.supportedExecutors,
35
+ }),
36
+ });
37
+ }
38
+ catch (error) {
39
+ const message = error instanceof Error ? error.message : String(error);
40
+ throw new Error(`delego-runner: unable to reach relay at ${relayUrl}/api/runner/register: ${message}`);
41
+ }
42
+ if (response.status === 401) {
43
+ throw new Error('delego-runner: pairing token expired or already used. Mint a new one in the dashboard and retry.');
44
+ }
45
+ const body = (await response.json().catch(() => ({})));
46
+ if (!response.ok) {
47
+ throw new Error(`delego-runner: relay rejected pairing (HTTP ${response.status}): ${body.error ?? 'unknown'}`);
48
+ }
49
+ if (!body.runner?.id || !body.bearer) {
50
+ throw new Error(`delego-runner: relay returned an unexpected response shape (missing runner.id or bearer)`);
51
+ }
52
+ const profile = {
53
+ relayUrl,
54
+ runnerId: body.runner.id,
55
+ bearer: body.bearer,
56
+ pairedAt: now().toISOString(),
57
+ };
58
+ upsertProfile(input.credentialsPath, input.profileName, profile);
59
+ return {
60
+ runnerId: profile.runnerId,
61
+ bearer: profile.bearer,
62
+ relayUrl: profile.relayUrl,
63
+ orphanedRunnerId,
64
+ };
65
+ }