@pulsehub/runner 0.1.2 → 0.1.4
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 +25 -1
- package/package.json +1 -1
- package/src/index.js +137 -23
- package/src/job.js +8 -6
- package/src/process.js +10 -4
- package/src/readiness.js +35 -0
- package/test/job.test.js +11 -0
- package/test/process.test.js +7 -1
- package/test/readiness.test.js +34 -0
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.
|
|
@@ -14,4 +16,26 @@ The Runner only uses local Git and provider sessions, creates disposable worktre
|
|
|
14
16
|
|
|
15
17
|
## QA review jobs
|
|
16
18
|
|
|
17
|
-
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).
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { inspectReadiness, assertLocalReadiness, localIdentities } from './readiness.js';
|
|
2
3
|
import { generateKeyPairSync } from 'node:crypto';
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
4
|
-
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { appendFile, chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
import { platform } from 'node:os';
|
|
7
8
|
import { ensureDataDir, paths, readConfig, readCredential, removeTemporary, writeConfig, writeCredential } from './config.js';
|
|
8
9
|
import { validateJob, sanitize } from './job.js';
|
|
9
|
-
import { command,
|
|
10
|
+
import { command, runAgent } from './process.js';
|
|
10
11
|
|
|
11
12
|
const DEFAULT_ENDPOINT = 'https://us-east4-pulse-app-93.cloudfunctions.net';
|
|
12
13
|
const args = process.argv.slice(2);
|
|
13
14
|
const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; };
|
|
14
15
|
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
|
+
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`);
|
|
17
|
+
|
|
18
|
+
async function logEvent(event, details = {}) {
|
|
19
|
+
try {
|
|
20
|
+
const p = await ensureDataDir();
|
|
21
|
+
const safeDetails = Object.fromEntries(Object.entries(details).map(([key, value]) => [
|
|
22
|
+
key,
|
|
23
|
+
typeof value === 'string' ? sanitize(value) : value,
|
|
24
|
+
]));
|
|
25
|
+
const file = join(p.logs, 'runner.jsonl');
|
|
26
|
+
await appendFile(file, `${JSON.stringify({ timestamp: new Date().toISOString(), event, ...safeDetails })}\n`, { mode: 0o600 });
|
|
27
|
+
await chmod(file, 0o600);
|
|
28
|
+
} catch {
|
|
29
|
+
// Logging must not turn a successfully completed job into a failed one.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
16
32
|
|
|
17
33
|
async function request(config, path, body) {
|
|
18
34
|
const credential = await readCredential();
|
|
@@ -23,6 +39,24 @@ async function request(config, path, body) {
|
|
|
23
39
|
}
|
|
24
40
|
|
|
25
41
|
function repoPath(repo) { return repo.replace('/', '__'); }
|
|
42
|
+
function failureDetail(output) {
|
|
43
|
+
return sanitize(output).split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-3).join(' | ').slice(-240);
|
|
44
|
+
}
|
|
45
|
+
function completionSummary(result, publication) {
|
|
46
|
+
const entries = [{
|
|
47
|
+
label: result.code === 0 ? 'Agent completed' : `Agent failed (exit ${result.code})`,
|
|
48
|
+
detail: result.code === 0 ? '' : failureDetail(result.output),
|
|
49
|
+
}];
|
|
50
|
+
for (const published of publication.succeeded) entries.push({ label: 'Publication succeeded', detail: published });
|
|
51
|
+
for (const failed of publication.failed) entries.push({
|
|
52
|
+
label: `Publication failed for ${failed.repo} during ${failed.phase}`,
|
|
53
|
+
detail: failureDetail(failed.error),
|
|
54
|
+
});
|
|
55
|
+
const detailCount = entries.filter((entry) => entry.detail).length;
|
|
56
|
+
const prefixSize = entries.reduce((sum, entry) => sum + entry.label.length + (entry.detail ? 2 : 0), 0) + entries.length - 1;
|
|
57
|
+
const detailBudget = detailCount ? Math.max(0, Math.floor((480 - prefixSize) / detailCount)) : 0;
|
|
58
|
+
return entries.map(({ label, detail }) => detail && detailBudget ? `${label}: ${detail.slice(-detailBudget)}` : label).join('\n').slice(-500);
|
|
59
|
+
}
|
|
26
60
|
async function checkoutRepo(repo, worktree) {
|
|
27
61
|
const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
|
|
28
62
|
const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
|
|
@@ -56,47 +90,107 @@ async function cleanup(checkouts) {
|
|
|
56
90
|
await removeTemporary(worktree);
|
|
57
91
|
}));
|
|
58
92
|
}
|
|
59
|
-
async function publishCheckout(checkout) {
|
|
93
|
+
async function publishCheckout(checkout, jobId) {
|
|
60
94
|
const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current']);
|
|
61
95
|
const branch = branchResult.output.trim();
|
|
62
|
-
if (!branch || !branch.startsWith('pul/'))
|
|
96
|
+
if (!branch || !branch.startsWith('pul/')) {
|
|
97
|
+
await logEvent('publication.skipped', { jobId, repo: checkout.repo, reason: 'branch-not-pulse' });
|
|
98
|
+
return '';
|
|
99
|
+
}
|
|
100
|
+
let phase = 'validate-commit';
|
|
63
101
|
const status = await command('git', ['-C', checkout.worktree, 'status', '--porcelain']);
|
|
64
102
|
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.`);
|
|
103
|
+
if (uncommitted.length > 0) throw Object.assign(new Error(`Uncommitted changes remain in ${checkout.repo}; the agent must commit every changed worktree.`), { phase });
|
|
104
|
+
phase = 'inspect-commit';
|
|
66
105
|
const head = await command('git', ['-C', checkout.worktree, 'rev-parse', 'HEAD']);
|
|
67
|
-
if (head.code
|
|
106
|
+
if (head.code) throw Object.assign(new Error(sanitize(head.output)), { phase });
|
|
107
|
+
if (head.output.trim() === checkout.baseCommit) {
|
|
108
|
+
await logEvent('publication.skipped', { jobId, repo: checkout.repo, branch, reason: 'no-commits' });
|
|
109
|
+
return '';
|
|
110
|
+
}
|
|
68
111
|
// Sólo se publica una rama de trabajo Pulse de un worktree firmado como
|
|
69
112
|
// contexto de este job. El Runner no elige repos ni destinos externos.
|
|
113
|
+
phase = 'push';
|
|
114
|
+
await logEvent('publication.started', { jobId, repo: checkout.repo, branch, phase });
|
|
70
115
|
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));
|
|
116
|
+
if (pushed.code) throw Object.assign(new Error(sanitize(pushed.output)), { phase });
|
|
117
|
+
await logEvent('publication.pushed', { jobId, repo: checkout.repo, branch, phase });
|
|
118
|
+
phase = 'find-pull-request';
|
|
72
119
|
const existing = await command('gh', ['pr', 'view', branch, '--repo', checkout.repo, '--json', 'url']);
|
|
73
|
-
if (existing.code === 0)
|
|
120
|
+
if (existing.code === 0) {
|
|
121
|
+
await logEvent('publication.pull-request-exists', { jobId, repo: checkout.repo, branch });
|
|
122
|
+
return `Published ${checkout.repo}:${branch}; existing PR detected.`;
|
|
123
|
+
}
|
|
124
|
+
if (!/no pull requests found/i.test(existing.output)) throw Object.assign(new Error(sanitize(existing.output)), { phase });
|
|
125
|
+
phase = 'create-pull-request';
|
|
74
126
|
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));
|
|
127
|
+
if (created.code) throw Object.assign(new Error(sanitize(created.output)), { phase });
|
|
128
|
+
await logEvent('publication.pull-request-created', { jobId, repo: checkout.repo, branch, url: created.output.trim() });
|
|
76
129
|
return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
|
|
77
130
|
}
|
|
78
|
-
async function publish(checkouts) {
|
|
79
|
-
const publications = [];
|
|
131
|
+
async function publish(checkouts, jobId) {
|
|
132
|
+
const publications = { succeeded: [], failed: [] };
|
|
80
133
|
for (const checkout of checkouts) {
|
|
81
|
-
|
|
82
|
-
|
|
134
|
+
try {
|
|
135
|
+
const published = await publishCheckout(checkout, jobId);
|
|
136
|
+
if (published) publications.succeeded.push(published);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
const failure = { repo: checkout.repo, phase: error.phase || 'prepare', error: sanitize(error.message) };
|
|
139
|
+
publications.failed.push(failure);
|
|
140
|
+
await logEvent('publication.failed', { jobId, ...failure });
|
|
141
|
+
}
|
|
83
142
|
}
|
|
84
|
-
return publications
|
|
143
|
+
return publications;
|
|
144
|
+
}
|
|
145
|
+
let readinessCache;
|
|
146
|
+
async function readinessFor(config, force = false) {
|
|
147
|
+
const key = JSON.stringify([config.workspaceId, localIdentities(config), config.repos]);
|
|
148
|
+
if (force || !readinessCache || readinessCache.key !== key || Date.now() - readinessCache.at > 60_000) readinessCache = { key, at: Date.now(), value: await inspectReadiness(config) };
|
|
149
|
+
return readinessCache.value;
|
|
85
150
|
}
|
|
86
151
|
async function runOnce(config) {
|
|
87
|
-
await
|
|
152
|
+
const readiness = await readinessFor(config);
|
|
153
|
+
await request(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
|
|
88
154
|
const delivery = await request(config, 'pulseRunnerPoll', {}); if (!delivery.job) return false;
|
|
89
155
|
const job = delivery.job;
|
|
156
|
+
await logEvent('job.received', { jobId: job.id, issueId: job.issueId, agentId: job.agentId, mode: job.mode, repo: job.repoFullName });
|
|
157
|
+
let jobPhase = 'validate-job';
|
|
90
158
|
try {
|
|
91
159
|
const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
|
|
92
|
-
validateJob(job, config, agent);
|
|
160
|
+
validateJob(job, config, agent);
|
|
161
|
+
jobPhase = 'local-preflight';
|
|
162
|
+
assertLocalReadiness(job, config, agent, await readinessFor(config, true));
|
|
163
|
+
await logEvent('job.validated', { jobId: job.id, repoCount: (job.contextRepos || [job.repoFullName]).length });
|
|
164
|
+
jobPhase = 'prepare-worktrees';
|
|
165
|
+
await request(config, 'pulseRunnerHeartbeat', { status: 'busy' });
|
|
93
166
|
const { primary, checkouts } = await checkout(job); const temp = join(paths().root, 'tmp', job.id);
|
|
94
167
|
try {
|
|
168
|
+
jobPhase = 'run-agent';
|
|
169
|
+
await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
|
|
95
170
|
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((checkout) => checkout !== primary), config, delivery.mcpCredential, temp);
|
|
96
|
-
const
|
|
97
|
-
await
|
|
171
|
+
const shouldPublish = result.code === 0 && job.mode !== 'review';
|
|
172
|
+
await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', {
|
|
173
|
+
jobId: job.id,
|
|
174
|
+
exitCode: result.code,
|
|
175
|
+
...(result.code === 0 ? {} : { error: failureDetail(result.output) }),
|
|
176
|
+
});
|
|
177
|
+
const publication = shouldPublish ? await publish(checkouts, job.id) : { succeeded: [], failed: [] };
|
|
178
|
+
const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
|
|
179
|
+
const summary = completionSummary(result, publication);
|
|
180
|
+
const failure = outcome === 'failed' ? { phase: result.code !== 0 ? 'run-agent' : publication.failed[0]?.phase || 'publication', category: 'execution', correlationId: job.id } : null;
|
|
181
|
+
jobPhase = 'report-completion';
|
|
182
|
+
await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome, result: sanitize(summary), failure });
|
|
183
|
+
await logEvent('job.completed', { jobId: job.id, outcome, publicationFailures: publication.failed.length });
|
|
98
184
|
} finally { await removeTemporary(temp); await cleanup(checkouts); await removeTemporary(join(paths().worktrees, job.id)); }
|
|
99
|
-
} catch (error) {
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const phase = error.phase || jobPhase;
|
|
187
|
+
const message = error?.message || String(error);
|
|
188
|
+
await logEvent('job.failed', { jobId: job.id, issueId: job.issueId, repo: job.repoFullName, phase, error: message });
|
|
189
|
+
await request(config, 'pulseRunnerComplete', { jobId: job.id, outcome: 'failed', result: sanitize(`${phase}: ${message}`), failure: { phase, category: phase === 'validate-job' ? 'configuration' : ['local-preflight', 'prepare-worktrees'].includes(phase) ? 'local_preparation' : 'execution', correlationId: job.id } }).catch((completionError) => {
|
|
190
|
+
logEvent('job.completion-report-failed', { jobId: job.id, error: completionError?.message || String(completionError) });
|
|
191
|
+
});
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
100
194
|
finally { await request(config, 'pulseRunnerHeartbeat', { status: 'online' }).catch(() => {}); }
|
|
101
195
|
return true;
|
|
102
196
|
}
|
|
@@ -104,14 +198,34 @@ async function runOnce(config) {
|
|
|
104
198
|
async function init() {
|
|
105
199
|
const publicKeyPath = value('--signing-public-key'); if (!publicKeyPath || !value('--workspace') || !value('--agent')) throw new Error('init requires --workspace, --agent and --signing-public-key.');
|
|
106
200
|
if (!has('--allow-autonomous')) throw new Error('Autonomous agent execution requires explicit --allow-autonomous confirmation.');
|
|
201
|
+
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.');
|
|
107
202
|
const { publicKey } = generateKeyPairSync('ed25519'); const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
108
203
|
const signingPublicKey = await readFile(publicKeyPath, 'utf8');
|
|
109
|
-
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 });
|
|
204
|
+
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 });
|
|
110
205
|
console.log(`Public key for pairing:\n${publicKeyPem}\n\nPaste it in Pulse Settings, then run: pulse-runner pair --credential <runner credential>`);
|
|
111
206
|
}
|
|
112
207
|
async function syncRepos(config) { await request(config, 'pulseRunnerConfigure', { connectedRepos: config.repos }); }
|
|
113
208
|
async function repo(config) { const op = args[1]; const name = args[2]; if (op === 'list') return console.log(config.repos.join('\n')); if (!name || !/^[^/\s]+\/[^/\s]+$/.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 writeConfig(updated); await syncRepos(updated); }
|
|
114
|
-
async function diagnose(config) {
|
|
209
|
+
async function diagnose(config) {
|
|
210
|
+
const readiness = await readinessFor(config, true);
|
|
211
|
+
console.log(JSON.stringify(readiness, null, 2));
|
|
212
|
+
if (await readCredential()) await request(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
|
|
213
|
+
const providers = localIdentities(config).map((identity) => readiness.providers[identity.kind]);
|
|
214
|
+
if (providers.some((provider) => !provider?.cli || !provider?.session) || readiness.repositories.some((repo) => !repo.accessible)) process.exitCode = 1;
|
|
215
|
+
}
|
|
216
|
+
async function identity(config) {
|
|
217
|
+
if (args[1] === 'list') return console.log(JSON.stringify(localIdentities(config), null, 2));
|
|
218
|
+
const agentId = value('--agent');
|
|
219
|
+
const kind = value('--agent-kind');
|
|
220
|
+
const role = value('--agent-role');
|
|
221
|
+
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].');
|
|
222
|
+
if (args[1] === 'add' && (!['codex', 'claude'].includes(kind) || !['dev', 'qa'].includes(role))) throw new Error('identity add requires a valid provider and role.');
|
|
223
|
+
const identities = localIdentities(config).filter((entry) => entry.agentId !== agentId);
|
|
224
|
+
if (args[1] === 'add') identities.push({ agentId, kind, role });
|
|
225
|
+
const updated = { ...config, identities };
|
|
226
|
+
await writeConfig(updated);
|
|
227
|
+
await diagnose(updated);
|
|
228
|
+
}
|
|
115
229
|
async function service(config) {
|
|
116
230
|
const action = args[1]; const p = paths(); const definition = join(p.root, 'service-install.json'); const pidFile = join(p.root, 'service.pid');
|
|
117
231
|
if (action === 'install') { await mkdir(p.logs, { recursive: true, mode: 0o700 }); await writeFile(definition, JSON.stringify({ command: process.execPath, args: [new URL('./index.js', import.meta.url).pathname, 'start'], platform: platform() }, null, 2), { mode: 0o600 }); return console.log(`Service definition prepared at ${definition}.`); }
|
|
@@ -125,5 +239,5 @@ try {
|
|
|
125
239
|
if (!args.length || has('--help')) usage();
|
|
126
240
|
else if (args[0] === 'init') await init();
|
|
127
241
|
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(); }
|
|
242
|
+
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(config); else if (args[0] === 'run') await runOnce(config); else if (args[0] === 'start') { let backoff = 5_000; for (;;) { try { const ranJob = await runOnce((await readConfig()) || 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
243
|
} catch (error) { console.error(`pulse-runner: ${sanitize(error.message)}`); process.exitCode = 1; }
|
package/src/job.js
CHANGED
|
@@ -14,16 +14,18 @@ export function validateJob(job, config, agentOrNow = null, suppliedNow = Date.n
|
|
|
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
|
if (!job.id?.startsWith('rjob-') || new Date(job.expiresAt).getTime() <= now) throw new Error('Job is expired or malformed.');
|
|
16
16
|
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
|
-
}
|
|
17
|
+
const identity = (config.identities || [{ agentId: config.agentId, kind: config.agentKind, role: config.agentRole || 'dev' }]).find((entry) => entry.agentId === job.agentId);
|
|
18
|
+
const role = job.mode === 'review' ? 'qa' : 'dev';
|
|
19
|
+
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
20
|
const contextRepos = [...new Set(job.contextRepos || [job.repoFullName])];
|
|
23
21
|
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
22
|
return true;
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
export function sanitize(value) {
|
|
28
|
-
return String(value || '')
|
|
26
|
+
return String(value || '')
|
|
27
|
+
.replace(/\b(runner-[A-Za-z0-9_-]{12}\.)[A-Za-z0-9._-]{32,}/gi, '$1[redacted]')
|
|
28
|
+
.replace(/\b(?:sk-ant-|sk-proj-|pulse_sk_|ghp_|gho_|ghu_|ghs_|github_pat_|sk-)[A-Za-z0-9_-]+/gi, '[redacted]')
|
|
29
|
+
.replace(/\bBearer\s+[A-Za-z0-9._-]+/gi, 'Bearer [redacted]')
|
|
30
|
+
.slice(-500);
|
|
29
31
|
}
|
package/src/process.js
CHANGED
|
@@ -6,9 +6,11 @@ import { join } from 'node:path';
|
|
|
6
6
|
export function command(command, args, options = {}) {
|
|
7
7
|
return new Promise((resolve, reject) => {
|
|
8
8
|
const child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
9
|
+
const timer = options.timeout ? setTimeout(() => child.kill('SIGKILL'), options.timeout) : null;
|
|
9
10
|
let output = '';
|
|
10
|
-
|
|
11
|
-
child.on('
|
|
11
|
+
const append = (chunk) => { output = (output + chunk).slice(-(options.maxOutput || 1_000_000)); };
|
|
12
|
+
child.stdout.on('data', append); child.stderr.on('data', append);
|
|
13
|
+
child.on('error', (error) => { if (timer) clearTimeout(timer); reject(error); }); child.on('close', (code) => { if (timer) clearTimeout(timer); resolve({ code, output }); });
|
|
12
14
|
});
|
|
13
15
|
}
|
|
14
16
|
|
|
@@ -34,10 +36,14 @@ export function agentPermissionArgs(kind, mode) {
|
|
|
34
36
|
return [];
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
export function mcpEndpoint(endpoint) {
|
|
40
|
+
return endpoint.replace(/\/(?:pulseRunner\w*|pulseMcp)\/?$/, '').replace(/\/$/, '') + '/pulseMcp';
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
async function mcpConfig(dir, credential, endpoint) {
|
|
38
44
|
const path = join(dir, 'pulse-mcp.json');
|
|
39
45
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
40
|
-
await writeFile(path, JSON.stringify({ mcpServers: { pulse: { type: 'http', url: endpoint
|
|
46
|
+
await writeFile(path, JSON.stringify({ mcpServers: { pulse: { type: 'http', url: mcpEndpoint(endpoint), headers: { Authorization: `Bearer ${credential}` } } } }), { mode: 0o600 });
|
|
41
47
|
return path;
|
|
42
48
|
}
|
|
43
49
|
|
|
@@ -56,7 +62,7 @@ export async function runAgent(kind, job, worktree, additionalWorktrees, config,
|
|
|
56
62
|
// `--approve-for-me` selects Codex's workspace-write sandbox itself.
|
|
57
63
|
// Supplying both flags is rejected by current Codex releases.
|
|
58
64
|
if (kind === 'codex') {
|
|
59
|
-
return await command(resolveExecutable('codex'), ['exec', '--ephemeral', ...agentPermissionArgs(kind, job.mode), '--cd', worktree, ...additionalDirs, '-c', `mcp_servers.pulse.url="${config.endpoint.replace(/\"/g, '\\\"')}
|
|
65
|
+
return await command(resolveExecutable('codex'), ['exec', '--ephemeral', ...agentPermissionArgs(kind, job.mode), '--cd', worktree, ...additionalDirs, '-c', `mcp_servers.pulse.url="${mcpEndpoint(config.endpoint).replace(/\"/g, '\\\"')}"`, '-c', `mcp_servers.pulse.http_headers={Authorization="Bearer ${credential}"}`, prompt], { cwd: worktree });
|
|
60
66
|
}
|
|
61
67
|
if (kind === 'claude') {
|
|
62
68
|
return await command(resolveExecutable('claude'), ['--print', '--no-session-persistence', ...agentPermissionArgs(kind, job.mode), ...additionalDirs, '--strict-mcp-config', '--mcp-config', mcp, prompt], { cwd: worktree });
|
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
|
+
}
|
package/test/job.test.js
CHANGED
|
@@ -13,3 +13,14 @@ test('accepts a signed, bound job and rejects changed identity', () => {
|
|
|
13
13
|
assert.throws(() => validateJob({ ...job, contextRepos: ['owner/repo'] }, config));
|
|
14
14
|
});
|
|
15
15
|
test('sanitizes provider tokens', () => assert.equal(sanitize('Bearer secret.token sk-ant-secret'), 'Bearer [redacted] [redacted]'));
|
|
16
|
+
|
|
17
|
+
test('signed QA jobs require explicit agent identity and provider/role compatibility', () => {
|
|
18
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
19
|
+
const job = { id: 'rjob-qa', workspaceId: 'ws-1', issueId: 'issue-1', agentId: 'qa-1', runnerId: 'runner-1', repoFullName: 'owner/repo', mode: 'review', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60000).toISOString(), signatureAlgorithm: 'ed25519', signingKeyId: 'key' };
|
|
20
|
+
job.signature = sign(null, Buffer.from(jobPayload(job)), privateKey).toString('base64url');
|
|
21
|
+
const config = { workspaceId: 'ws-1', runnerId: 'runner-1', agentId: 'dev-1', agentKind: 'codex', repos: ['owner/repo'], signingKeyId: 'key', signingPublicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString() };
|
|
22
|
+
assert.throws(() => validateJob(job, config, { kind: 'codex', role: 'qa' }), /identity/);
|
|
23
|
+
config.identities = [{ agentId: 'qa-1', kind: 'codex', role: 'qa' }];
|
|
24
|
+
assert.equal(validateJob(job, config, { kind: 'codex', role: 'qa' }), true);
|
|
25
|
+
assert.throws(() => validateJob(job, config, { kind: 'claude', role: 'qa' }), /provider/);
|
|
26
|
+
});
|
package/test/process.test.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { agentPermissionArgs } from '../src/process.js';
|
|
3
|
+
import { agentPermissionArgs, mcpEndpoint } from '../src/process.js';
|
|
4
4
|
|
|
5
5
|
test('QA Runner sessions use read-only filesystem permissions', () => {
|
|
6
6
|
assert.deepEqual(agentPermissionArgs('codex', 'review'), ['--sandbox', 'read-only', '--ask-for-approval', 'never']);
|
|
@@ -11,3 +11,9 @@ test('development jobs retain their configured write permissions', () => {
|
|
|
11
11
|
assert.deepEqual(agentPermissionArgs('codex', 'task'), ['--approve-for-me']);
|
|
12
12
|
assert.deepEqual(agentPermissionArgs('claude', 'task'), ['--permission-mode', 'bypassPermissions', '--dangerously-skip-permissions']);
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
test('both providers use the same MCP URL for cloud and local emulator endpoints', () => {
|
|
16
|
+
assert.equal(mcpEndpoint('https://example.cloudfunctions.net'), 'https://example.cloudfunctions.net/pulseMcp');
|
|
17
|
+
assert.equal(mcpEndpoint('http://127.0.0.1:5001/demo-pulse-local/us-east4'), 'http://127.0.0.1:5001/demo-pulse-local/us-east4/pulseMcp');
|
|
18
|
+
assert.equal(mcpEndpoint('http://localhost:5001/demo-pulse-local/us-east4/pulseRunnerPoll'), 'http://localhost:5001/demo-pulse-local/us-east4/pulseMcp');
|
|
19
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { inspectReadiness, assertLocalReadiness } from '../src/readiness.js';
|
|
4
|
+
const config = { workspaceId: 'ws-1', agentId: 'agent-1', agentKind: 'codex', repos: ['owner/repo'] };
|
|
5
|
+
const job = { agentId: 'agent-1', repoFullName: 'owner/repo', mode: 'task' };
|
|
6
|
+
const agent = { kind: 'codex', role: 'dev' };
|
|
7
|
+
test('readiness uses native status commands and reports only booleans', async () => {
|
|
8
|
+
const calls = [];
|
|
9
|
+
const report = await inspectReadiness(config, async (cmd, args) => {
|
|
10
|
+
calls.push(args);
|
|
11
|
+
return { code: 0, output: args[0] === 'auth' ? JSON.stringify({ loggedIn: true, email: 'private', token: 'private' }) : 'private' };
|
|
12
|
+
});
|
|
13
|
+
assert.ok(calls.some((args) => args.join(' ') === 'login status'));
|
|
14
|
+
assert.ok(calls.some((args) => args.join(' ') === 'auth status'));
|
|
15
|
+
assert.equal(JSON.stringify(report).includes('private'), false);
|
|
16
|
+
assert.doesNotThrow(() => assertLocalReadiness(job, config, agent, report));
|
|
17
|
+
assert.throws(() => assertLocalReadiness({ ...job, agentId: 'other' }, config, agent, report), /identity/);
|
|
18
|
+
});
|
|
19
|
+
test('CLI missing, session false and Git denial remain unavailable', async () => {
|
|
20
|
+
const report = await inspectReadiness(config, async (cmd, args) => {
|
|
21
|
+
if (cmd.includes('codex')) throw new Error('missing');
|
|
22
|
+
return { code: args[0] === 'ls-remote' ? 1 : 0, output: '{"loggedIn":false}' };
|
|
23
|
+
});
|
|
24
|
+
assert.equal(report.providers.codex.cli, false);
|
|
25
|
+
assert.equal(report.providers.claude.session, false);
|
|
26
|
+
assert.equal(report.repositories[0].accessible, false);
|
|
27
|
+
assert.throws(() => assertLocalReadiness(job, config, agent, report), /CLI/);
|
|
28
|
+
});
|
|
29
|
+
test('QA identity is explicitly allowed and cannot execute development jobs', async () => {
|
|
30
|
+
const qaConfig = { ...config, identities: [{ agentId: 'qa-1', kind: 'claude', role: 'qa' }] };
|
|
31
|
+
const report = await inspectReadiness(qaConfig, async () => ({ code: 0, output: '{"loggedIn":true}' }));
|
|
32
|
+
assert.doesNotThrow(() => assertLocalReadiness({ ...job, agentId: 'qa-1', mode: 'review' }, qaConfig, { kind: 'claude', role: 'qa' }, report));
|
|
33
|
+
assert.throws(() => assertLocalReadiness({ ...job, agentId: 'qa-1' }, qaConfig, { kind: 'claude', role: 'qa' }, report), /role/);
|
|
34
|
+
});
|