@pulsehub/runner 0.1.4 → 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 +32 -1
- package/package.json +1 -1
- package/src/config.js +2 -2
- package/src/index.js +29 -197
- package/src/job.js +4 -2
- package/src/process.js +49 -14
- 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 +12 -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
|
|
|
@@ -39,3 +39,34 @@ Backend dispatch requires a recent heartbeat and readiness report, a matching id
|
|
|
39
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
40
|
|
|
41
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,198 +1,38 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { inspectReadiness,
|
|
2
|
+
import { inspectReadiness, localIdentities } from './readiness.js';
|
|
3
3
|
import { generateKeyPairSync } from 'node:crypto';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import { command, 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';
|
|
11
10
|
|
|
12
11
|
const DEFAULT_ENDPOINT = 'https://us-east4-pulse-app-93.cloudfunctions.net';
|
|
13
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
|
+
});
|
|
14
21
|
const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? undefined : args[i + 1]; };
|
|
15
22
|
const has = (flag) => args.includes(flag);
|
|
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
|
-
}
|
|
32
|
-
|
|
33
|
-
async function request(config, path, body) {
|
|
34
|
-
const credential = await readCredential();
|
|
35
|
-
if (!credential) throw new Error('Runner is not paired. Run pulse-runner pair first.');
|
|
36
|
-
const response = await fetch(`${config.endpoint}/${path}`, { method: 'POST', headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
37
|
-
if (!response.ok) throw new Error(`Pulse endpoint rejected request (${response.status}).`);
|
|
38
|
-
return response.json();
|
|
39
|
-
}
|
|
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`);
|
|
40
24
|
|
|
41
|
-
function
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
}
|
|
60
|
-
async function checkoutRepo(repo, worktree) {
|
|
61
|
-
const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
|
|
62
|
-
const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
|
|
63
|
-
if (existing.code !== 0) {
|
|
64
|
-
const cloned = await command('git', ['clone', '--mirror', `https://github.com/${repo}.git`, mirror]);
|
|
65
|
-
if (cloned.code) throw new Error(sanitize(cloned.output));
|
|
66
|
-
}
|
|
67
|
-
const fetchResult = await command('git', ['--git-dir', mirror, 'fetch', '--prune', 'origin']); if (fetchResult.code) throw new Error(sanitize(fetchResult.output));
|
|
68
|
-
const added = await command('git', ['--git-dir', mirror, 'worktree', 'add', '--detach', worktree, 'HEAD']); if (added.code) throw new Error(sanitize(added.output));
|
|
69
|
-
const base = await command('git', ['-C', worktree, 'rev-parse', 'HEAD']);
|
|
70
|
-
if (base.code) throw new Error(sanitize(base.output));
|
|
71
|
-
return { repo, mirror, worktree, baseCommit: base.output.trim() };
|
|
72
|
-
}
|
|
73
|
-
async function checkout(job) {
|
|
74
|
-
const root = join(paths().worktrees, job.id); const repos = [...new Set(job.contextRepos || [job.repoFullName])]; const checkouts = [];
|
|
75
|
-
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
76
|
-
try {
|
|
77
|
-
for (const repo of repos) {
|
|
78
|
-
const checkout = await checkoutRepo(repo, join(root, repoPath(repo)));
|
|
79
|
-
await writeFile(join(checkout.worktree, '.pulse-runner-job'), job.id, { mode: 0o600 });
|
|
80
|
-
checkouts.push(checkout);
|
|
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));
|
|
81
34
|
}
|
|
82
|
-
|
|
83
|
-
if (!primary) throw new Error('Job context does not include its target repository.');
|
|
84
|
-
return { primary, checkouts };
|
|
85
|
-
} catch (error) { await cleanup(checkouts); await removeTemporary(root); throw error; }
|
|
86
|
-
}
|
|
87
|
-
async function cleanup(checkouts) {
|
|
88
|
-
await Promise.all(checkouts.map(async ({ worktree, mirror }) => {
|
|
89
|
-
await command('git', ['--git-dir', mirror, 'worktree', 'remove', '--force', worktree]).catch(() => {});
|
|
90
|
-
await removeTemporary(worktree);
|
|
91
|
-
}));
|
|
92
|
-
}
|
|
93
|
-
async function publishCheckout(checkout, jobId) {
|
|
94
|
-
const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current']);
|
|
95
|
-
const branch = branchResult.output.trim();
|
|
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';
|
|
101
|
-
const status = await command('git', ['-C', checkout.worktree, 'status', '--porcelain']);
|
|
102
|
-
const uncommitted = status.output.split('\n').filter((line) => line && !line.endsWith('.pulse-runner-job'));
|
|
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';
|
|
105
|
-
const head = await command('git', ['-C', checkout.worktree, 'rev-parse', 'HEAD']);
|
|
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
|
-
}
|
|
111
|
-
// Sólo se publica una rama de trabajo Pulse de un worktree firmado como
|
|
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 });
|
|
115
|
-
const pushed = await command('git', ['-c', 'remote.origin.mirror=false', '--git-dir', checkout.mirror, 'push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`]);
|
|
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';
|
|
119
|
-
const existing = await command('gh', ['pr', 'view', branch, '--repo', checkout.repo, '--json', 'url']);
|
|
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';
|
|
126
|
-
const created = await command('gh', ['pr', 'create', '--repo', checkout.repo, '--base', 'main', '--head', branch, '--fill']);
|
|
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() });
|
|
129
|
-
return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
|
|
130
|
-
}
|
|
131
|
-
async function publish(checkouts, jobId) {
|
|
132
|
-
const publications = { succeeded: [], failed: [] };
|
|
133
|
-
for (const checkout of checkouts) {
|
|
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
|
-
}
|
|
142
|
-
}
|
|
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;
|
|
150
|
-
}
|
|
151
|
-
async function runOnce(config) {
|
|
152
|
-
const readiness = await readinessFor(config);
|
|
153
|
-
await request(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
|
|
154
|
-
const delivery = await request(config, 'pulseRunnerPoll', {}); if (!delivery.job) return false;
|
|
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';
|
|
158
|
-
try {
|
|
159
|
-
const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
|
|
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' });
|
|
166
|
-
const { primary, checkouts } = await checkout(job); const temp = join(paths().root, 'tmp', job.id);
|
|
167
|
-
try {
|
|
168
|
-
jobPhase = 'run-agent';
|
|
169
|
-
await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
|
|
170
|
-
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((checkout) => checkout !== primary), config, delivery.mcpCredential, temp);
|
|
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 });
|
|
184
|
-
} finally { await removeTemporary(temp); await cleanup(checkouts); await removeTemporary(join(paths().worktrees, job.id)); }
|
|
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
|
-
}
|
|
194
|
-
finally { await request(config, 'pulseRunnerHeartbeat', { status: 'online' }).catch(() => {}); }
|
|
195
|
-
return true;
|
|
35
|
+
});
|
|
196
36
|
}
|
|
197
37
|
|
|
198
38
|
async function init() {
|
|
@@ -205,9 +45,9 @@ async function init() {
|
|
|
205
45
|
console.log(`Public key for pairing:\n${publicKeyPem}\n\nPaste it in Pulse Settings, then run: pulse-runner pair --credential <runner credential>`);
|
|
206
46
|
}
|
|
207
47
|
async function syncRepos(config) { await request(config, 'pulseRunnerConfigure', { connectedRepos: config.repos }); }
|
|
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 || !/^[
|
|
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); }
|
|
209
49
|
async function diagnose(config) {
|
|
210
|
-
const readiness = await
|
|
50
|
+
const readiness = await inspectReadiness(config);
|
|
211
51
|
console.log(JSON.stringify(readiness, null, 2));
|
|
212
52
|
if (await readCredential()) await request(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
|
|
213
53
|
const providers = localIdentities(config).map((identity) => readiness.providers[identity.kind]);
|
|
@@ -226,18 +66,10 @@ async function identity(config) {
|
|
|
226
66
|
await writeConfig(updated);
|
|
227
67
|
await diagnose(updated);
|
|
228
68
|
}
|
|
229
|
-
async function service(config) {
|
|
230
|
-
const action = args[1]; const p = paths(); const definition = join(p.root, 'service-install.json'); const pidFile = join(p.root, 'service.pid');
|
|
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}.`); }
|
|
232
|
-
if (action === 'status') { try { const { pid } = JSON.parse(await readFile(pidFile, 'utf8')); process.kill(pid, 0); return console.log(`Pulse Runner is running (pid ${pid}).`); } catch { return console.log('Pulse Runner is stopped.'); } }
|
|
233
|
-
if (action === 'start') { try { const { pid } = JSON.parse(await readFile(pidFile, 'utf8')); process.kill(pid, 0); return console.log(`Pulse Runner is already running (pid ${pid}).`); } catch {} const child = spawn(process.execPath, [new URL('./index.js', import.meta.url).pathname, 'start'], { detached: true, stdio: 'ignore', windowsHide: true, env: process.env }); child.unref(); await writeFile(pidFile, JSON.stringify({ pid: child.pid }), { mode: 0o600 }); return console.log(`Pulse Runner started (pid ${child.pid}).`); }
|
|
234
|
-
if (action === 'stop') { try { const { pid } = JSON.parse(await readFile(pidFile, 'utf8')); process.kill(pid, 'SIGTERM'); } catch {} await rm(pidFile, { force: true }); return console.log('Pulse Runner stopped.'); }
|
|
235
|
-
throw new Error('Supported service commands: install, start, stop, status.');
|
|
236
|
-
}
|
|
237
69
|
|
|
238
70
|
try {
|
|
239
71
|
if (!args.length || has('--help')) usage();
|
|
240
72
|
else if (args[0] === 'init') await init();
|
|
241
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.`); }
|
|
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(
|
|
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(); }
|
|
243
75
|
} catch (error) { console.error(`pulse-runner: ${sanitize(error.message)}`); process.exitCode = 1; }
|
package/src/job.js
CHANGED
|
@@ -10,9 +10,11 @@ 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
19
|
const identity = (config.identities || [{ agentId: config.agentId, kind: config.agentKind, role: config.agentRole || 'dev' }]).find((entry) => entry.agentId === job.agentId);
|
|
18
20
|
const role = job.mode === 'review' ? 'qa' : 'dev';
|
package/src/process.js
CHANGED
|
@@ -3,14 +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
|
-
|
|
9
|
-
|
|
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;
|
|
10
31
|
let output = '';
|
|
11
32
|
const append = (chunk) => { output = (output + chunk).slice(-(options.maxOutput || 1_000_000)); };
|
|
12
33
|
child.stdout.on('data', append); child.stderr.on('data', append);
|
|
13
|
-
|
|
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 }); });
|
|
14
43
|
});
|
|
15
44
|
}
|
|
16
45
|
|
|
@@ -47,7 +76,19 @@ async function mcpConfig(dir, credential, endpoint) {
|
|
|
47
76
|
return path;
|
|
48
77
|
}
|
|
49
78
|
|
|
50
|
-
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 = {}) {
|
|
51
92
|
const context = additionalWorktrees.length
|
|
52
93
|
? job.mode === 'review'
|
|
53
94
|
? ` Additional review worktrees for inspection: ${additionalWorktrees.map(({ repo, worktree: path }) => `${repo} (${path})`).join('; ')}.`
|
|
@@ -59,14 +100,8 @@ export async function runAgent(kind, job, worktree, additionalWorktrees, config,
|
|
|
59
100
|
const mcp = await mcpConfig(tempDir, credential, config.endpoint);
|
|
60
101
|
const additionalDirs = additionalWorktrees.flatMap(({ worktree: path }) => ['--add-dir', path]);
|
|
61
102
|
try {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 });
|
|
66
|
-
}
|
|
67
|
-
if (kind === 'claude') {
|
|
68
|
-
return await command(resolveExecutable('claude'), ['--print', '--no-session-persistence', ...agentPermissionArgs(kind, job.mode), ...additionalDirs, '--strict-mcp-config', '--mcp-config', mcp, prompt], { cwd: worktree });
|
|
69
|
-
}
|
|
70
|
-
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 });
|
|
71
106
|
} finally { await rm(mcp, { force: true }); }
|
|
72
107
|
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { appendFile, chmod, mkdir, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ensureDataDir, paths, readCredential, removeTemporary } from './config.js';
|
|
4
|
+
import { validateJob, sanitize } from './job.js';
|
|
5
|
+
import { command, resolveExecutable, runAgent } from './process.js';
|
|
6
|
+
import { addIsolatedWorktree, removeIsolatedWorktree } from './worktree.js';
|
|
7
|
+
import { inspectReadiness, assertLocalReadiness, localIdentities } from './readiness.js';
|
|
8
|
+
|
|
9
|
+
export async function logEvent(event, details = {}) {
|
|
10
|
+
try {
|
|
11
|
+
const p = await ensureDataDir();
|
|
12
|
+
const safeDetails = Object.fromEntries(Object.entries(details).map(([key, value]) => [
|
|
13
|
+
key,
|
|
14
|
+
typeof value === 'string' ? sanitize(value) : value,
|
|
15
|
+
]));
|
|
16
|
+
const file = join(p.logs, 'runner.jsonl');
|
|
17
|
+
await appendFile(file, `${JSON.stringify({ timestamp: new Date().toISOString(), event, ...safeDetails })}\n`, { mode: 0o600 });
|
|
18
|
+
await chmod(file, 0o600);
|
|
19
|
+
} catch {
|
|
20
|
+
// Logging must not turn a successfully completed job into a failed one.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function request(config, path, body) {
|
|
25
|
+
const credential = await readCredential();
|
|
26
|
+
if (!credential) throw new Error('Runner is not paired. Run pulse-runner pair first.');
|
|
27
|
+
const response = await fetch(`${config.endpoint}/${path}`, { method: 'POST', headers: { Authorization: `Bearer ${credential}`, 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) });
|
|
28
|
+
if (!response.ok) throw Object.assign(new Error(`Pulse endpoint rejected request (${response.status}).`), { status: response.status });
|
|
29
|
+
return response.json();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function repoPath(repo) { return repo.replace('/', '__'); }
|
|
33
|
+
function failureDetail(output) {
|
|
34
|
+
return sanitize(output).split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-3).join(' | ').slice(-240);
|
|
35
|
+
}
|
|
36
|
+
function completionSummary(result, publication) {
|
|
37
|
+
const entries = [{
|
|
38
|
+
label: result.code === 0 ? 'Agent completed' : `Agent failed (exit ${result.code})`,
|
|
39
|
+
detail: result.code === 0 ? '' : failureDetail(result.output),
|
|
40
|
+
}];
|
|
41
|
+
for (const published of publication.succeeded) entries.push({ label: 'Publication succeeded', detail: published });
|
|
42
|
+
for (const failed of publication.failed) entries.push({
|
|
43
|
+
label: `Publication failed for ${failed.repo} during ${failed.phase}`,
|
|
44
|
+
detail: failureDetail(failed.error),
|
|
45
|
+
});
|
|
46
|
+
const detailCount = entries.filter((entry) => entry.detail).length;
|
|
47
|
+
const prefixSize = entries.reduce((sum, entry) => sum + entry.label.length + (entry.detail ? 2 : 0), 0) + entries.length - 1;
|
|
48
|
+
const detailBudget = detailCount ? Math.max(0, Math.floor((480 - prefixSize) / detailCount)) : 0;
|
|
49
|
+
return entries.map(({ label, detail }) => detail && detailBudget ? `${label}: ${detail.slice(-detailBudget)}` : label).join('\n').slice(-500);
|
|
50
|
+
}
|
|
51
|
+
async function checkoutRepo(repo, worktree, signal) {
|
|
52
|
+
const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
|
|
53
|
+
const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
|
|
54
|
+
if (existing.code !== 0) {
|
|
55
|
+
const cloned = await command('git', ['clone', '--mirror', `https://github.com/${repo}.git`, mirror], { signal, timeout: 120_000 });
|
|
56
|
+
if (cloned.code !== 0) throw new Error(sanitize(cloned.output));
|
|
57
|
+
}
|
|
58
|
+
const fetchResult = await command('git', ['--git-dir', mirror, 'fetch', '--prune', 'origin'], { signal, timeout: 120_000 }); if (fetchResult.code !== 0) throw new Error(sanitize(fetchResult.output));
|
|
59
|
+
return addIsolatedWorktree(repo, mirror, worktree, { signal });
|
|
60
|
+
}
|
|
61
|
+
async function checkout(job, options = {}) {
|
|
62
|
+
const root = join(paths().worktrees, job.id); const repos = [...new Set(job.contextRepos || [job.repoFullName])]; const checkouts = [];
|
|
63
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
64
|
+
try {
|
|
65
|
+
for (const repo of repos) {
|
|
66
|
+
const checkout = await checkoutRepo(repo, join(root, repoPath(repo)), options.signal);
|
|
67
|
+
await writeFile(join(checkout.worktree, '.pulse-runner-job'), job.id, { mode: 0o600 });
|
|
68
|
+
checkouts.push(checkout);
|
|
69
|
+
}
|
|
70
|
+
const primary = checkouts.find((checkout) => checkout.repo === job.repoFullName);
|
|
71
|
+
if (!primary) throw new Error('Job context does not include its target repository.');
|
|
72
|
+
return { primary, checkouts };
|
|
73
|
+
} catch (error) { await cleanup(checkouts); await removeTemporary(root); throw error; }
|
|
74
|
+
}
|
|
75
|
+
async function cleanup(checkouts) {
|
|
76
|
+
await Promise.all(checkouts.map(async ({ worktree, mirror }) => {
|
|
77
|
+
await removeIsolatedWorktree({ mirror, worktree });
|
|
78
|
+
await removeTemporary(worktree);
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
async function publishCheckout(checkout, jobId, checkActive, signal) {
|
|
82
|
+
const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current'], { signal });
|
|
83
|
+
const branch = branchResult.output.trim();
|
|
84
|
+
if (!branch || !branch.startsWith('pul/')) {
|
|
85
|
+
await logEvent('publication.skipped', { jobId, repo: checkout.repo, reason: 'branch-not-pulse' });
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
let phase = 'validate-commit';
|
|
89
|
+
const status = await command('git', ['-C', checkout.worktree, 'status', '--porcelain'], { signal });
|
|
90
|
+
const uncommitted = status.output.split('\n').filter((line) => line && !line.endsWith('.pulse-runner-job'));
|
|
91
|
+
if (uncommitted.length > 0) throw Object.assign(new Error(`Uncommitted changes remain in ${checkout.repo}; the agent must commit every changed worktree.`), { phase });
|
|
92
|
+
phase = 'inspect-commit';
|
|
93
|
+
const head = await command('git', ['-C', checkout.worktree, 'rev-parse', 'HEAD'], { signal });
|
|
94
|
+
if (head.code !== 0) throw Object.assign(new Error(sanitize(head.output)), { phase });
|
|
95
|
+
if (head.output.trim() === checkout.baseCommit) {
|
|
96
|
+
await logEvent('publication.skipped', { jobId, repo: checkout.repo, branch, reason: 'no-commits' });
|
|
97
|
+
return '';
|
|
98
|
+
}
|
|
99
|
+
// Sólo se publica una rama de trabajo Pulse de un worktree firmado como
|
|
100
|
+
// contexto de este job. El Runner no elige repos ni destinos externos.
|
|
101
|
+
phase = 'push';
|
|
102
|
+
await logEvent('publication.started', { jobId, repo: checkout.repo, branch, phase });
|
|
103
|
+
await checkActive();
|
|
104
|
+
const pushed = await command('git', ['-c', 'remote.origin.mirror=false', '--git-dir', checkout.mirror, 'push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], { signal, timeout: 120_000 });
|
|
105
|
+
if (pushed.code !== 0) throw Object.assign(new Error(sanitize(pushed.output)), { phase });
|
|
106
|
+
await logEvent('publication.pushed', { jobId, repo: checkout.repo, branch, phase });
|
|
107
|
+
phase = 'find-pull-request';
|
|
108
|
+
const existing = await command(resolveExecutable('gh'), ['pr', 'view', branch, '--repo', checkout.repo, '--json', 'url'], { signal, timeout: 30_000 });
|
|
109
|
+
if (existing.code === 0) {
|
|
110
|
+
await logEvent('publication.pull-request-exists', { jobId, repo: checkout.repo, branch });
|
|
111
|
+
return `Published ${checkout.repo}:${branch}; existing PR detected.`;
|
|
112
|
+
}
|
|
113
|
+
if (!/no pull requests found/i.test(existing.output)) throw Object.assign(new Error(sanitize(existing.output)), { phase });
|
|
114
|
+
phase = 'create-pull-request';
|
|
115
|
+
await checkActive();
|
|
116
|
+
const created = await command(resolveExecutable('gh'), ['pr', 'create', '--repo', checkout.repo, '--base', 'main', '--head', branch, '--fill'], { signal, timeout: 30_000 });
|
|
117
|
+
if (created.code !== 0) throw Object.assign(new Error(sanitize(created.output)), { phase });
|
|
118
|
+
await logEvent('publication.pull-request-created', { jobId, repo: checkout.repo, branch, url: created.output.trim() });
|
|
119
|
+
return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
|
|
120
|
+
}
|
|
121
|
+
async function publish(checkouts, jobId, checkActive, signal) {
|
|
122
|
+
const publications = { succeeded: [], failed: [] };
|
|
123
|
+
for (const checkout of checkouts) {
|
|
124
|
+
try {
|
|
125
|
+
const published = await publishCheckout(checkout, jobId, checkActive, signal);
|
|
126
|
+
if (published) publications.succeeded.push(published);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (error.name === 'AbortError') throw error;
|
|
129
|
+
const failure = { repo: checkout.repo, phase: error.phase || 'prepare', error: sanitize(error.message) };
|
|
130
|
+
publications.failed.push(failure);
|
|
131
|
+
await logEvent('publication.failed', { jobId, ...failure });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return publications;
|
|
135
|
+
}
|
|
136
|
+
let readinessCache;
|
|
137
|
+
async function readinessFor(config, force = false) {
|
|
138
|
+
const key = JSON.stringify([config.workspaceId, localIdentities(config), config.repos]);
|
|
139
|
+
if (force || !readinessCache || readinessCache.key !== key || Date.now() - readinessCache.at > 60_000) readinessCache = { key, at: Date.now(), value: await inspectReadiness(config) };
|
|
140
|
+
return readinessCache.value;
|
|
141
|
+
}
|
|
142
|
+
/** Run one delivered job. Dependencies allow integration tests to use a local control plane. */
|
|
143
|
+
export async function runOnce(config, options = {}) {
|
|
144
|
+
const remote = options.request || request;
|
|
145
|
+
const readinessForJob = options.readinessFor || readinessFor;
|
|
146
|
+
const prepare = options.checkout || checkout;
|
|
147
|
+
const publishWork = options.publish || publish;
|
|
148
|
+
const cleanWork = options.cleanup || cleanup;
|
|
149
|
+
const controller = new AbortController();
|
|
150
|
+
const cancel = () => controller.abort();
|
|
151
|
+
if (options.signal?.aborted) return false;
|
|
152
|
+
options.signal?.addEventListener('abort', cancel, { once: true });
|
|
153
|
+
let watcher;
|
|
154
|
+
let expiryTimer;
|
|
155
|
+
const stopWatching = new AbortController();
|
|
156
|
+
let job;
|
|
157
|
+
let jobPhase = 'poll';
|
|
158
|
+
let completionReported = false;
|
|
159
|
+
const journal = join(paths().root, 'active-job.json');
|
|
160
|
+
const checkActive = async () => {
|
|
161
|
+
controller.signal.throwIfAborted();
|
|
162
|
+
if (Date.parse(job.expiresAt) <= Date.now()) controller.abort();
|
|
163
|
+
const response = await remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id });
|
|
164
|
+
if (response.cancelRequested) controller.abort();
|
|
165
|
+
controller.signal.throwIfAborted();
|
|
166
|
+
};
|
|
167
|
+
try {
|
|
168
|
+
const readiness = await readinessForJob(config);
|
|
169
|
+
await remote(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
|
|
170
|
+
const delivery = await remote(config, 'pulseRunnerPoll', {});
|
|
171
|
+
if (!delivery.job) return false;
|
|
172
|
+
job = delivery.job;
|
|
173
|
+
await logEvent('job.received', { jobId: job.id, issueId: job.issueId, agentId: job.agentId, mode: job.mode, repo: job.repoFullName });
|
|
174
|
+
jobPhase = 'validate-job';
|
|
175
|
+
const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
|
|
176
|
+
validateJob(job, config, agent);
|
|
177
|
+
jobPhase = 'local-preflight';
|
|
178
|
+
assertLocalReadiness(job, config, agent, await readinessForJob(config, true));
|
|
179
|
+
await logEvent('job.validated', { jobId: job.id, repoCount: (job.contextRepos || [job.repoFullName]).length });
|
|
180
|
+
await checkActive();
|
|
181
|
+
expiryTimer = setTimeout(cancel, Math.max(0, Date.parse(job.expiresAt) - Date.now()));
|
|
182
|
+
expiryTimer.unref();
|
|
183
|
+
// Heartbeat stays outbound while the CLI runs. Losing authorization or
|
|
184
|
+
// contact with Pulse stops local execution rather than publishing blindly.
|
|
185
|
+
watcher = (async () => {
|
|
186
|
+
while (!stopWatching.signal.aborted) {
|
|
187
|
+
await new Promise((resolve) => {
|
|
188
|
+
const done = () => { clearTimeout(timer); stopWatching.signal.removeEventListener('abort', done); resolve(); };
|
|
189
|
+
const timer = setTimeout(done, options.heartbeatIntervalMs || 2000);
|
|
190
|
+
stopWatching.signal.addEventListener('abort', done, { once: true });
|
|
191
|
+
});
|
|
192
|
+
if (stopWatching.signal.aborted) break;
|
|
193
|
+
try { await checkActive(); } catch { controller.abort(); break; }
|
|
194
|
+
}
|
|
195
|
+
})();
|
|
196
|
+
jobPhase = 'prepare-worktrees';
|
|
197
|
+
await writeFile(journal, JSON.stringify({ jobId: job.id }), { mode: 0o600 });
|
|
198
|
+
await chmod(journal, 0o600);
|
|
199
|
+
const { primary, checkouts } = await prepare(job, { signal: controller.signal });
|
|
200
|
+
const temp = join(paths().root, 'tmp', job.id);
|
|
201
|
+
let completion;
|
|
202
|
+
try {
|
|
203
|
+
controller.signal.throwIfAborted();
|
|
204
|
+
jobPhase = 'run-agent';
|
|
205
|
+
await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
|
|
206
|
+
const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal });
|
|
207
|
+
controller.signal.throwIfAborted();
|
|
208
|
+
await checkActive();
|
|
209
|
+
await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
|
|
210
|
+
jobPhase = 'publication';
|
|
211
|
+
const publication = result.code === 0 && job.mode !== 'review' ? await publishWork(checkouts, job.id, checkActive, controller.signal) : { succeeded: [], failed: [] };
|
|
212
|
+
await checkActive();
|
|
213
|
+
const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
|
|
214
|
+
const failure = outcome === 'failed' ? { phase: result.code !== 0 ? 'run-agent' : publication.failed[0]?.phase || 'publication', category: 'execution', correlationId: job.id } : null;
|
|
215
|
+
jobPhase = 'report-completion';
|
|
216
|
+
completion = { jobId: job.id, outcome, result: sanitize(completionSummary(result, publication)), failure };
|
|
217
|
+
} finally {
|
|
218
|
+
await removeTemporary(temp);
|
|
219
|
+
await cleanWork(checkouts);
|
|
220
|
+
await removeTemporary(join(paths().worktrees, job.id));
|
|
221
|
+
}
|
|
222
|
+
controller.signal.throwIfAborted();
|
|
223
|
+
await remote(config, 'pulseRunnerComplete', completion);
|
|
224
|
+
completionReported = true;
|
|
225
|
+
await logEvent('job.completed', { jobId: job.id, outcome: completion.outcome });
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (!job) throw error;
|
|
228
|
+
const canceled = controller.signal.aborted;
|
|
229
|
+
const phase = error.phase || jobPhase;
|
|
230
|
+
const message = canceled ? 'Job canceled; local execution stopped.' : error?.message || String(error);
|
|
231
|
+
await logEvent(canceled ? 'job.canceled' : 'job.failed', { jobId: job.id, phase, error: message });
|
|
232
|
+
await remote(config, 'pulseRunnerComplete', { jobId: job.id, outcome: canceled ? 'canceled' : 'failed', result: sanitize(`${phase}: ${message}`), failure: canceled ? null : { phase, category: phase === 'validate-job' ? 'configuration' : ['local-preflight', 'prepare-worktrees'].includes(phase) ? 'local_preparation' : 'execution', correlationId: job.id } }).then(() => { completionReported = true; }).catch(async (completionError) => {
|
|
233
|
+
await logEvent('job.completion-report-failed', { jobId: job.id, error: completionError.message });
|
|
234
|
+
});
|
|
235
|
+
if (!canceled) throw error;
|
|
236
|
+
} finally {
|
|
237
|
+
stopWatching.abort();
|
|
238
|
+
if (expiryTimer) clearTimeout(expiryTimer);
|
|
239
|
+
await watcher;
|
|
240
|
+
if (completionReported) await rm(journal, { force: true });
|
|
241
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
242
|
+
if (job) await remote(config, 'pulseRunnerHeartbeat', { status: 'online' }).catch(() => {});
|
|
243
|
+
}
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function recoverInterruptedWork(config) {
|
|
248
|
+
const p = await ensureDataDir();
|
|
249
|
+
for (const area of [p.worktrees, join(p.root, 'tmp')]) {
|
|
250
|
+
await mkdir(area, { recursive: true, mode: 0o700 });
|
|
251
|
+
const jobs = await readdir(area, { withFileTypes: true });
|
|
252
|
+
await Promise.all(jobs.map((entry) => rm(join(area, entry.name), { recursive: true, force: true })));
|
|
253
|
+
}
|
|
254
|
+
const repositories = await readdir(p.repos, { withFileTypes: true });
|
|
255
|
+
await Promise.all(repositories.filter((entry) => entry.isDirectory()).map((entry) => command('git', ['--git-dir', join(p.repos, entry.name), 'worktree', 'prune']).catch(() => {})));
|
|
256
|
+
if (config) {
|
|
257
|
+
const journal = join(p.root, 'active-job.json');
|
|
258
|
+
const interrupted = await readFile(journal, 'utf8').then(JSON.parse).catch((error) => {
|
|
259
|
+
if (error.code === 'ENOENT') return null;
|
|
260
|
+
throw error;
|
|
261
|
+
});
|
|
262
|
+
if (interrupted) {
|
|
263
|
+
await request(config, 'pulseRunnerComplete', { jobId: interrupted.jobId, outcome: 'canceled', result: 'Runner restarted; interrupted worktrees and temporary MCP files cleaned.' }).catch((error) => {
|
|
264
|
+
if (![404, 409].includes(error.status)) throw error;
|
|
265
|
+
});
|
|
266
|
+
await rm(journal, { force: true });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** A per-data-directory lock covers both the daemon and one-shot execution. */
|
|
272
|
+
export async function withRunnerLock(run) {
|
|
273
|
+
const p = await ensureDataDir();
|
|
274
|
+
const lockPath = join(p.root, 'runner.lock');
|
|
275
|
+
let lock;
|
|
276
|
+
try { lock = await open(lockPath, 'wx', 0o600); }
|
|
277
|
+
catch (error) {
|
|
278
|
+
if (error.code !== 'EEXIST') throw error;
|
|
279
|
+
const pid = Number((await readFile(lockPath, 'utf8')).trim());
|
|
280
|
+
let active = false;
|
|
281
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
282
|
+
try { process.kill(pid, 0); active = true; } catch (probeError) { active = probeError.code !== 'ESRCH'; }
|
|
283
|
+
} else active = Date.now() - (await stat(lockPath)).mtimeMs < 10_000;
|
|
284
|
+
if (active) throw new Error('Pulse Runner is already active.');
|
|
285
|
+
await rm(lockPath, { force: true });
|
|
286
|
+
lock = await open(lockPath, 'wx', 0o600);
|
|
287
|
+
}
|
|
288
|
+
try { await lock.writeFile(`${process.pid}\n`); return await run(); }
|
|
289
|
+
finally { await lock.close(); await rm(lockPath, { force: true }); }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
package/src/service.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir, platform } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { ensureDataDir } from './config.js';
|
|
7
|
+
import { command as nativeCommand } from './process.js';
|
|
8
|
+
import { sanitize } from './job.js';
|
|
9
|
+
|
|
10
|
+
export async function service(action, options = {}) {
|
|
11
|
+
const p = await ensureDataDir();
|
|
12
|
+
const os = options.platform || platform();
|
|
13
|
+
const userId = options.userId ?? process.getuid?.() ?? 0;
|
|
14
|
+
const userHome = options.userHome || homedir();
|
|
15
|
+
const command = options.command || nativeCommand;
|
|
16
|
+
const print = options.print || console.log;
|
|
17
|
+
const scriptPath = fileURLToPath(new URL('./index.js', import.meta.url));
|
|
18
|
+
const instance = createHash('sha256').update(p.root).digest('hex').slice(0, 12);
|
|
19
|
+
const label = `com.pulse.runner.${instance}`;
|
|
20
|
+
const unitName = `pulse-runner-${instance}.service`;
|
|
21
|
+
const taskName = `PulseRunner-${instance}`;
|
|
22
|
+
const launchAgent = join(userHome, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
23
|
+
const systemdDir = join((!options.userHome && process.env.XDG_CONFIG_HOME) || join(userHome, '.config'), 'systemd', 'user');
|
|
24
|
+
const unitPath = join(systemdDir, unitName);
|
|
25
|
+
const lockPath = join(p.root, 'runner.lock');
|
|
26
|
+
const xml = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
27
|
+
const manager = async (exe, argv) => { const result = await command(exe, argv); if (result.code !== 0) throw new Error(sanitize(result.output)); };
|
|
28
|
+
const unitValue = (value) => String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('%', '%%').replaceAll('\n', '').replaceAll('\r', '');
|
|
29
|
+
const currentPid = async () => { try { return Number((await readFile(lockPath, 'utf8')).trim()); } catch { return 0; } };
|
|
30
|
+
|
|
31
|
+
if (action === 'install') {
|
|
32
|
+
if (os === 'darwin') {
|
|
33
|
+
await mkdir(join(userHome, 'Library', 'LaunchAgents'), { recursive: true, mode: 0o700 });
|
|
34
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict><key>Label</key><string>${label}</string><key>ProgramArguments</key><array><string>${xml(process.execPath)}</string><string>${xml(scriptPath)}</string><string>start</string><string>--home</string><string>${xml(p.root)}</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(process.env.PATH || '')}</string></dict><key>StandardOutPath</key><string>${xml(join(p.logs, 'service.log'))}</string><key>StandardErrorPath</key><string>${xml(join(p.logs, 'service.log'))}</string></dict></plist>`;
|
|
35
|
+
await writeFile(launchAgent, plist, { mode: 0o600 });
|
|
36
|
+
await command('launchctl', ['bootout', `gui/${userId}/${label}`]).catch(() => {});
|
|
37
|
+
await manager('launchctl', ['bootstrap', `gui/${userId}`, launchAgent]);
|
|
38
|
+
} else if (os === 'linux') {
|
|
39
|
+
await mkdir(systemdDir, { recursive: true, mode: 0o700 });
|
|
40
|
+
const unit = `[Unit]\nDescription=Pulse local Runner\nAfter=network-online.target\n\n[Service]\nType=simple\nExecStart="${unitValue(process.execPath)}" "${unitValue(scriptPath)}" start --home "${unitValue(p.root)}"\nRestart=on-failure\nRestartSec=5\nEnvironment="PATH=${unitValue(process.env.PATH || '/usr/local/bin:/usr/bin:/bin')}"\n\n[Install]\nWantedBy=default.target\n`;
|
|
41
|
+
await writeFile(unitPath, unit, { mode: 0o600 });
|
|
42
|
+
await manager('systemctl', ['--user', 'daemon-reload']);
|
|
43
|
+
await manager('systemctl', ['--user', 'enable', '--now', unitName]);
|
|
44
|
+
} else if (os === 'win32') {
|
|
45
|
+
await manager('schtasks', ['/Create', '/SC', 'ONLOGON', '/TN', taskName, '/TR', `"${process.execPath}" "${scriptPath}" start --home "${p.root}"`, '/RL', 'LIMITED', '/F']);
|
|
46
|
+
await manager('schtasks', ['/Run', '/TN', taskName]);
|
|
47
|
+
} else throw new Error(`Service installation is not supported on ${os}.`);
|
|
48
|
+
return print('Pulse Runner is installed as a per-user background service.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (action === 'uninstall') {
|
|
52
|
+
if (os === 'darwin') { await command('launchctl', ['bootout', `gui/${userId}/${label}`]).catch(() => {}); await rm(launchAgent, { force: true }); }
|
|
53
|
+
else if (os === 'linux') { await command('systemctl', ['--user', 'disable', '--now', unitName]).catch(() => {}); await rm(unitPath, { force: true }); await command('systemctl', ['--user', 'daemon-reload']).catch(() => {}); }
|
|
54
|
+
else if (os === 'win32') { await command('schtasks', ['/End', '/TN', taskName]).catch(() => {}); await command('schtasks', ['/Delete', '/TN', taskName, '/F']).catch(() => {}); }
|
|
55
|
+
return print('Pulse Runner background service removed.');
|
|
56
|
+
}
|
|
57
|
+
if (action === 'start') {
|
|
58
|
+
if (os === 'darwin') {
|
|
59
|
+
await command('launchctl', ['bootstrap', `gui/${userId}`, launchAgent]).catch(() => {});
|
|
60
|
+
await manager('launchctl', ['kickstart', `gui/${userId}/${label}`]);
|
|
61
|
+
}
|
|
62
|
+
else if (os === 'linux') await manager('systemctl', ['--user', 'start', unitName]);
|
|
63
|
+
else if (os === 'win32') await manager('schtasks', ['/Run', '/TN', taskName]);
|
|
64
|
+
else throw new Error(`Service management is not supported on ${os}.`);
|
|
65
|
+
return print('Pulse Runner service started.');
|
|
66
|
+
}
|
|
67
|
+
if (action === 'stop') {
|
|
68
|
+
if (os === 'darwin') await command('launchctl', ['bootout', `gui/${userId}/${label}`]).catch(() => {});
|
|
69
|
+
else if (os === 'linux') await command('systemctl', ['--user', 'stop', unitName]).catch(() => {});
|
|
70
|
+
else if (os === 'win32') {
|
|
71
|
+
const processId = await currentPid();
|
|
72
|
+
if (processId) { try { process.kill(processId, 'SIGTERM'); } catch {} }
|
|
73
|
+
await command('schtasks', ['/End', '/TN', taskName]).catch(() => {});
|
|
74
|
+
}
|
|
75
|
+
return print('Pulse Runner service stopped.');
|
|
76
|
+
}
|
|
77
|
+
if (action === 'status') {
|
|
78
|
+
const processId = await currentPid();
|
|
79
|
+
if (processId) { try { process.kill(processId, 0); return print(`Pulse Runner is running (pid ${processId}).`); } catch {} }
|
|
80
|
+
return print('Pulse Runner is stopped.');
|
|
81
|
+
}
|
|
82
|
+
throw new Error('Supported service commands: install, start, stop, status, uninstall.');
|
|
83
|
+
}
|
|
84
|
+
|
package/src/worktree.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { command } from './process.js';
|
|
2
|
+
import { sanitize } from './job.js';
|
|
3
|
+
|
|
4
|
+
export async function addIsolatedWorktree(repo, gitDir, worktree, options = {}) {
|
|
5
|
+
options.signal?.throwIfAborted();
|
|
6
|
+
const added = await command('git', ['--git-dir', gitDir, 'worktree', 'add', '--detach', worktree, 'HEAD'], options);
|
|
7
|
+
if (added.code !== 0) throw new Error(sanitize(added.output));
|
|
8
|
+
const base = await command('git', ['-C', worktree, 'rev-parse', 'HEAD'], options);
|
|
9
|
+
if (base.code !== 0) {
|
|
10
|
+
await command('git', ['--git-dir', gitDir, 'worktree', 'remove', '--force', worktree]).catch(() => {});
|
|
11
|
+
throw new Error(sanitize(base.output));
|
|
12
|
+
}
|
|
13
|
+
return { repo, mirror: gitDir, worktree, baseCommit: base.output.trim() };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function removeIsolatedWorktree(checkout) {
|
|
17
|
+
await command('git', ['--git-dir', checkout.mirror, 'worktree', 'remove', '--force', checkout.worktree]).catch(() => {});
|
|
18
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { generateKeyPairSync, sign } from 'node:crypto';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { mkdtemp, mkdir, readFile, writeFile, rm, access } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { paths, ensureDataDir, writeCredential } from '../src/config.js';
|
|
9
|
+
import { jobPayload } from '../src/job.js';
|
|
10
|
+
import { command } from '../src/process.js';
|
|
11
|
+
import { inspectReadiness } from '../src/readiness.js';
|
|
12
|
+
import { runOnce, recoverInterruptedWork, withRunnerLock } from '../src/runtime.js';
|
|
13
|
+
|
|
14
|
+
const exists = (file) => access(file).then(() => true).catch(() => false);
|
|
15
|
+
async function git(args) { const result = await command('git', args); assert.equal(result.code, 0, result.output); return result.output.trim(); }
|
|
16
|
+
|
|
17
|
+
async function fixture(kind, callback) {
|
|
18
|
+
const root = await mkdtemp(join(tmpdir(), 'pulse-runtime-'));
|
|
19
|
+
const saved = { ...process.env };
|
|
20
|
+
process.env.PULSE_RUNNER_HOME = join(root, 'runner');
|
|
21
|
+
const source = join(root, 'source');
|
|
22
|
+
const trace = join(root, 'trace.json');
|
|
23
|
+
const fake = join(root, 'provider.mjs');
|
|
24
|
+
await writeFile(fake, `
|
|
25
|
+
import { writeFileSync, readFileSync } from 'node:fs';
|
|
26
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const settings = JSON.parse(readFileSync(${JSON.stringify(join(root, 'settings.json'))}, 'utf8'));
|
|
29
|
+
if (args.includes('--version')) console.log('fixture-cli');
|
|
30
|
+
else if (args[0] === 'login') process.exit(settings.session ? 0 : 1);
|
|
31
|
+
else if (args[0] === 'auth') console.log(JSON.stringify({loggedIn: settings.session}));
|
|
32
|
+
else {
|
|
33
|
+
const file = args[args.indexOf('--mcp-config') + 1];
|
|
34
|
+
const received = args.includes('exec') ? !!process.env.PULSE_RUNNER_MCP_CREDENTIAL?.startsWith('Bearer ') : !!JSON.parse(readFileSync(file)).mcpServers.pulse.headers.Authorization;
|
|
35
|
+
const child = settings.slow ? spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], {stdio:'ignore'}) : null;
|
|
36
|
+
writeFileSync(${JSON.stringify(trace)}, JSON.stringify({args, received, pid: process.pid, childPid: child?.pid}));
|
|
37
|
+
writeFileSync('job-output.txt', 'isolated output');
|
|
38
|
+
if (settings.publish) {
|
|
39
|
+
for (const argv of [['switch','-c','pul/fixture'],['add','job-output.txt'],['-c','user.name=Fixture','-c','user.email=fixture@example.invalid','commit','-m','fixture job']]) {
|
|
40
|
+
const result=spawnSync('git',argv,{encoding:'utf8'});
|
|
41
|
+
if(result.status!==0) throw new Error(result.stderr);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (settings.slow) setInterval(() => {}, 1000);
|
|
45
|
+
else { console.log('Bearer fixture-secret pulse_sk_fixture'); process.exitCode = settings.fail ? 1 : 0; }
|
|
46
|
+
}
|
|
47
|
+
`);
|
|
48
|
+
const fakeGh = join(root,'gh.mjs');
|
|
49
|
+
await writeFile(fakeGh, `
|
|
50
|
+
import { appendFileSync } from 'node:fs';
|
|
51
|
+
appendFileSync(${JSON.stringify(join(root,'gh-calls.jsonl'))},JSON.stringify(process.argv.slice(2))+'\\n');
|
|
52
|
+
if (process.argv.includes('view')) {console.error('no pull requests found');process.exitCode=1;}
|
|
53
|
+
else console.log('https://github.com/owner/repo/pull/123');
|
|
54
|
+
`);
|
|
55
|
+
process.env.PULSE_RUNNER_GH_BIN = fakeGh;
|
|
56
|
+
process.env.PULSE_RUNNER_CODEX_BIN = fake;
|
|
57
|
+
process.env.PULSE_RUNNER_CLAUDE_BIN = fake;
|
|
58
|
+
const settings = { session: true, slow: false, fail: false };
|
|
59
|
+
const updateSettings = () => writeFile(join(root, 'settings.json'), JSON.stringify(settings));
|
|
60
|
+
await updateSettings();
|
|
61
|
+
const p = await ensureDataDir();
|
|
62
|
+
await writeCredential('runner-fixture.synthetic-device');
|
|
63
|
+
await git(['init', source]);
|
|
64
|
+
await writeFile(join(source, 'source.txt'), 'original');
|
|
65
|
+
await git(['-C', source, 'add', 'source.txt']);
|
|
66
|
+
await git(['-C', source, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'initial']);
|
|
67
|
+
await git(['clone', '--mirror', source, join(p.repos, 'owner__repo.git')]);
|
|
68
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
69
|
+
const config = { workspaceId: 'ws-test', runnerId: 'runner-fixture', agentId: 'agent-test', agentKind: kind, agentRole: 'dev', repos: ['owner/repo'], signingKeyId: 'fixture', signingPublicKey: publicKey.export({type:'spki',format:'pem'}).toString() };
|
|
70
|
+
const job = { id: 'rjob-test', workspaceId: config.workspaceId, runnerId: config.runnerId, agentId: config.agentId, issueId: 'issue-fixture', repoFullName: 'owner/repo', mode: 'task', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now()+60_000).toISOString(), signatureAlgorithm:'ed25519', signingKeyId: config.signingKeyId };
|
|
71
|
+
const signJob = () => { job.signature = sign(null, Buffer.from(jobPayload(job)), privateKey).toString('base64url'); };
|
|
72
|
+
signJob();
|
|
73
|
+
const calls = [];
|
|
74
|
+
const control = { cancel: false, cancelWhenRunning: false, disconnectWhenRunning: false };
|
|
75
|
+
const server = createServer(async (req, res) => {
|
|
76
|
+
let raw = ''; for await (const chunk of req) raw += chunk;
|
|
77
|
+
const body = JSON.parse(raw); calls.push({path:req.url, body});
|
|
78
|
+
res.setHeader('Content-Type', 'application/json');
|
|
79
|
+
if (req.url === '/pulseRunnerPoll') res.end(JSON.stringify({job, agent:{kind,role: job.mode === 'review' ? 'qa' : 'dev'}, mcpCredential:'pulse_sk_fixture'}));
|
|
80
|
+
else if (body.jobId && req.url === '/pulseRunnerHeartbeat') {
|
|
81
|
+
const running = await exists(trace);
|
|
82
|
+
if (control.disconnectWhenRunning && running) {res.statusCode=401;res.end('{}');}
|
|
83
|
+
else res.end(JSON.stringify({cancelRequested: control.cancel || (control.cancelWhenRunning && running)}));
|
|
84
|
+
} else res.end('{}');
|
|
85
|
+
});
|
|
86
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
87
|
+
config.endpoint = `http://127.0.0.1:${server.address().port}`;
|
|
88
|
+
const readinessFor = () => inspectReadiness(config, (exe, args, options) => command(exe, exe === 'git' ? ['ls-remote', '--exit-code', source, 'HEAD'] : args, options));
|
|
89
|
+
const run = (extra = {}) => runOnce(config, { readinessFor, heartbeatIntervalMs: 20, ...extra });
|
|
90
|
+
try { await callback({root, source, trace, config, job, signJob, settings, updateSettings, calls, control, run}); }
|
|
91
|
+
finally {
|
|
92
|
+
server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
|
|
93
|
+
for (const key of ['PULSE_RUNNER_HOME', 'PULSE_RUNNER_CODEX_BIN', 'PULSE_RUNNER_CLAUDE_BIN', 'PULSE_RUNNER_GH_BIN']) {
|
|
94
|
+
if (saved[key] === undefined) delete process.env[key]; else process.env[key] = saved[key];
|
|
95
|
+
}
|
|
96
|
+
await rm(root, {recursive:true,force:true});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const kind of ['claude','codex']) {
|
|
101
|
+
test(`integration: ${kind} executes a real fixture CLI, isolates work and cleans MCP configuration`, () => fixture(kind, async f => {
|
|
102
|
+
await f.run();
|
|
103
|
+
const trace = JSON.parse(await readFile(f.trace,'utf8'));
|
|
104
|
+
assert.equal(trace.received, true);
|
|
105
|
+
assert.ok(!trace.args.join(' ').includes('pulse_sk_fixture'));
|
|
106
|
+
assert.equal(await readFile(join(f.source,'source.txt'),'utf8'),'original');
|
|
107
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
108
|
+
assert.equal(await exists(join(paths().root,'tmp',f.job.id)),false);
|
|
109
|
+
const completion = f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
|
|
110
|
+
assert.equal(completion.outcome,'completed');
|
|
111
|
+
assert.ok(!JSON.stringify(f.calls).includes('pulse_sk_fixture'));
|
|
112
|
+
}));
|
|
113
|
+
test(`integration: ${kind} missing native session rejects before creating worktrees`, () => fixture(kind, async f => {
|
|
114
|
+
f.settings.session = false; await f.updateSettings();
|
|
115
|
+
let prepared = false;
|
|
116
|
+
await assert.rejects(f.run({checkout:async()=>{prepared=true;}}),/session is unavailable/);
|
|
117
|
+
assert.equal(prepared,false);
|
|
118
|
+
assert.equal(await exists(f.trace),false);
|
|
119
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'failed');
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
test('integration: invalid signed jobs never reach checkout', () => fixture('codex', async f => {
|
|
124
|
+
const original = {...f.job};
|
|
125
|
+
const cases = [
|
|
126
|
+
{signature:'invalid'}, {workspaceId:'other'}, {runnerId:'other'}, {agentId:'other'},
|
|
127
|
+
{repoFullName:'other/repo'}, {expiresAt:new Date(Date.now()-1000).toISOString()},
|
|
128
|
+
{expiresAt:'invalid'}, {issuedAt:'invalid'}, {id:'rjob-../../outside'}, {mode:'other'},
|
|
129
|
+
];
|
|
130
|
+
for (const mutation of cases) {
|
|
131
|
+
Object.assign(f.job,original,mutation);
|
|
132
|
+
if (!mutation.signature) f.signJob();
|
|
133
|
+
let prepared=false;
|
|
134
|
+
await assert.rejects(f.run({checkout:async()=>{prepared=true;}}));
|
|
135
|
+
assert.equal(prepared,false);
|
|
136
|
+
assert.equal(await exists(f.trace),false);
|
|
137
|
+
}
|
|
138
|
+
}));
|
|
139
|
+
|
|
140
|
+
for (const cause of ['remote', 'signal', 'revoked']) {
|
|
141
|
+
test(`integration: ${cause} cancellation stops the CLI and descendants, skips publication and cleans worktrees`, () => fixture('codex', async f => {
|
|
142
|
+
f.settings.slow=true; await f.updateSettings();
|
|
143
|
+
f.control.cancelWhenRunning=cause==='remote';
|
|
144
|
+
f.control.disconnectWhenRunning=cause==='revoked';
|
|
145
|
+
const controller=new AbortController();
|
|
146
|
+
let published=false;
|
|
147
|
+
const running=f.run({signal:controller.signal,publish:async()=>{published=true;}});
|
|
148
|
+
if(cause==='signal') {
|
|
149
|
+
for(let i=0;i<200 && !(await exists(f.trace));i++) await new Promise(r=>setTimeout(r,10));
|
|
150
|
+
assert.equal(await exists(f.trace),true);
|
|
151
|
+
controller.abort();
|
|
152
|
+
}
|
|
153
|
+
await running;
|
|
154
|
+
const trace=JSON.parse(await readFile(f.trace,'utf8'));
|
|
155
|
+
assert.equal(published,false);
|
|
156
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
|
|
157
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
158
|
+
assert.equal(await exists(join(paths().root,'tmp',f.job.id)),false);
|
|
159
|
+
assert.equal(await git(['-C',f.source,'status','--porcelain']),'');
|
|
160
|
+
for(const pid of [trace.pid,trace.childPid]) {
|
|
161
|
+
let alive=true;
|
|
162
|
+
for(let i=0;i<50 && alive;i++) {try{process.kill(pid,0);}catch{alive=false;} if(alive) await new Promise(r=>setTimeout(r,20));}
|
|
163
|
+
assert.equal(alive,false,`process ${pid} must terminate`);
|
|
164
|
+
}
|
|
165
|
+
assert.equal(f.calls.at(-1).body.status,'online');
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
test('integration: a cancellation before execution never starts the CLI', () => fixture('claude',async f=>{
|
|
170
|
+
f.control.cancel=true;
|
|
171
|
+
await f.run();
|
|
172
|
+
assert.equal(await exists(f.trace),false);
|
|
173
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
test('integration: failed provider output is sanitized in reports and local logs',()=>fixture('codex',async f=>{
|
|
177
|
+
f.settings.fail=true; await f.updateSettings();
|
|
178
|
+
await f.run();
|
|
179
|
+
const completion=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
|
|
180
|
+
assert.equal(completion.outcome,'failed');
|
|
181
|
+
assert.match(completion.result,/redacted/);
|
|
182
|
+
assert.ok(!JSON.stringify(f.calls).includes('fixture-secret'));
|
|
183
|
+
const logs=await readFile(join(paths().logs,'runner.jsonl'),'utf8');
|
|
184
|
+
assert.ok(!logs.includes('pulse_sk_fixture'));
|
|
185
|
+
assert.ok(!logs.includes('fixture-secret'));
|
|
186
|
+
}));
|
|
187
|
+
|
|
188
|
+
test('integration: recovery prunes interrupted worktrees and temporary MCP files without touching the source',()=>fixture('codex',async f=>{
|
|
189
|
+
const mirror=join(paths().repos,'owner__repo.git');
|
|
190
|
+
const interrupted=join(paths().worktrees,'rjob-interrupted');
|
|
191
|
+
await git(['--git-dir',mirror,'worktree','add','--detach',interrupted,'HEAD']);
|
|
192
|
+
await writeFile(join(interrupted,'source.txt'),'interrupted edits');
|
|
193
|
+
const temp=join(paths().root,'tmp','rjob-interrupted');
|
|
194
|
+
await mkdir(temp,{recursive:true}); await writeFile(join(temp,'pulse-mcp.json'),'synthetic temporary data');
|
|
195
|
+
await writeFile(join(paths().root, 'active-job.json'), JSON.stringify({jobId:f.job.id}));
|
|
196
|
+
await recoverInterruptedWork(f.config);
|
|
197
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
|
|
198
|
+
assert.equal(await exists(join(paths().root,'active-job.json')),false);
|
|
199
|
+
f.calls.length=0;
|
|
200
|
+
assert.equal(await exists(interrupted),false);
|
|
201
|
+
assert.equal(await exists(temp),false);
|
|
202
|
+
assert.ok(!(await git(['--git-dir',mirror,'worktree','list','--porcelain'])).includes('rjob-interrupted'));
|
|
203
|
+
assert.equal(await readFile(join(f.source,'source.txt'),'utf8'),'original');
|
|
204
|
+
await f.run();
|
|
205
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
|
|
206
|
+
}));
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
test('integration: one-shot and daemon share a lock; stale locks and thrown jobs release it',()=>fixture('codex',async()=>{
|
|
210
|
+
await withRunnerLock(async()=>{
|
|
211
|
+
await assert.rejects(withRunnerLock(async()=>{}),/already active/);
|
|
212
|
+
});
|
|
213
|
+
assert.equal(await exists(join(paths().root,'runner.lock')),false);
|
|
214
|
+
await writeFile(join(paths().root,'runner.lock'),'2147483647\n');
|
|
215
|
+
await assert.rejects(withRunnerLock(async()=>{throw new Error('fixture failure');}),/fixture failure/);
|
|
216
|
+
assert.equal(await exists(join(paths().root,'runner.lock')),false);
|
|
217
|
+
await withRunnerLock(async()=>{});
|
|
218
|
+
}));
|
|
219
|
+
|
|
220
|
+
test('integration: a job expires during execution and is stopped before publication',()=>fixture('claude',async f=>{
|
|
221
|
+
f.settings.slow=true; await f.updateSettings();
|
|
222
|
+
f.job.expiresAt=new Date(Date.now()+5000).toISOString(); f.signJob();
|
|
223
|
+
let published=false;
|
|
224
|
+
await f.run({publish:async()=>{published=true;}});
|
|
225
|
+
assert.equal(published,false);
|
|
226
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'canceled');
|
|
227
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
228
|
+
}));
|
|
229
|
+
|
|
230
|
+
test('integration: QA execution retains read-only flags and never publishes',()=>fixture('codex',async f=>{
|
|
231
|
+
f.job.mode='review'; f.signJob();
|
|
232
|
+
f.config.agentRole='qa';
|
|
233
|
+
let published=false;
|
|
234
|
+
await f.run({publish:async()=>{published=true;}});
|
|
235
|
+
const trace=JSON.parse(await readFile(f.trace,'utf8'));
|
|
236
|
+
assert.ok(trace.args.includes('read-only'));
|
|
237
|
+
assert.equal(published,false);
|
|
238
|
+
assert.equal(f.calls.find(c=>c.path==='/pulseRunnerComplete').body.outcome,'completed');
|
|
239
|
+
}));
|
|
240
|
+
|
|
241
|
+
test('integration: signed multi-repo worktrees are passed to the adapter and all cleaned',()=>fixture('codex',async f=>{
|
|
242
|
+
await git(['clone','--mirror',f.source,join(paths().repos,'owner__app.git')]);
|
|
243
|
+
f.config.repos.push('owner/app');
|
|
244
|
+
f.job.contextRepos=['owner/repo','owner/app']; f.signJob();
|
|
245
|
+
await f.run();
|
|
246
|
+
const trace=JSON.parse(await readFile(f.trace,'utf8'));
|
|
247
|
+
assert.ok(trace.args.includes('--add-dir'));
|
|
248
|
+
assert.ok(trace.args.some(arg=>arg.endsWith('owner__app')));
|
|
249
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
250
|
+
for (const repo of ['owner__repo.git','owner__app.git']) {
|
|
251
|
+
assert.ok(!(await git(['--git-dir',join(paths().repos,repo),'worktree','list','--porcelain'])).includes('rjob-test'));
|
|
252
|
+
}
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
test('integration: committed job branches push to a local remote and report the fixture PR',()=>fixture('codex',async f=>{
|
|
257
|
+
f.settings.publish=true; await f.updateSettings();
|
|
258
|
+
const original=await git(['-C',f.source,'rev-parse','HEAD']);
|
|
259
|
+
await f.run();
|
|
260
|
+
const completion=f.calls.find(c=>c.path==='/pulseRunnerComplete').body;
|
|
261
|
+
assert.equal(completion.outcome,'completed');
|
|
262
|
+
assert.match(completion.result,/owner\/repo:pul\/fixture/);
|
|
263
|
+
assert.match(completion.result,/https:\/\/github.com\/owner\/repo\/pull\/123/);
|
|
264
|
+
assert.equal(await git(['-C',f.source,'rev-parse','HEAD']),original);
|
|
265
|
+
assert.notEqual(await git(['-C',f.source,'rev-parse','refs/heads/pul/fixture']),original);
|
|
266
|
+
const calls=(await readFile(join(f.root,'gh-calls.jsonl'),'utf8')).trim().split('\n').map(JSON.parse);
|
|
267
|
+
assert.equal(calls[0][1],'view'); assert.equal(calls[1][1],'create');
|
|
268
|
+
assert.equal(await exists(join(paths().worktrees,f.job.id)),false);
|
|
269
|
+
}));
|
package/test/job.test.js
CHANGED
|
@@ -24,3 +24,15 @@ test('signed QA jobs require explicit agent identity and provider/role compatibi
|
|
|
24
24
|
assert.equal(validateJob(job, config, { kind: 'codex', role: 'qa' }), true);
|
|
25
25
|
assert.throws(() => validateJob(job, config, { kind: 'claude', role: 'qa' }), /provider/);
|
|
26
26
|
});
|
|
27
|
+
|
|
28
|
+
test('rejects signed envelopes with missing or invalid timestamps before execution', () => {
|
|
29
|
+
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
|
|
30
|
+
const config = { workspaceId: 'ws-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repos: ['owner/repo'], signingKeyId: 'runner-job-v1', signingPublicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString() };
|
|
31
|
+
const job = { id: 'rjob-456', workspaceId: 'ws-1', issueId: 'issue-1', agentId: 'agent-1', runnerId: 'runner-123456789012', repoFullName: 'owner/repo', mode: 'task', issuedAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString(), signatureAlgorithm: 'ed25519', signingKeyId: 'runner-job-v1' };
|
|
32
|
+
const malformed = { ...job, expiresAt: 'invalid' };
|
|
33
|
+
malformed.signature = sign(null, Buffer.from(jobPayload(malformed)), privateKey).toString('base64url');
|
|
34
|
+
assert.throws(() => validateJob(malformed, config), /expired or malformed/);
|
|
35
|
+
const expired = { ...job, expiresAt: new Date(Date.now() - 1_000).toISOString() };
|
|
36
|
+
expired.signature = sign(null, Buffer.from(jobPayload(expired)), privateKey).toString('base64url');
|
|
37
|
+
assert.throws(() => validateJob(expired, config), /expired or malformed/);
|
|
38
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { command, providerInvocation } from '../src/process.js';
|
|
7
|
+
import { addIsolatedWorktree, removeIsolatedWorktree } from '../src/worktree.js';
|
|
8
|
+
|
|
9
|
+
test('cancellation terminates the active child process', async () => {
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
const running = command(process.execPath, ['-e', 'setTimeout(() => {}, 30000)'], { signal: controller.signal });
|
|
12
|
+
setTimeout(() => controller.abort(), 100);
|
|
13
|
+
const result = await running;
|
|
14
|
+
assert.notEqual(result.code, 0);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('Claude and Codex adapters receive only ephemeral MCP configuration references', () => {
|
|
18
|
+
const config = { endpoint: 'https://pulse.example.test' };
|
|
19
|
+
const codex = providerInvocation('codex', '/tmp/job', [], config, '/tmp/pulse-mcp.json', 'run task');
|
|
20
|
+
const claude = providerInvocation('claude', '/tmp/job', [], config, '/tmp/pulse-mcp.json', 'run task');
|
|
21
|
+
assert.match(codex.args.join(' '), /env_http_headers/);
|
|
22
|
+
assert.doesNotMatch(codex.args.join(' '), /secret|credential-value/);
|
|
23
|
+
assert.ok(claude.args.includes('/tmp/pulse-mcp.json'));
|
|
24
|
+
assert.throws(() => providerInvocation('unknown', '/tmp/job', [], config, '', 'run task'), /Unsupported agent kind/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('an isolated worktree can change and clean up without changing its source checkout', async () => {
|
|
28
|
+
const root = await mkdtemp(join(tmpdir(), 'pulse-runner-worktree-'));
|
|
29
|
+
const repo = join(root, 'repo');
|
|
30
|
+
const worktree = join(root, 'job');
|
|
31
|
+
try {
|
|
32
|
+
await command('git', ['init', repo]);
|
|
33
|
+
await writeFile(join(repo, 'source.txt'), 'original\n');
|
|
34
|
+
await command('git', ['-C', repo, 'add', 'source.txt']);
|
|
35
|
+
await command('git', ['-C', repo, '-c', 'user.name=Pulse Test', '-c', 'user.email=pulse@example.invalid', 'commit', '-m', 'initial']);
|
|
36
|
+
const checkout = await addIsolatedWorktree('owner/repo', join(repo, '.git'), worktree);
|
|
37
|
+
assert.ok(checkout.baseCommit);
|
|
38
|
+
await writeFile(join(worktree, 'source.txt'), 'changed in job\n');
|
|
39
|
+
assert.equal(await readFile(join(repo, 'source.txt'), 'utf8'), 'original\n');
|
|
40
|
+
await removeIsolatedWorktree(checkout);
|
|
41
|
+
} finally { await rm(root, { recursive: true, force: true }); }
|
|
42
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { service } from '../src/service.js';
|
|
7
|
+
|
|
8
|
+
for (const platform of ['darwin','linux','win32']) {
|
|
9
|
+
test(`service: ${platform} retains instance home and supports install, stop, start and uninstall`,async()=>{
|
|
10
|
+
const root=await mkdtemp(join(tmpdir(),'pulse-service-'));
|
|
11
|
+
const saved=process.env.PULSE_RUNNER_HOME;
|
|
12
|
+
process.env.PULSE_RUNNER_HOME=join(root,'instance home');
|
|
13
|
+
const calls=[];
|
|
14
|
+
const options={platform,userHome:join(root,'os-user'),print:()=>{},command:async(exe,args)=>{calls.push({exe,args});return {code:0,output:''};}};
|
|
15
|
+
try {
|
|
16
|
+
await service('install',options);
|
|
17
|
+
if(platform==='darwin') {
|
|
18
|
+
const folder=join(options.userHome,'Library','LaunchAgents');
|
|
19
|
+
const [file]=await readdir(folder);const plist=await readFile(join(folder,file),'utf8');
|
|
20
|
+
assert.match(plist,/<string>--home<\/string>/);
|
|
21
|
+
assert.ok(plist.includes(process.env.PULSE_RUNNER_HOME));
|
|
22
|
+
assert.match(file,/com\.pulse\.runner\.[0-9a-f]{12}\.plist/);
|
|
23
|
+
} else if(platform==='linux') {
|
|
24
|
+
const folder=join(options.userHome,'.config','systemd','user');
|
|
25
|
+
const [file]=await readdir(folder);const unit=await readFile(join(folder,file),'utf8');
|
|
26
|
+
const homeArgument=unit.match(/--home ("(?:\\.|[^"])*")/)?.[1];
|
|
27
|
+
assert.equal(JSON.parse(homeArgument),process.env.PULSE_RUNNER_HOME);
|
|
28
|
+
assert.match(file,/pulse-runner-[0-9a-f]{12}\.service/);
|
|
29
|
+
} else {
|
|
30
|
+
const install=calls.find(c=>c.args.includes('/Create'));
|
|
31
|
+
assert.ok(install.args.some(arg=>arg.includes(`--home "${process.env.PULSE_RUNNER_HOME}"`)));
|
|
32
|
+
assert.ok(install.args.some(arg=>/^PulseRunner-[0-9a-f]{12}$/.test(arg)));
|
|
33
|
+
}
|
|
34
|
+
await service('stop',options);
|
|
35
|
+
const afterStop=calls.length;
|
|
36
|
+
await service('start',options);
|
|
37
|
+
if(platform==='darwin') assert.deepEqual(calls.slice(afterStop).map(c=>c.args[0]),['bootstrap','kickstart']);
|
|
38
|
+
await service('uninstall',options);
|
|
39
|
+
const firstName=calls.find(c=>c.args.some(arg=>/pulse\.runner\.|pulse-runner-|PulseRunner-/.test(arg))).args.join(' ');
|
|
40
|
+
process.env.PULSE_RUNNER_HOME=join(root,'second instance');
|
|
41
|
+
calls.length=0; await service('install',options);
|
|
42
|
+
const secondName=calls.find(c=>c.args.some(arg=>/pulse\.runner\.|pulse-runner-|PulseRunner-/.test(arg))).args.join(' ');
|
|
43
|
+
assert.notEqual(firstName,secondName);
|
|
44
|
+
} finally {
|
|
45
|
+
if(saved===undefined) delete process.env.PULSE_RUNNER_HOME;else process.env.PULSE_RUNNER_HOME=saved;
|
|
46
|
+
await rm(root,{recursive:true,force:true});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|