@pulsehub/runner 0.1.4 → 0.1.6

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/src/runtime.js ADDED
@@ -0,0 +1,301 @@
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, readProfiles, 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 { resolveProfile, validateLocalRepo } from './profile.js';
8
+ import { inspectReadiness, assertLocalReadiness, localIdentities } from './readiness.js';
9
+
10
+ export async function logEvent(event, details = {}) {
11
+ try {
12
+ const p = await ensureDataDir();
13
+ const safeDetails = Object.fromEntries(Object.entries(details).map(([key, value]) => [
14
+ key,
15
+ typeof value === 'string' ? sanitize(value) : value,
16
+ ]));
17
+ const file = join(p.logs, 'runner.jsonl');
18
+ await appendFile(file, `${JSON.stringify({ timestamp: new Date().toISOString(), event, ...safeDetails })}\n`, { mode: 0o600 });
19
+ await chmod(file, 0o600);
20
+ } catch {
21
+ // Logging must not turn a successfully completed job into a failed one.
22
+ }
23
+ }
24
+
25
+ export async function request(config, path, body) {
26
+ const credential = await readCredential();
27
+ if (!credential) throw new Error('Runner is not paired. Run pulse-runner pair first.');
28
+ 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) });
29
+ if (!response.ok) throw Object.assign(new Error(`Pulse endpoint rejected request (${response.status}).`), { status: response.status });
30
+ return response.json();
31
+ }
32
+
33
+ function repoPath(repo) { return repo.replace('/', '__'); }
34
+ function failureDetail(output) {
35
+ return sanitize(output).split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(-3).join(' | ').slice(-240);
36
+ }
37
+ function completionSummary(result, publication) {
38
+ const entries = [{
39
+ label: result.code === 0 ? 'Agent completed' : `Agent failed (exit ${result.code})`,
40
+ detail: result.code === 0 ? '' : failureDetail(result.output),
41
+ }];
42
+ for (const published of publication.succeeded) entries.push({ label: 'Publication succeeded', detail: published });
43
+ for (const failed of publication.failed) entries.push({
44
+ label: `Publication failed for ${failed.repo} during ${failed.phase}`,
45
+ detail: failureDetail(failed.error),
46
+ });
47
+ const detailCount = entries.filter((entry) => entry.detail).length;
48
+ const prefixSize = entries.reduce((sum, entry) => sum + entry.label.length + (entry.detail ? 2 : 0), 0) + entries.length - 1;
49
+ const detailBudget = detailCount ? Math.max(0, Math.floor((480 - prefixSize) / detailCount)) : 0;
50
+ return entries.map(({ label, detail }) => detail && detailBudget ? `${label}: ${detail.slice(-detailBudget)}` : label).join('\n').slice(-500);
51
+ }
52
+ async function checkoutRepo(repo, worktree, signal, sourceDirectory) {
53
+ if (sourceDirectory) return addIsolatedWorktree(repo, sourceDirectory, worktree, { signal, sourceDirectory });
54
+ const p = paths(); const mirror = join(p.repos, `${repoPath(repo)}.git`);
55
+ const existing = await command('git', ['--git-dir', mirror, 'rev-parse', '--git-dir']).catch(() => ({ code: 1, output: '' }));
56
+ if (existing.code !== 0) {
57
+ const cloned = await command('git', ['clone', '--mirror', `https://github.com/${repo}.git`, mirror], { signal, timeout: 120_000 });
58
+ if (cloned.code !== 0) throw new Error(sanitize(cloned.output));
59
+ }
60
+ 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));
61
+ return addIsolatedWorktree(repo, mirror, worktree, { signal });
62
+ }
63
+ async function checkout(job, options = {}) {
64
+ const root = join(paths().worktrees, job.id); const repos = [...new Set(job.contextRepos || [job.repoFullName])]; const checkouts = [];
65
+ await mkdir(root, { recursive: true, mode: 0o700 });
66
+ try {
67
+ for (const repo of repos) {
68
+ const checkout = await checkoutRepo(repo, join(root, repoPath(repo)), options.signal, repo === job.repoFullName ? options.profile?.directory : undefined);
69
+ await writeFile(join(checkout.worktree, '.pulse-runner-job'), job.id, { mode: 0o600 });
70
+ checkouts.push(checkout);
71
+ }
72
+ const primary = checkouts.find((checkout) => checkout.repo === job.repoFullName);
73
+ if (!primary) throw new Error('Job context does not include its target repository.');
74
+ return { primary, checkouts };
75
+ } catch (error) { await cleanup(checkouts); await removeTemporary(root); throw error; }
76
+ }
77
+ async function cleanup(checkouts) {
78
+ await Promise.all(checkouts.map(async ({ worktree, mirror, sourceDirectory }) => {
79
+ await removeIsolatedWorktree({ mirror, worktree, sourceDirectory });
80
+ await removeTemporary(worktree);
81
+ }));
82
+ }
83
+ async function publishCheckout(checkout, jobId, checkActive, signal) {
84
+ const branchResult = await command('git', ['-C', checkout.worktree, 'branch', '--show-current'], { signal });
85
+ const branch = branchResult.output.trim();
86
+ if (!branch || !branch.startsWith('pul/')) {
87
+ await logEvent('publication.skipped', { jobId, repo: checkout.repo, reason: 'branch-not-pulse' });
88
+ return '';
89
+ }
90
+ let phase = 'validate-commit';
91
+ const status = await command('git', ['-C', checkout.worktree, 'status', '--porcelain'], { signal });
92
+ const uncommitted = status.output.split('\n').filter((line) => line && !line.endsWith('.pulse-runner-job'));
93
+ if (uncommitted.length > 0) throw Object.assign(new Error(`Uncommitted changes remain in ${checkout.repo}; the agent must commit every changed worktree.`), { phase });
94
+ phase = 'inspect-commit';
95
+ const head = await command('git', ['-C', checkout.worktree, 'rev-parse', 'HEAD'], { signal });
96
+ if (head.code !== 0) throw Object.assign(new Error(sanitize(head.output)), { phase });
97
+ if (head.output.trim() === checkout.baseCommit) {
98
+ await logEvent('publication.skipped', { jobId, repo: checkout.repo, branch, reason: 'no-commits' });
99
+ return '';
100
+ }
101
+ // Sólo se publica una rama de trabajo Pulse de un worktree firmado como
102
+ // contexto de este job. El Runner no elige repos ni destinos externos.
103
+ phase = 'push';
104
+ await logEvent('publication.started', { jobId, repo: checkout.repo, branch, phase });
105
+ await checkActive();
106
+ const pushed = await command('git', [...(checkout.sourceDirectory ? ['-C', checkout.worktree] : ['-c', 'remote.origin.mirror=false', '--git-dir', checkout.mirror]), 'push', 'origin', `refs/heads/${branch}:refs/heads/${branch}`], { signal, timeout: 120_000 });
107
+ if (pushed.code !== 0) throw Object.assign(new Error(sanitize(pushed.output)), { phase });
108
+ await logEvent('publication.pushed', { jobId, repo: checkout.repo, branch, phase });
109
+ phase = 'find-pull-request';
110
+ const existing = await command(resolveExecutable('gh'), ['pr', 'view', branch, '--repo', checkout.repo, '--json', 'url'], { signal, timeout: 30_000 });
111
+ if (existing.code === 0) {
112
+ await logEvent('publication.pull-request-exists', { jobId, repo: checkout.repo, branch });
113
+ return `Published ${checkout.repo}:${branch}; existing PR detected.`;
114
+ }
115
+ if (!/no pull requests found/i.test(existing.output)) throw Object.assign(new Error(sanitize(existing.output)), { phase });
116
+ phase = 'create-pull-request';
117
+ await checkActive();
118
+ const created = await command(resolveExecutable('gh'), ['pr', 'create', '--repo', checkout.repo, '--base', 'main', '--head', branch, '--fill'], { signal, timeout: 30_000 });
119
+ if (created.code !== 0) throw Object.assign(new Error(sanitize(created.output)), { phase });
120
+ await logEvent('publication.pull-request-created', { jobId, repo: checkout.repo, branch, url: created.output.trim() });
121
+ return `Published ${checkout.repo}:${branch}; ${created.output.trim()}`;
122
+ }
123
+ async function publish(checkouts, jobId, checkActive, signal) {
124
+ const publications = { succeeded: [], failed: [] };
125
+ for (const checkout of checkouts) {
126
+ try {
127
+ const published = await publishCheckout(checkout, jobId, checkActive, signal);
128
+ if (published) publications.succeeded.push(published);
129
+ } catch (error) {
130
+ if (error.name === 'AbortError') throw error;
131
+ const failure = { repo: checkout.repo, phase: error.phase || 'prepare', error: sanitize(error.message) };
132
+ publications.failed.push(failure);
133
+ await logEvent('publication.failed', { jobId, ...failure });
134
+ }
135
+ }
136
+ return publications;
137
+ }
138
+ let readinessCache;
139
+ async function readinessFor(config, force = false) {
140
+ const key = JSON.stringify([config.workspaceId, localIdentities(config), config.repos]);
141
+ if (force || !readinessCache || readinessCache.key !== key || Date.now() - readinessCache.at > 60_000) readinessCache = { key, at: Date.now(), value: await inspectReadiness(config) };
142
+ return readinessCache.value;
143
+ }
144
+ /** Run one delivered job. Dependencies allow integration tests to use a local control plane. */
145
+ export async function runOnce(config, options = {}) {
146
+ const remote = options.request || request;
147
+ const readinessForJob = options.readinessFor || readinessFor;
148
+ const prepare = options.checkout || checkout;
149
+ const publishWork = options.publish || publish;
150
+ const cleanWork = options.cleanup || cleanup;
151
+ const controller = new AbortController();
152
+ const cancel = () => controller.abort();
153
+ if (options.signal?.aborted) return false;
154
+ options.signal?.addEventListener('abort', cancel, { once: true });
155
+ let watcher;
156
+ let expiryTimer;
157
+ const stopWatching = new AbortController();
158
+ let job;
159
+ let jobPhase = 'poll';
160
+ let completionReported = false;
161
+ const journal = join(paths().root, 'active-job.json');
162
+ const checkActive = async () => {
163
+ controller.signal.throwIfAborted();
164
+ if (Date.parse(job.expiresAt) <= Date.now()) controller.abort();
165
+ const response = await remote(config, 'pulseRunnerHeartbeat', { status: 'busy', jobId: job.id });
166
+ if (response.cancelRequested) controller.abort();
167
+ controller.signal.throwIfAborted();
168
+ };
169
+ try {
170
+ const readiness = await readinessForJob(config);
171
+ await remote(config, 'pulseRunnerHeartbeat', { status: 'online', readiness });
172
+ const delivery = await remote(config, 'pulseRunnerPoll', {});
173
+ if (!delivery.job) return false;
174
+ job = delivery.job;
175
+ await logEvent('job.received', { jobId: job.id, issueId: job.issueId, agentId: job.agentId, mode: job.mode, repo: job.repoFullName });
176
+ jobPhase = 'validate-job';
177
+ const agent = delivery.agent || { kind: config.agentKind, role: job.agentId === config.agentId ? 'dev' : null };
178
+ validateJob(job, config, agent);
179
+ jobPhase = 'local-preflight';
180
+ const profile = await resolveProfile({ ...config, agentKind: agent.kind }, job);
181
+ const jobConfig = job.protocolVersion === 2 ? { ...config, repos: job.contextRepos || [job.repoFullName] } : config;
182
+ assertLocalReadiness(job, jobConfig, agent, await readinessForJob(jobConfig, true));
183
+ await logEvent('job.validated', { jobId: job.id, repoCount: (job.contextRepos || [job.repoFullName]).length });
184
+ await checkActive();
185
+ expiryTimer = setTimeout(cancel, Math.max(0, Date.parse(job.expiresAt) - Date.now()));
186
+ expiryTimer.unref();
187
+ // Heartbeat stays outbound while the CLI runs. Losing authorization or
188
+ // contact with Pulse stops local execution rather than publishing blindly.
189
+ watcher = (async () => {
190
+ while (!stopWatching.signal.aborted) {
191
+ await new Promise((resolve) => {
192
+ const done = () => { clearTimeout(timer); stopWatching.signal.removeEventListener('abort', done); resolve(); };
193
+ const timer = setTimeout(done, options.heartbeatIntervalMs || 2000);
194
+ stopWatching.signal.addEventListener('abort', done, { once: true });
195
+ });
196
+ if (stopWatching.signal.aborted) break;
197
+ try { await checkActive(); } catch { controller.abort(); break; }
198
+ }
199
+ })();
200
+ jobPhase = 'prepare-worktrees';
201
+ await writeFile(journal, JSON.stringify({ jobId: job.id }), { mode: 0o600 });
202
+ await chmod(journal, 0o600);
203
+ const { primary, checkouts } = await prepare(job, { signal: controller.signal, profile });
204
+ const temp = join(paths().root, 'tmp', job.id);
205
+ let completion;
206
+ try {
207
+ controller.signal.throwIfAborted();
208
+ jobPhase = 'run-agent';
209
+ await logEvent('agent.started', { jobId: job.id, repo: primary.repo, mode: job.mode });
210
+ const result = await runAgent(agent.kind, job, primary.worktree, checkouts.filter((entry) => entry !== primary), config, delivery.mcpCredential, temp, { signal: controller.signal, profile });
211
+ controller.signal.throwIfAborted();
212
+ await checkActive();
213
+ await logEvent(result.code === 0 ? 'agent.completed' : 'agent.failed', { jobId: job.id, exitCode: result.code, ...(result.code === 0 ? {} : { error: failureDetail(result.output) }) });
214
+ jobPhase = 'publication';
215
+ const publication = result.code === 0 && job.mode !== 'review' ? await publishWork(checkouts, job.id, checkActive, controller.signal) : { succeeded: [], failed: [] };
216
+ await checkActive();
217
+ const outcome = result.code === 0 && publication.failed.length === 0 ? 'completed' : 'failed';
218
+ const failure = outcome === 'failed' ? { phase: result.code !== 0 ? 'run-agent' : publication.failed[0]?.phase || 'publication', category: 'execution', correlationId: job.id } : null;
219
+ jobPhase = 'report-completion';
220
+ completion = { jobId: job.id, outcome, result: sanitize(completionSummary(result, publication)), failure };
221
+ } finally {
222
+ await removeTemporary(temp);
223
+ await cleanWork(checkouts);
224
+ await removeTemporary(join(paths().worktrees, job.id));
225
+ }
226
+ controller.signal.throwIfAborted();
227
+ await remote(config, 'pulseRunnerComplete', completion);
228
+ completionReported = true;
229
+ await logEvent('job.completed', { jobId: job.id, outcome: completion.outcome });
230
+ } catch (error) {
231
+ if (!job) throw error;
232
+ const canceled = controller.signal.aborted;
233
+ const phase = error.phase || jobPhase;
234
+ const message = canceled ? 'Job canceled; local execution stopped.' : error?.message || String(error);
235
+ await logEvent(canceled ? 'job.canceled' : 'job.failed', { jobId: job.id, phase, error: message });
236
+ 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) => {
237
+ await logEvent('job.completion-report-failed', { jobId: job.id, error: completionError.message });
238
+ });
239
+ if (!canceled) throw error;
240
+ } finally {
241
+ stopWatching.abort();
242
+ if (expiryTimer) clearTimeout(expiryTimer);
243
+ await watcher;
244
+ if (completionReported) await rm(journal, { force: true });
245
+ options.signal?.removeEventListener('abort', cancel);
246
+ if (job) await remote(config, 'pulseRunnerHeartbeat', { status: 'online' }).catch(() => {});
247
+ }
248
+ return true;
249
+ }
250
+
251
+ export async function recoverInterruptedWork(config) {
252
+ const p = await ensureDataDir();
253
+ for (const area of [p.worktrees, join(p.root, 'tmp')]) {
254
+ await mkdir(area, { recursive: true, mode: 0o700 });
255
+ const jobs = await readdir(area, { withFileTypes: true });
256
+ await Promise.all(jobs.map((entry) => rm(join(area, entry.name), { recursive: true, force: true })));
257
+ }
258
+ const repositories = await readdir(p.repos, { withFileTypes: true });
259
+ await Promise.all(repositories.filter((entry) => entry.isDirectory()).map((entry) => command('git', ['--git-dir', join(p.repos, entry.name), 'worktree', 'prune']).catch(() => {})));
260
+ const profiles = await readProfiles();
261
+ for (const profile of Object.values(profiles)) {
262
+ for (const [repo, directory] of Object.entries(profile.directories || {})) {
263
+ const source = await validateLocalRepo(config, repo, directory).catch(() => null);
264
+ if (source) await command('git', ['-C', source, 'worktree', 'prune']).catch(() => {});
265
+ }
266
+ }
267
+ if (config) {
268
+ const journal = join(p.root, 'active-job.json');
269
+ const interrupted = await readFile(journal, 'utf8').then(JSON.parse).catch((error) => {
270
+ if (error.code === 'ENOENT') return null;
271
+ throw error;
272
+ });
273
+ if (interrupted) {
274
+ await request(config, 'pulseRunnerComplete', { jobId: interrupted.jobId, outcome: 'canceled', result: 'Runner restarted; interrupted worktrees and temporary MCP files cleaned.' }).catch((error) => {
275
+ if (![404, 409].includes(error.status)) throw error;
276
+ });
277
+ await rm(journal, { force: true });
278
+ }
279
+ }
280
+ }
281
+
282
+ /** A per-data-directory lock covers both the daemon and one-shot execution. */
283
+ export async function withRunnerLock(run) {
284
+ const p = await ensureDataDir();
285
+ const lockPath = join(p.root, 'runner.lock');
286
+ let lock;
287
+ try { lock = await open(lockPath, 'wx', 0o600); }
288
+ catch (error) {
289
+ if (error.code !== 'EEXIST') throw error;
290
+ const pid = Number((await readFile(lockPath, 'utf8')).trim());
291
+ let active = false;
292
+ if (Number.isInteger(pid) && pid > 0) {
293
+ try { process.kill(pid, 0); active = true; } catch (probeError) { active = probeError.code !== 'ESRCH'; }
294
+ } else active = Date.now() - (await stat(lockPath)).mtimeMs < 10_000;
295
+ if (active) throw new Error('Pulse Runner is already active.');
296
+ await rm(lockPath, { force: true });
297
+ lock = await open(lockPath, 'wx', 0o600);
298
+ }
299
+ try { await lock.writeFile(`${process.pid}\n`); return await run(); }
300
+ finally { await lock.close(); await rm(lockPath, { force: true }); }
301
+ }
package/src/service.js ADDED
@@ -0,0 +1,83 @@
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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
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
+ }
@@ -0,0 +1,19 @@
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 prefix = options.sourceDirectory ? ['-C', options.sourceDirectory] : ['--git-dir', gitDir];
7
+ const added = await command('git', [...prefix, 'worktree', 'add', '--detach', worktree, 'HEAD'], options);
8
+ if (added.code !== 0) throw new Error(sanitize(added.output));
9
+ const base = await command('git', ['-C', worktree, 'rev-parse', 'HEAD'], options);
10
+ if (base.code !== 0) {
11
+ await command('git', [...prefix, 'worktree', 'remove', '--force', worktree]).catch(() => {});
12
+ throw new Error(sanitize(base.output));
13
+ }
14
+ return { repo, mirror: options.sourceDirectory ? null : gitDir, sourceDirectory: options.sourceDirectory, worktree, baseCommit: base.output.trim() };
15
+ }
16
+
17
+ export async function removeIsolatedWorktree(checkout) {
18
+ await command('git', [...(checkout.sourceDirectory ? ['-C', checkout.sourceDirectory] : ['--git-dir', checkout.mirror]), 'worktree', 'remove', '--force', checkout.worktree]).catch(() => {});
19
+ }