@pulsehub/runner 0.1.1 → 0.1.3

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