@pulsehub/runner 0.1.3 → 0.1.5
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 +55 -2
- package/package.json +1 -1
- package/src/config.js +2 -2
- package/src/index.js +49 -186
- package/src/job.js +7 -7
- package/src/process.js +56 -15
- package/src/readiness.js +35 -0
- package/src/runtime.js +292 -0
- package/src/service.js +84 -0
- package/src/worktree.js +18 -0
- package/test/integration.test.js +269 -0
- package/test/job.test.js +23 -0
- package/test/process.test.js +7 -1
- package/test/readiness.test.js +34 -0
- package/test/runtime.test.js +42 -0
- package/test/service.test.js +49 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Install with Node 20+: `npm install -g @pulsehub/runner`.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Before updating, stop the existing process with its installed `pulse-runner service stop` command (or Ctrl+C in its terminal). Then run `npm update -g @pulsehub/runner` and `pulse-runner service install`. This release replaces the old detached-process service with an OS-managed per-user service; install it once after upgrading. Service installation also starts it.
|
|
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
|
|
|
@@ -16,4 +16,57 @@ The Runner only uses local Git and provider sessions, creates disposable worktre
|
|
|
16
16
|
|
|
17
17
|
## QA review jobs
|
|
18
18
|
|
|
19
|
-
An agent can use a dedicated Runner identity for QA. To run more than one agent on the same machine, give each instance its own `PULSE_RUNNER_HOME`; initialize and pair each one with its own agent and Runner credential, then run `pulse-runner start` with that same environment variable. Register the QA Runner in Pulse Settings using its generated signing public key, connect every repository that has a PR in the review, pair its one-time device credential, and select it on the QA agent. Review jobs call `pulse_next_review` and `pulse_get_review_context` through Pulse MCP, inspect every PR, and submit their verdict with `pulse_submit_review`. They do not publish source changes or create PRs.
|
|
19
|
+
An agent can use a dedicated Runner identity for QA. To run more than one agent on the same machine, give each instance its own `PULSE_RUNNER_HOME`; initialize each QA instance with `--agent-role qa`, and pair each one with its own agent and Runner credential, then run `pulse-runner start` with that same environment variable. Register the QA Runner in Pulse Settings using its generated signing public key, connect every repository that has a PR in the review, pair its one-time device credential, and select it on the QA agent. Review jobs call `pulse_next_review` and `pulse_get_review_context` through Pulse MCP, inspect every PR, and submit their verdict with `pulse_submit_review`. They do not publish source changes or create PRs.
|
|
20
|
+
|
|
21
|
+
## Preparation and preflight (TES-304)
|
|
22
|
+
|
|
23
|
+
In Pulse Settings, select a Runner on the agent, then choose **Verificar preparación**. The expected local identity is shown there. On the Runner machine, under the same OS user as the service, register it explicitly:
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pulse-runner identity add --agent AGENT_ID --agent-kind codex --agent-role dev
|
|
27
|
+
# For a QA agent (Claude is also supported):
|
|
28
|
+
pulse-runner identity add --agent QA_AGENT_ID --agent-kind claude --agent-role qa
|
|
29
|
+
pulse-runner identity list
|
|
30
|
+
pulse-runner diagnose
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`identity remove --agent ID` removes an identity. Each job must match an explicitly configured agent, provider and role, including QA jobs. Use separate `PULSE_RUNNER_HOME` directories for independent local instances. The service reloads identity configuration on each polling cycle.
|
|
34
|
+
|
|
35
|
+
Sign in locally with `codex login` or `claude auth login`. Diagnostics use the providers' native status commands and Git `ls-remote`; Pulse receives only agent/provider/role identifiers and CLI/session/repository availability booleans. Account information, command output and provider credentials are discarded locally. A status check confirms locally available authentication, not that a later provider request cannot fail. The daemon refreshes readiness at least once per minute and runs a fresh local check before executing a delivered job. Probes time out without opening interactive login prompts.
|
|
36
|
+
|
|
37
|
+
Backend dispatch requires a recent heartbeat and readiness report, a matching identity/provider/role, repository authorization and access, and capacity. It checks these centrally for tasks, rework, handoff, retry and QA; delivery checks again before issuing a job MCP credential. Failed jobs carry a phase/category and the job ID used in `logs/runner.jsonl`.
|
|
38
|
+
|
|
39
|
+
Rollout: upgrade the Runner and configure all Dev/QA identities before enabling the new backend preflight. Older Runners without readiness reports will be blocked with an actionable message. The frontend and backend changes are required together for the preparation panel. Manual UI and live provider verification remain part of TES-308.
|
|
40
|
+
|
|
41
|
+
Authentication command references: [Codex CLI](https://developers.openai.com/codex/cli/reference), [Claude CLI](https://code.claude.com/docs/en/cli-reference).
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
## Installation and background services (TES-286)
|
|
45
|
+
|
|
46
|
+
Install Node 20+, Git, the GitHub CLI and the provider CLI on macOS, Linux or Windows. Sign in locally with `gh auth login` and the provider's login command. No GitHub token needs to be entered in Pulse. Initialize with the public signing key shipped in this package, register the generated public key and approved repositories in Settings → Agentes → Pulse Runners, then pair the one-time device credential on that machine. A signing **public** key is safe to install locally; never copy the backend's signing private key.
|
|
47
|
+
|
|
48
|
+
Run `pulse-runner diagnose`, then use `pulse-runner run --once` for one job or `pulse-runner start` for foreground operation. `service install` registers a LaunchAgent on macOS, a systemd user unit on Linux, or an on-logon Scheduled Task on Windows. `service start|stop|status|uninstall` manages that installation. Run these commands as the same OS user who owns the local provider and Git sessions. Linux background execution requires a working user systemd session; macOS requires a logged-in GUI user; the Windows task starts on user logon.
|
|
49
|
+
|
|
50
|
+
For multiple instances, use a separate `PULSE_RUNNER_HOME` for every Runner, including when running service commands. Service names derive from that directory, and the installed command retains it through `--home`. A shared lock prevents a daemon and `run --once` from using the same data directory concurrently. If a process died, the next invocation recovers its stale lock and cleans interrupted temporary work before polling.
|
|
51
|
+
|
|
52
|
+
## Cancellation, recovery and repository scope
|
|
53
|
+
|
|
54
|
+
In Settings → Agentes → Pulse Runners → Actividad reciente, select **Cancelar** for an active job. Only the Runner owner or a workspace admin can cancel it. A queued job becomes canceled immediately. A delivered job shows **Cancelación solicitada**, revokes its ephemeral MCP access immediately, and retains its active capacity until the Runner stops the process and acknowledges cleanup. Refreshing that panel follows the acknowledgment automatically. A disconnected Runner cannot acknowledge until it reconnects; expired jobs are released by the existing sweeper.
|
|
55
|
+
|
|
56
|
+
While executing, the Runner sends an outbound heartbeat every two seconds with its active job ID. A cancellation, revocation, expired job or failed heartbeat stops the provider process tree. On Unix it sends SIGTERM, escalating after two seconds; Windows uses `taskkill /T /F`. Cancellation skips subsequent publication and removes per-job MCP configuration and worktrees before reporting `canceled`. It also checks cancellation before each push and PR creation. A push or PR that already finished cannot be undone by a later cancellation.
|
|
57
|
+
|
|
58
|
+
Ctrl+C or SIGTERM also cancels foreground work. A force-killed daemon recovers on its next start: its local journal stores only the job ID, cleans interrupted worktrees/MCP files, prunes Git registrations and reports the interrupted job canceled. Committed branches remain in the local mirror; uncommitted disposable worktree edits are removed. The source checkout remains untouched. On Windows, forcibly ending the daemon may defer cleanup until that restart.
|
|
59
|
+
|
|
60
|
+
The local `repo` command can retain or reduce the repository list approved during pairing; the device credential cannot widen it. To approve a larger scope, the owner/admin registers a replacement Runner with the new repository list in Pulse, revokes the old Runner and pairs the replacement locally. A rejected local change leaves the local configuration unchanged.
|
|
61
|
+
|
|
62
|
+
Codex receives the ephemeral MCP Authorization header through a child-process environment variable, never command-line arguments. Claude receives a user-only temporary MCP configuration file, deleted after execution. Provider authentication is always discovered with native status commands; Pulse receives readiness booleans and sanitized lifecycle/results, not provider sessions.
|
|
63
|
+
|
|
64
|
+
## Signing and rollout
|
|
65
|
+
|
|
66
|
+
Backend `RUNNER_JOB_SIGNING_PRIVATE_KEY` must already exist before deployment, be Ed25519, and match this release's `keys/runner-job-v1-public.pem` and `runner-job-v1` key ID. `contextRepos` is included in the signed envelope. Unknown keys, malformed/future/expired timestamps, invalid identities or unauthorized repositories are rejected before worktree creation. Older HMAC envelopes without algorithm/key ID are deliberately rejected; never reinterpret them locally. Before changing the signing format or key, drain/cancel queued jobs and update Runners together with the backend; current envelopes expire after 30 minutes.
|
|
67
|
+
|
|
68
|
+
Upgrade Runners before enabling the cancellation UI/backend. Older Runners do not observe cancellation requests while their CLI runs, even if the backend revokes MCP access. The backend transaction preserves a requested cancellation if a completion races it. This version retains the TES-304 readiness and Dev/QA identity requirements.
|
|
69
|
+
|
|
70
|
+
## Verification
|
|
71
|
+
|
|
72
|
+
`npm test` runs native fixture CLIs for both providers against a loopback control plane and real temporary Git repositories. It covers native session detection, signed envelope rejection before checkout, multi-repo isolation, remote/local/revocation/expiry cancellation, process descendants, sanitized reports, temporary-file cleanup, lock exclusion, interrupted-job recovery and QA permissions. The CI matrix runs the suite and CLI smoke on macOS, Linux and Windows. Fixture tests do not prove live model execution or actual service installation in each OS session.
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { homedir, platform } from 'node:os';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
4
|
|
|
5
5
|
export function dataDir() {
|
|
6
|
-
if (process.env.PULSE_RUNNER_HOME) return process.env.PULSE_RUNNER_HOME;
|
|
6
|
+
if (process.env.PULSE_RUNNER_HOME) return resolve(process.env.PULSE_RUNNER_HOME);
|
|
7
7
|
if (platform() === 'darwin') return join(homedir(), 'Library', 'Application Support', 'pulse-runner');
|
|
8
8
|
if (platform() === 'win32') return join(process.env.APPDATA || join(homedir(), 'AppData', 'Roaming'), 'pulse-runner');
|
|
9
9
|
return join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'pulse-runner');
|
package/src/index.js
CHANGED
|
@@ -1,212 +1,75 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { inspectReadiness, localIdentities } from './readiness.js';
|
|
2
3
|
import { generateKeyPairSync } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
import { command, executable, runAgent } from './process.js';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { readConfig, readCredential, writeConfig, writeCredential } from './config.js';
|
|
6
|
+
import { sanitize } from './job.js';
|
|
7
|
+
import { service } from './service.js';
|
|
8
|
+
|
|
9
|
+
import { runOnce, recoverInterruptedWork, withRunnerLock, request } from './runtime.js';
|
|
10
10
|
|
|
11
11
|
const DEFAULT_ENDPOINT = 'https://us-east4-pulse-app-93.cloudfunctions.net';
|
|
12
12
|
const args = process.argv.slice(2);
|
|
13
|
+
const homeIndex = args.indexOf('--home');
|
|
14
|
+
if (homeIndex >= 0 && args[homeIndex + 1]) process.env.PULSE_RUNNER_HOME = args[homeIndex + 1];
|
|
15
|
+
let shutdownRequested = false;
|
|
16
|
+
let activeJobController;
|
|
17
|
+
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
|
|
18
|
+
shutdownRequested = true;
|
|
19
|
+
activeJobController?.abort();
|
|
20
|
+
});
|
|
13
21
|
const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; };
|
|
14
22
|
const has = (flag) => args.includes(flag);
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
async function request(config, path, body) {
|
|
33
|
-
const credential = await readCredential();
|
|
34
|
-
if (!credential) throw new Error('Runner is not paired. Run pulse-runner pair first.');
|
|
35
|
-
const response = await fetch(`${config.endpoint}/${path}`, { method: 'POST', headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
36
|
-
if (!response.ok) throw new Error(`Pulse endpoint rejected request (${response.status}).`);
|
|
37
|
-
return response.json();
|
|
38
|
-
}
|
|
23
|
+
const usage = () => console.log(`pulse-runner <init|pair|start|run|diagnose|identity|repo|service>\n\ninit --workspace WS --agent AGENT [--agent-kind codex|claude] [--agent-role dev|qa] --signing-public-key FILE --allow-autonomous [--endpoint URL] [--repo owner/repo]\npair --credential runner.secret\nidentity add|remove --agent ID --agent-kind codex|claude --agent-role dev|qa | identity list\nstart | run --once | diagnose | repo add|remove|list | service install|start|stop|status|uninstall`);
|
|
39
24
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
}
|
|
59
|
-
async function checkoutRepo(repo, worktree) {
|
|
60
|
-
const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
|
|
61
|
-
const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
|
|
62
|
-
if (existing.code !== 0) {
|
|
63
|
-
const cloned = await command('git', ['clone', '--mirror', `https://github.com/${repo}.git`, mirror]);
|
|
64
|
-
if (cloned.code) throw new Error(sanitize(cloned.output));
|
|
65
|
-
}
|
|
66
|
-
const fetchResult = await command('git', ['--git-dir', mirror, 'fetch', '--prune', 'origin']); if (fetchResult.code) throw new Error(sanitize(fetchResult.output));
|
|
67
|
-
const added = await command('git', ['--git-dir', mirror, 'worktree', 'add', '--detach', worktree, 'HEAD']); if (added.code) throw new Error(sanitize(added.output));
|
|
68
|
-
const base = await command('git', ['-C', worktree, 'rev-parse', 'HEAD']);
|
|
69
|
-
if (base.code) throw new Error(sanitize(base.output));
|
|
70
|
-
return { repo, mirror, worktree, baseCommit: base.output.trim() };
|
|
71
|
-
}
|
|
72
|
-
async function checkout(job) {
|
|
73
|
-
const root = join(paths().worktrees, job.id); const repos = [...new Set(job.contextRepos || [job.repoFullName])]; const checkouts = [];
|
|
74
|
-
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
75
|
-
try {
|
|
76
|
-
for (const repo of repos) {
|
|
77
|
-
const checkout = await checkoutRepo(repo, join(root, repoPath(repo)));
|
|
78
|
-
await writeFile(join(checkout.worktree, '.pulse-runner-job'), job.id, { mode: 0o600 });
|
|
79
|
-
checkouts.push(checkout);
|
|
80
|
-
}
|
|
81
|
-
const primary = checkouts.find((checkout) => checkout.repo === job.repoFullName);
|
|
82
|
-
if (!primary) throw new Error('Job context does not include its target repository.');
|
|
83
|
-
return { primary, checkouts };
|
|
84
|
-
} catch (error) { await cleanup(checkouts); await removeTemporary(root); throw error; }
|
|
85
|
-
}
|
|
86
|
-
async function cleanup(checkouts) {
|
|
87
|
-
await Promise.all(checkouts.map(async ({ worktree, mirror }) => {
|
|
88
|
-
await command('git', ['--git-dir', mirror, 'worktree', 'remove', '--force', worktree]).catch(() => {});
|
|
89
|
-
await removeTemporary(worktree);
|
|
90
|
-
}));
|
|
91
|
-
}
|
|
92
|
-
async function publishCheckout(checkout, jobId) {
|
|
93
|
-
const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current']);
|
|
94
|
-
const branch = branchResult.output.trim();
|
|
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';
|
|
100
|
-
const status = await command('git', ['-C', checkout.worktree, 'status', '--porcelain']);
|
|
101
|
-
const uncommitted = status.output.split('\n').filter((line) => line && !line.endsWith('.pulse-runner-job'));
|
|
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';
|
|
104
|
-
const head = await command('git', ['-C', checkout.worktree, 'rev-parse', 'HEAD']);
|
|
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
|
-
}
|
|
110
|
-
// Sólo se publica una rama de trabajo Pulse de un worktree firmado como
|
|
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 });
|
|
114
|
-
const pushed = await command('git', ['-c', 'remote.origin.mirror=false', '--git-dir', checkout.mirror, 'push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`]);
|
|
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';
|
|
118
|
-
const existing = await command('gh', ['pr', 'view', branch, '--repo', checkout.repo, '--json', 'url']);
|
|
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';
|
|
125
|
-
const created = await command('gh', ['pr', 'create', '--repo', checkout.repo, '--base', 'main', '--head', branch, '--fill']);
|
|
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() });
|
|
128
|
-
return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
|
|
129
|
-
}
|
|
130
|
-
async function publish(checkouts, jobId) {
|
|
131
|
-
const publications = { succeeded: [], failed: [] };
|
|
132
|
-
for (const checkout of checkouts) {
|
|
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 });
|
|
25
|
+
async function start(config) {
|
|
26
|
+
return withRunnerLock(async () => {
|
|
27
|
+
await recoverInterruptedWork(config);
|
|
28
|
+
let backoff = 5_000;
|
|
29
|
+
while (!shutdownRequested) {
|
|
30
|
+
try { activeJobController = new AbortController(); const ranJob = await runOnce((await readConfig()) || config, { signal: activeJobController.signal }); backoff = ranJob ? 1_000 : 5_000; }
|
|
31
|
+
catch (error) { console.error(sanitize(error.message)); backoff = Math.min(backoff * 2, 60_000); }
|
|
32
|
+
finally { activeJobController = undefined; }
|
|
33
|
+
if (!shutdownRequested) await new Promise((resolve) => setTimeout(resolve, backoff));
|
|
140
34
|
}
|
|
141
|
-
}
|
|
142
|
-
return publications;
|
|
143
|
-
}
|
|
144
|
-
async function runOnce(config) {
|
|
145
|
-
await request(config, 'pulseRunnerHeartbeat', { status: 'online' });
|
|
146
|
-
const delivery = await request(config, 'pulseRunnerPoll', {}); if (!delivery.job) return false;
|
|
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';
|
|
150
|
-
try {
|
|
151
|
-
const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
|
|
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' });
|
|
156
|
-
const { primary, checkouts } = await checkout(job); const temp = join(paths().root, 'tmp', job.id);
|
|
157
|
-
try {
|
|
158
|
-
jobPhase = 'run-agent';
|
|
159
|
-
await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
|
|
160
|
-
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((checkout) => checkout !== primary), config, delivery.mcpCredential, temp);
|
|
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 });
|
|
173
|
-
} finally { await removeTemporary(temp); await cleanup(checkouts); await removeTemporary(join(paths().worktrees, job.id)); }
|
|
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
|
-
}
|
|
183
|
-
finally { await request(config, 'pulseRunnerHeartbeat', { status: 'online' }).catch(() => {}); }
|
|
184
|
-
return true;
|
|
35
|
+
});
|
|
185
36
|
}
|
|
186
37
|
|
|
187
38
|
async function init() {
|
|
188
39
|
const publicKeyPath = value('--signing-public-key'); if (!publicKeyPath || !value('--workspace') || !value('--agent')) throw new Error('init requires --workspace, --agent and --signing-public-key.');
|
|
189
40
|
if (!has('--allow-autonomous')) throw new Error('Autonomous agent execution requires explicit --allow-autonomous confirmation.');
|
|
41
|
+
if (!['codex', 'claude'].includes(value('--agent-kind') || 'codex') || !['dev', 'qa'].includes(value('--agent-role') || 'dev')) throw new Error('init requires a valid --agent-kind codex|claude and --agent-role dev|qa.');
|
|
190
42
|
const { publicKey } = generateKeyPairSync('ed25519'); const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
191
43
|
const signingPublicKey = await readFile(publicKeyPath, 'utf8');
|
|
192
|
-
await writeConfig({ version: 1, endpoint: value('--endpoint') || DEFAULT_ENDPOINT, workspaceId: value('--workspace'), agentId: value('--agent'), agentKind: value('--agent-kind') || 'codex', signingKeyId: value('--signing-key-id') || 'runner-job-v1', signingPublicKey, repos: args.filter((item, index) => args[index - 1] === '--repo'), autonomousConfirmed: true });
|
|
44
|
+
await writeConfig({ version: 1, endpoint: value('--endpoint') || DEFAULT_ENDPOINT, workspaceId: value('--workspace'), agentId: value('--agent'), agentKind: value('--agent-kind') || 'codex', agentRole: value('--agent-role') || 'dev', signingKeyId: value('--signing-key-id') || 'runner-job-v1', signingPublicKey, repos: args.filter((item, index) => args[index - 1] === '--repo'), autonomousConfirmed: true });
|
|
193
45
|
console.log(`Public key for pairing:\n${publicKeyPem}\n\nPaste it in Pulse Settings, then run: pulse-runner pair --credential <runner credential>`);
|
|
194
46
|
}
|
|
195
47
|
async function syncRepos(config) { await request(config, 'pulseRunnerConfigure', { connectedRepos: config.repos }); }
|
|
196
|
-
async function repo(config) { const op = args[1]; const name = args[2]; if (op === 'list') return console.log(config.repos.join('\n')); if (!name || !/^[
|
|
197
|
-
async function diagnose(config) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
48
|
+
async function repo(config) { const op = args[1]; const name = args[2]; if (op === 'list') return console.log(config.repos.join('\n')); if (!['add', 'remove'].includes(op) || !name || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(name)) throw new Error('Repository must be owner/repo.'); const repos = new Set(config.repos); op === 'add' ? repos.add(name) : repos.delete(name); const updated = { ...config, repos: [...repos].sort() }; await syncRepos(updated); await writeConfig(updated); }
|
|
49
|
+
async function diagnose(config) {
|
|
50
|
+
const readiness = await inspectReadiness(config);
|
|
51
|
+
console.log(JSON.stringify(readiness, null, 2));
|
|
52
|
+
if (await readCredential()) await request(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
|
|
53
|
+
const providers = localIdentities(config).map((identity) => readiness.providers[identity.kind]);
|
|
54
|
+
if (providers.some((provider) => !provider?.cli || !provider?.session) || readiness.repositories.some((repo) => !repo.accessible)) process.exitCode = 1;
|
|
55
|
+
}
|
|
56
|
+
async function identity(config) {
|
|
57
|
+
if (args[1] === 'list') return console.log(JSON.stringify(localIdentities(config), null, 2));
|
|
58
|
+
const agentId = value('--agent');
|
|
59
|
+
const kind = value('--agent-kind');
|
|
60
|
+
const role = value('--agent-role');
|
|
61
|
+
if (!agentId || !/^[-A-Za-z0-9_]{1,100}$/.test(agentId) || !['add', 'remove'].includes(args[1])) throw new Error('Use identity add|remove --agent ID [--agent-kind codex|claude --agent-role dev|qa].');
|
|
62
|
+
if (args[1] === 'add' && (!['codex', 'claude'].includes(kind) || !['dev', 'qa'].includes(role))) throw new Error('identity add requires a valid provider and role.');
|
|
63
|
+
const identities = localIdentities(config).filter((entry) => entry.agentId !== agentId);
|
|
64
|
+
if (args[1] === 'add') identities.push({ agentId, kind, role });
|
|
65
|
+
const updated = { ...config, identities };
|
|
66
|
+
await writeConfig(updated);
|
|
67
|
+
await diagnose(updated);
|
|
205
68
|
}
|
|
206
69
|
|
|
207
70
|
try {
|
|
208
71
|
if (!args.length || has('--help')) usage();
|
|
209
72
|
else if (args[0] === 'init') await init();
|
|
210
73
|
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.`); }
|
|
211
|
-
else { const config = await readConfig(); if (!config) throw new Error('Runner is not initialized.'); if (args[0] === '
|
|
74
|
+
else { const config = await readConfig(); if (!config) throw new Error('Runner is not initialized.'); if (args[0] === 'identity') await identity(config); else if (args[0] === 'repo') await repo(config); else if (args[0] === 'diagnose') await diagnose(config); else if (args[0] === 'service') await service(args[1]); else if (args[0] === 'run') { activeJobController = new AbortController(); try { await withRunnerLock(async () => { await recoverInterruptedWork(config); await runOnce(config, { signal: activeJobController.signal }); }); } finally { activeJobController = undefined; } } else if (args[0] === 'start') await start(config); else usage(); }
|
|
212
75
|
} catch (error) { console.error(`pulse-runner: ${sanitize(error.message)}`); process.exitCode = 1; }
|
package/src/job.js
CHANGED
|
@@ -10,15 +10,15 @@ export function jobPayload(job) {
|
|
|
10
10
|
export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.now()) {
|
|
11
11
|
const agent = typeof agentOrNow === 'object' && agentOrNow !== null ? agentOrNow : null;
|
|
12
12
|
const now = typeof agentOrNow === 'number' ? agentOrNow : suppliedNow;
|
|
13
|
-
if (!job || job.signatureAlgorithm !== 'ed25519' || job.signingKeyId !== config.signingKeyId) throw new Error('Job signing key is not trusted.');
|
|
13
|
+
if (!job || typeof job.signature !== 'string' || job.signatureAlgorithm !== 'ed25519' || job.signingKeyId !== config.signingKeyId) throw new Error('Job signing key is not trusted.');
|
|
14
14
|
if (!config.signingPublicKey || !verify(null, Buffer.from(jobPayload(job)), config.signingPublicKey, b64(job.signature))) throw new Error('Job signature is invalid.');
|
|
15
|
-
|
|
15
|
+
const issuedAt = Date.parse(job.issuedAt);
|
|
16
|
+
const expiresAt = Date.parse(job.expiresAt);
|
|
17
|
+
if (!/^rjob-[A-Za-z0-9_-]+$/.test(job.id || '') || !Number.isFinite(issuedAt) || !Number.isFinite(expiresAt) || issuedAt > now || expiresAt <= now || expiresAt <= issuedAt || !['task', 'rework', 'review', 'handoff'].includes(job.mode)) throw new Error('Job is expired or malformed.');
|
|
16
18
|
if (job.workspaceId !== config.workspaceId || job.runnerId !== config.runnerId) throw new Error('Job identity does not match this Runner.');
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
throw new Error('Job identity does not match this Runner.');
|
|
21
|
-
}
|
|
19
|
+
const identity = (config.identities || [{ agentId: config.agentId, kind: config.agentKind, role: config.agentRole || 'dev' }]).find((entry) => entry.agentId === job.agentId);
|
|
20
|
+
const role = job.mode === 'review' ? 'qa' : 'dev';
|
|
21
|
+
if (!identity || identity.role !== role || (agent && (agent.kind !== identity.kind || agent.role !== role))) throw new Error('Job identity does not match this Runner provider/role.');
|
|
22
22
|
const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])];
|
|
23
23
|
if (!contextRepos.includes(job.repoFullName) || contextRepos.some((repo) => !config.repos.includes(repo))) throw new Error('Job repository context is not allowed by this Runner.');
|
|
24
24
|
return true;
|
package/src/process.js
CHANGED
|
@@ -3,12 +3,43 @@ import { existsSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
|
-
export function command(
|
|
6
|
+
export function command(executable, args, options = {}) {
|
|
7
7
|
return new Promise((resolve, reject) => {
|
|
8
|
-
|
|
8
|
+
if (options.signal?.aborted) { resolve({ code: null, output: '' }); return; }
|
|
9
|
+
// Node CLI entrypoints can be used directly on every supported OS.
|
|
10
|
+
const isScript = /\.(?:cjs|mjs|js)$/.test(executable);
|
|
11
|
+
const child = spawn(isScript ? process.execPath : executable, isScript ? [executable, ...args] : args, {
|
|
12
|
+
cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'],
|
|
13
|
+
windowsHide: true, detached: process.platform !== 'win32',
|
|
14
|
+
});
|
|
15
|
+
let escalation;
|
|
16
|
+
const killTree = (signal) => {
|
|
17
|
+
try {
|
|
18
|
+
if (process.platform === 'win32') {
|
|
19
|
+
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
20
|
+
killer.on('error', () => { try { child.kill(signal); } catch {} });
|
|
21
|
+
} else process.kill(-child.pid, signal);
|
|
22
|
+
} catch {}
|
|
23
|
+
};
|
|
24
|
+
const abort = () => {
|
|
25
|
+
killTree('SIGTERM');
|
|
26
|
+
escalation = setTimeout(() => killTree('SIGKILL'), 2000);
|
|
27
|
+
escalation.unref();
|
|
28
|
+
};
|
|
29
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
30
|
+
const timer = options.timeout ? setTimeout(abort, options.timeout) : null;
|
|
9
31
|
let output = '';
|
|
10
|
-
|
|
11
|
-
child.on('
|
|
32
|
+
const append = (chunk) => { output = (output + chunk).slice(-(options.maxOutput || 1_000_000)); };
|
|
33
|
+
child.stdout.on('data', append); child.stderr.on('data', append);
|
|
34
|
+
const cleanup = () => {
|
|
35
|
+
if (timer) clearTimeout(timer);
|
|
36
|
+
// Kill any descendants before releasing the process group on cancellation.
|
|
37
|
+
if (options.signal?.aborted || escalation) killTree('SIGKILL');
|
|
38
|
+
if (escalation) clearTimeout(escalation);
|
|
39
|
+
options.signal?.removeEventListener('abort', abort);
|
|
40
|
+
};
|
|
41
|
+
child.on('error', (error) => { cleanup(); reject(error); });
|
|
42
|
+
child.on('close', (code) => { cleanup(); resolve({ code, output }); });
|
|
12
43
|
});
|
|
13
44
|
}
|
|
14
45
|
|
|
@@ -34,14 +65,30 @@ export function agentPermissionArgs(kind, mode) {
|
|
|
34
65
|
return [];
|
|
35
66
|
}
|
|
36
67
|
|
|
68
|
+
export function mcpEndpoint(endpoint) {
|
|
69
|
+
return endpoint.replace(/\/(?:pulseRunner\w*|pulseMcp)\/?$/, '').replace(/\/$/, '') + '/pulseMcp';
|
|
70
|
+
}
|
|
71
|
+
|
|
37
72
|
async function mcpConfig(dir, credential, endpoint) {
|
|
38
73
|
const path = join(dir, 'pulse-mcp.json');
|
|
39
74
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
40
|
-
await writeFile(path, JSON.stringify({ mcpServers: { pulse: { type: 'http', url: endpoint
|
|
75
|
+
await writeFile(path, JSON.stringify({ mcpServers: { pulse: { type: 'http', url: mcpEndpoint(endpoint), headers: { Authorization: `Bearer ${credential}` } } } }), { mode: 0o600 });
|
|
41
76
|
return path;
|
|
42
77
|
}
|
|
43
78
|
|
|
44
|
-
export
|
|
79
|
+
export function providerInvocation(kind, worktree, additionalDirs, config, mcp, prompt, mode = 'task') {
|
|
80
|
+
if (kind === 'codex') return {
|
|
81
|
+
executable: resolveExecutable('codex'),
|
|
82
|
+
args: ['exec', '--ephemeral', ...agentPermissionArgs(kind, mode), '--cd', worktree, ...additionalDirs, '-c', `mcp_servers.pulse.url=${JSON.stringify(mcpEndpoint(config.endpoint))}`, '-c', 'mcp_servers.pulse.env_http_headers={Authorization="PULSE_RUNNER_MCP_CREDENTIAL"}', prompt],
|
|
83
|
+
};
|
|
84
|
+
if (kind === 'claude') return {
|
|
85
|
+
executable: resolveExecutable('claude'),
|
|
86
|
+
args: ['--print', '--no-session-persistence', ...agentPermissionArgs(kind, mode), ...additionalDirs, '--strict-mcp-config', '--mcp-config', mcp, prompt],
|
|
87
|
+
};
|
|
88
|
+
throw new Error(`Unsupported agent kind '${kind}'.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runAgent(kind, job, worktree, additionalWorktrees, config, credential, tempDir, options = {}) {
|
|
45
92
|
const context = additionalWorktrees.length
|
|
46
93
|
? job.mode === 'review'
|
|
47
94
|
? ` Additional review worktrees for inspection: ${additionalWorktrees.map(({ repo, worktree: path }) => `${repo} (${path})`).join('; ')}.`
|
|
@@ -53,14 +100,8 @@ export async function runAgent(kind, job, worktree, additionalWorktrees, config,
|
|
|
53
100
|
const mcp = await mcpConfig(tempDir, credential, config.endpoint);
|
|
54
101
|
const additionalDirs = additionalWorktrees.flatMap(({ worktree: path }) => ['--add-dir', path]);
|
|
55
102
|
try {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return await command(resolveExecutable('codex'), ['exec', '--ephemeral', ...agentPermissionArgs(kind, job.mode), '--cd', worktree, ...additionalDirs, '-c', `mcp_servers.pulse.url="${config.endpoint.replace(/\"/g, '\\\"')}/pulseMcp"`, '-c', `mcp_servers.pulse.http_headers={Authorization="Bearer ${credential}"}`, prompt], { cwd: worktree });
|
|
60
|
-
}
|
|
61
|
-
if (kind === 'claude') {
|
|
62
|
-
return await command(resolveExecutable('claude'), ['--print', '--no-session-persistence', ...agentPermissionArgs(kind, job.mode), ...additionalDirs, '--strict-mcp-config', '--mcp-config', mcp, prompt], { cwd: worktree });
|
|
63
|
-
}
|
|
64
|
-
throw new Error(`Unsupported agent kind '${kind}'.`);
|
|
103
|
+
const invocation = providerInvocation(kind, worktree, additionalDirs, config, mcp, prompt, job.mode);
|
|
104
|
+
const env = kind === 'codex' ? { ...process.env, PULSE_RUNNER_MCP_CREDENTIAL: `Bearer ${credential}` } : process.env;
|
|
105
|
+
return await command(invocation.executable, invocation.args, { cwd: worktree, env, signal: options.signal });
|
|
65
106
|
} finally { await rm(mcp, { force: true }); }
|
|
66
107
|
}
|
package/src/readiness.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { command, resolveExecutable } from './process.js';
|
|
2
|
+
|
|
3
|
+
export function localIdentities(config) {
|
|
4
|
+
return config.identities || [{ agentId: config.agentId, kind: config.agentKind, role: config.agentRole || 'dev' }];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function inspectReadiness(config, run = command) {
|
|
8
|
+
const providers = {};
|
|
9
|
+
for (const kind of ['codex', 'claude']) {
|
|
10
|
+
const cli = await run(resolveExecutable(kind), ['--version'], { timeout: 10_000, maxOutput: 4096 }).then((result) => result.code === 0).catch(() => false);
|
|
11
|
+
const session = cli && await run(resolveExecutable(kind), kind === 'codex' ? ['login', 'status'] : ['auth', 'status'], { timeout: 10_000, maxOutput: 4096 }).then((result) => {
|
|
12
|
+
if (result.code !== 0) return false;
|
|
13
|
+
// Claude can exit successfully while reporting loggedIn:false. Never
|
|
14
|
+
// return its raw JSON (which may include account identifiers).
|
|
15
|
+
if (kind === 'claude') { try { return JSON.parse(result.output).loggedIn === true; } catch { return false; } }
|
|
16
|
+
return true;
|
|
17
|
+
}).catch(() => false);
|
|
18
|
+
providers[kind] = { cli, session: !!session };
|
|
19
|
+
}
|
|
20
|
+
const repositories = [];
|
|
21
|
+
for (const repo of config.repos) {
|
|
22
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error('Invalid repository.');
|
|
23
|
+
const accessible = await run('git', ['ls-remote', '--exit-code', `https://github.com/${repo}.git`, 'HEAD'], { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, timeout: 10_000, maxOutput: 4096 }).then((result) => result.code === 0).catch(() => false);
|
|
24
|
+
repositories.push({ repo, accessible });
|
|
25
|
+
}
|
|
26
|
+
return { workspaceId: config.workspaceId, identities: localIdentities(config), providers, repositories };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertLocalReadiness(job, config, agent, readiness) {
|
|
30
|
+
const identity = localIdentities(config).find((entry) => entry.agentId === job.agentId);
|
|
31
|
+
const role = job.mode === 'review' ? 'qa' : 'dev';
|
|
32
|
+
if (!identity || identity.kind !== agent.kind || identity.role !== role || agent.role !== role) throw new Error('Local agent identity/provider/role mismatch. Run pulse-runner identity add with this agent configuration.');
|
|
33
|
+
if (!readiness.providers[agent.kind]?.cli || !readiness.providers[agent.kind]?.session) throw new Error(`Local ${agent.kind} CLI/session is unavailable. Sign in locally and run pulse-runner diagnose.`);
|
|
34
|
+
if ((job.contextRepos || [job.repoFullName]).some((repo) => !readiness.repositories.some((entry) => entry.repo === repo && entry.accessible))) throw new Error('Local repository access is unavailable. Check Git authentication and run pulse-runner diagnose.');
|
|
35
|
+
}
|