@pulsehub/runner 0.1.2 → 0.1.3
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/README.md +2 -0
- package/package.json +1 -1
- package/src/index.js +101 -18
- package/src/job.js +5 -1
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ To update an existing installation after a new release, run `npm update -g @puls
|
|
|
6
6
|
|
|
7
7
|
Every push to `main` (including a merged pull request) runs the release workflow. It tests the Runner, increments the patch version, commits the version and a matching `vX.Y.Z` tag to `main`, then publishes that version to npm. The npm publisher uses GitHub Actions OIDC; no npm token is stored in GitHub. Development-channel releases can be added separately when a `dev` branch is introduced.
|
|
8
8
|
|
|
9
|
+
The background service writes sanitized lifecycle and per-repository publication events to `logs/runner.jsonl` under the Runner data directory. On macOS this is `~/Library/Application Support/pulse-runner/logs/runner.jsonl`; `PULSE_RUNNER_HOME` overrides the directory. The file is created with user-only permissions and does not contain agent transcripts or provider sessions.
|
|
10
|
+
|
|
9
11
|
Initialize the local machine with its workspace and agent identifiers plus the release signing public key, then paste the public key printed by `init` into Pulse Settings. The current release key is [`keys/runner-job-v1-public.pem`](keys/runner-job-v1-public.pem). Pulse returns a one-time device credential; `pair` derives and stores the Runner ID and credential with owner-only permissions. Use `diagnose` before `start`.
|
|
10
12
|
|
|
11
13
|
After a successful agent run, the Runner itself pushes every changed `pul/*` branch in the job's signed project worktrees and creates (or detects) its pull request through the local GitHub CLI. This avoids relying on an interactive agent approval for publication; `git` and `gh` must be authenticated locally.
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { generateKeyPairSync } from 'node:crypto';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { appendFile, chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { platform } from 'node:os';
|
|
7
7
|
import { ensureDataDir, paths, readConfig, readCredential, removeTemporary, writeConfig, writeCredential } from './config.js';
|
|
@@ -14,6 +14,21 @@ const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined
|
|
|
14
14
|
const has = (flag) => args.includes(flag);
|
|
15
15
|
const usage = () => console.log(`pulse-runner <init|pair|start|run|diagnose|repo|service>\n\ninit --workspace WS --agent AGENT --signing-public-key FILE --allow-autonomous [--endpoint URL] [--repo owner/repo]\npair --credential runner.secret\nstart | run --once | diagnose | repo add|remove|list | service install|start|stop|status`);
|
|
16
16
|
|
|
17
|
+
async function logEvent(event, details = {}) {
|
|
18
|
+
try {
|
|
19
|
+
const p = await ensureDataDir();
|
|
20
|
+
const safeDetails = Object.fromEntries(Object.entries(details).map(([key, value]) => [
|
|
21
|
+
key,
|
|
22
|
+
typeof value === 'string' ? sanitize(value) : value,
|
|
23
|
+
]));
|
|
24
|
+
const file = join(p.logs, 'runner.jsonl');
|
|
25
|
+
await appendFile(file, `${JSON.stringify({ timestamp: new Date().toISOString(), event, ...safeDetails })}\n`, { mode: 0o600 });
|
|
26
|
+
await chmod(file, 0o600);
|
|
27
|
+
} catch {
|
|
28
|
+
// Logging must not turn a successfully completed job into a failed one.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
async function request(config, path, body) {
|
|
18
33
|
const credential = await readCredential();
|
|
19
34
|
if (!credential) throw new Error('Runner is not paired. Run pulse-runner pair first.');
|
|
@@ -23,6 +38,24 @@ async function request(config, path, body) {
|
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
function repoPath(repo) { return repo.replace('/', '__'); }
|
|
41
|
+
function failureDetail(output) {
|
|
42
|
+
return sanitize(output).split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-3).join(' | ').slice(-240);
|
|
43
|
+
}
|
|
44
|
+
function completionSummary(result, publication) {
|
|
45
|
+
const entries = [{
|
|
46
|
+
label: result.code === 0 ? 'Agent completed' : `Agent failed (exit ${result.code})`,
|
|
47
|
+
detail: result.code === 0 ? '' : failureDetail(result.output),
|
|
48
|
+
}];
|
|
49
|
+
for (const published of publication.succeeded) entries.push({ label: 'Publication succeeded', detail: published });
|
|
50
|
+
for (const failed of publication.failed) entries.push({
|
|
51
|
+
label: `Publication failed for ${failed.repo} during ${failed.phase}`,
|
|
52
|
+
detail: failureDetail(failed.error),
|
|
53
|
+
});
|
|
54
|
+
const detailCount = entries.filter((entry) => entry.detail).length;
|
|
55
|
+
const prefixSize = entries.reduce((sum, entry) => sum + entry.label.length + (entry.detail ? 2 : 0), 0) + entries.length - 1;
|
|
56
|
+
const detailBudget = detailCount ? Math.max(0, Math.floor((480 - prefixSize) / detailCount)) : 0;
|
|
57
|
+
return entries.map(({ label, detail }) => detail && detailBudget ? `${label}: ${detail.slice(-detailBudget)}` : label).join('\n').slice(-500);
|
|
58
|
+
}
|
|
26
59
|
async function checkoutRepo(repo, worktree) {
|
|
27
60
|
const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
|
|
28
61
|
const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
|
|
@@ -56,47 +89,97 @@ async function cleanup(checkouts) {
|
|
|
56
89
|
await removeTemporary(worktree);
|
|
57
90
|
}));
|
|
58
91
|
}
|
|
59
|
-
async function publishCheckout(checkout) {
|
|
92
|
+
async function publishCheckout(checkout, jobId) {
|
|
60
93
|
const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current']);
|
|
61
94
|
const branch = branchResult.output.trim();
|
|
62
|
-
if (!branch || !branch.startsWith('pul/'))
|
|
95
|
+
if (!branch || !branch.startsWith('pul/')) {
|
|
96
|
+
await logEvent('publication.skipped', { jobId, repo: checkout.repo, reason: 'branch-not-pulse' });
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
let phase = 'validate-commit';
|
|
63
100
|
const status = await command('git', ['-C', checkout.worktree, 'status', '--porcelain']);
|
|
64
101
|
const uncommitted = status.output.split('\n').filter((line) => line && !line.endsWith('.pulse-runner-job'));
|
|
65
|
-
if (uncommitted.length > 0) throw new Error(`Uncommitted changes remain in ${checkout.repo}; the agent must commit every changed worktree.`);
|
|
102
|
+
if (uncommitted.length > 0) throw Object.assign(new Error(`Uncommitted changes remain in ${checkout.repo}; the agent must commit every changed worktree.`), { phase });
|
|
103
|
+
phase = 'inspect-commit';
|
|
66
104
|
const head = await command('git', ['-C', checkout.worktree, 'rev-parse', 'HEAD']);
|
|
67
|
-
if (head.code
|
|
105
|
+
if (head.code) throw Object.assign(new Error(sanitize(head.output)), { phase });
|
|
106
|
+
if (head.output.trim() === checkout.baseCommit) {
|
|
107
|
+
await logEvent('publication.skipped', { jobId, repo: checkout.repo, branch, reason: 'no-commits' });
|
|
108
|
+
return '';
|
|
109
|
+
}
|
|
68
110
|
// Sólo se publica una rama de trabajo Pulse de un worktree firmado como
|
|
69
111
|
// contexto de este job. El Runner no elige repos ni destinos externos.
|
|
112
|
+
phase = 'push';
|
|
113
|
+
await logEvent('publication.started', { jobId, repo: checkout.repo, branch, phase });
|
|
70
114
|
const pushed = await command('git', ['-c', 'remote.origin.mirror=false', '--git-dir', checkout.mirror, 'push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`]);
|
|
71
|
-
if (pushed.code) throw new Error(sanitize(pushed.output));
|
|
115
|
+
if (pushed.code) throw Object.assign(new Error(sanitize(pushed.output)), { phase });
|
|
116
|
+
await logEvent('publication.pushed', { jobId, repo: checkout.repo, branch, phase });
|
|
117
|
+
phase = 'find-pull-request';
|
|
72
118
|
const existing = await command('gh', ['pr', 'view', branch, '--repo', checkout.repo, '--json', 'url']);
|
|
73
|
-
if (existing.code === 0)
|
|
119
|
+
if (existing.code === 0) {
|
|
120
|
+
await logEvent('publication.pull-request-exists', { jobId, repo: checkout.repo, branch });
|
|
121
|
+
return `Published ${checkout.repo}:${branch}; existing PR detected.`;
|
|
122
|
+
}
|
|
123
|
+
if (!/no pull requests found/i.test(existing.output)) throw Object.assign(new Error(sanitize(existing.output)), { phase });
|
|
124
|
+
phase = 'create-pull-request';
|
|
74
125
|
const created = await command('gh', ['pr', 'create', '--repo', checkout.repo, '--base', 'main', '--head', branch, '--fill']);
|
|
75
|
-
if (created.code) throw new Error(sanitize(created.output));
|
|
126
|
+
if (created.code) throw Object.assign(new Error(sanitize(created.output)), { phase });
|
|
127
|
+
await logEvent('publication.pull-request-created', { jobId, repo: checkout.repo, branch, url: created.output.trim() });
|
|
76
128
|
return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
|
|
77
129
|
}
|
|
78
|
-
async function publish(checkouts) {
|
|
79
|
-
const publications = [];
|
|
130
|
+
async function publish(checkouts, jobId) {
|
|
131
|
+
const publications = { succeeded: [], failed: [] };
|
|
80
132
|
for (const checkout of checkouts) {
|
|
81
|
-
|
|
82
|
-
|
|
133
|
+
try {
|
|
134
|
+
const published = await publishCheckout(checkout, jobId);
|
|
135
|
+
if (published) publications.succeeded.push(published);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const failure = { repo: checkout.repo, phase: error.phase || 'prepare', error: sanitize(error.message) };
|
|
138
|
+
publications.failed.push(failure);
|
|
139
|
+
await logEvent('publication.failed', { jobId, ...failure });
|
|
140
|
+
}
|
|
83
141
|
}
|
|
84
|
-
return publications
|
|
142
|
+
return publications;
|
|
85
143
|
}
|
|
86
144
|
async function runOnce(config) {
|
|
87
145
|
await request(config, 'pulseRunnerHeartbeat', { status: 'online' });
|
|
88
146
|
const delivery = await request(config, 'pulseRunnerPoll', {}); if (!delivery.job) return false;
|
|
89
147
|
const job = delivery.job;
|
|
148
|
+
await logEvent('job.received', { jobId: job.id, issueId: job.issueId, agentId: job.agentId, mode: job.mode, repo: job.repoFullName });
|
|
149
|
+
let jobPhase = 'validate-job';
|
|
90
150
|
try {
|
|
91
151
|
const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
|
|
92
|
-
validateJob(job, config, agent);
|
|
152
|
+
validateJob(job, config, agent);
|
|
153
|
+
await logEvent('job.validated', { jobId: job.id, repoCount: (job.contextRepos || [job.repoFullName]).length });
|
|
154
|
+
jobPhase = 'prepare-worktrees';
|
|
155
|
+
await request(config, 'pulseRunnerHeartbeat', { status: 'busy' });
|
|
93
156
|
const { primary, checkouts } = await checkout(job); const temp = join(paths().root, 'tmp', job.id);
|
|
94
157
|
try {
|
|
158
|
+
jobPhase = 'run-agent';
|
|
159
|
+
await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
|
|
95
160
|
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((checkout) => checkout !== primary), config, delivery.mcpCredential, temp);
|
|
96
|
-
const
|
|
97
|
-
await
|
|
161
|
+
const shouldPublish = result.code === 0 && job.mode !== 'review';
|
|
162
|
+
await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', {
|
|
163
|
+
jobId: job.id,
|
|
164
|
+
exitCode: result.code,
|
|
165
|
+
...(result.code === 0 ? {} : { error: failureDetail(result.output) }),
|
|
166
|
+
});
|
|
167
|
+
const publication = shouldPublish ? await publish(checkouts, job.id) : { succeeded: [], failed: [] };
|
|
168
|
+
const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
|
|
169
|
+
const summary = completionSummary(result, publication);
|
|
170
|
+
jobPhase = 'report-completion';
|
|
171
|
+
await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome, result: sanitize(summary) });
|
|
172
|
+
await logEvent('job.completed', { jobId: job.id, outcome, publicationFailures: publication.failed.length });
|
|
98
173
|
} finally { await removeTemporary(temp); await cleanup(checkouts); await removeTemporary(join(paths().worktrees, job.id)); }
|
|
99
|
-
} catch (error) {
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const phase = error.phase || jobPhase;
|
|
176
|
+
const message = error?.message || String(error);
|
|
177
|
+
await logEvent('job.failed', { jobId: job.id, issueId: job.issueId, repo: job.repoFullName, phase, error: message });
|
|
178
|
+
await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome: 'failed', result: sanitize(`${phase}: ${message}`) }).catch((completionError) => {
|
|
179
|
+
logEvent('job.completion-report-failed', { jobId: job.id, error: completionError?.message || String(completionError) });
|
|
180
|
+
});
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
100
183
|
finally { await request(config, 'pulseRunnerHeartbeat', { status: 'online' }).catch(() => {}); }
|
|
101
184
|
return true;
|
|
102
185
|
}
|
|
@@ -125,5 +208,5 @@ try {
|
|
|
125
208
|
if (!args.length || has('--help')) usage();
|
|
126
209
|
else if (args[0] === 'init') await init();
|
|
127
210
|
else if (args[0] === 'pair') { const credential = value('--credential'); const match = credential?.match(/^(runner-[A-Za-z0-9_-]{12})\.[A-Za-z0-9_-]{32,}$/); if (!match) throw new Error('Invalid Runner credential.'); const config = await readConfig(); if (!config) throw new Error('Run pulse-runner init before pairing.'); const paired = { ...config, runnerId: match[1] }; await writeCredential(credential); await writeConfig(paired); await syncRepos(paired); console.log(`Runner ${paired.runnerId} paired.`); }
|
|
128
|
-
else { const config = await readConfig(); if (!config) throw new Error('Runner is not initialized.'); if (args[0] === 'repo') await repo(config); else if (args[0] === 'diagnose') await diagnose(config); else if (args[0] === 'service') await service(config); else if (args[0] === 'run') await runOnce(config); else if (args[0] === 'start') { let backoff = 5_000; for (;;) { try { const ranJob = await runOnce(config); backoff = ranJob ? 1_000 : 5_000; } catch (error) { console.error(sanitize(error.message)); backoff = Math.min(backoff * 2, 60_000); } await new Promise((resolve) => setTimeout(resolve, backoff)); } } else usage(); }
|
|
211
|
+
else { const config = await readConfig(); if (!config) throw new Error('Runner is not initialized.'); if (args[0] === 'repo') await repo(config); else if (args[0] === 'diagnose') await diagnose(config); else if (args[0] === 'service') await service(config); else if (args[0] === 'run') await runOnce(config); else if (args[0] === 'start') { let backoff = 5_000; for (;;) { try { const ranJob = await runOnce(config); backoff = ranJob ? 1_000 : 5_000; } catch (error) { await logEvent('service.loop-failed', { error: error.message }); console.error(sanitize(error.message)); backoff = Math.min(backoff * 2, 60_000); } await new Promise((resolve) => setTimeout(resolve, backoff)); } } else usage(); }
|
|
129
212
|
} catch (error) { console.error(`pulse-runner: ${sanitize(error.message)}`); process.exitCode = 1; }
|
package/src/job.js
CHANGED
|
@@ -25,5 +25,9 @@ export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.n
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export function sanitize(value) {
|
|
28
|
-
return String(value || '')
|
|
28
|
+
return String(value || '')
|
|
29
|
+
.replace(/\b(runner-[A-Za-z0-9_-]{12}\.)[A-Za-z0-9._-]{32,}/gi, '$1[redacted]')
|
|
30
|
+
.replace(/\b(?:sk-ant-|sk-proj-|pulse_sk_|ghp_|gho_|ghu_|ghs_|github_pat_|sk-)[A-Za-z0-9_-]+/gi, '[redacted]')
|
|
31
|
+
.replace(/\bBearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [redacted]')
|
|
32
|
+
.slice(-500);
|
|
29
33
|
}
|