@ai-dossier/cli 0.89.4 → 0.89.5-next.548.1

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.
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `sched ensure-running` — the non-systemd watchdog (#945 AC1). Run every
3
+ * minute (cron): if no live engine holds the lease, raise the once-per-episode
4
+ * stale-lease alert and start one, detached, with its output appended to
5
+ * `<sched-dir>/engine.log`. Starting is race-safe by construction — the engine
6
+ * itself takes the lease atomically, so two watchdogs (or a watchdog and a
7
+ * systemd unit) can never run two engines.
8
+ *
9
+ * `--disable` / `--enable` toggle a marker file so an operator can stop the
10
+ * engine on purpose without the watchdog resurrecting it.
11
+ */
12
+ import type { AlertNotifier } from '@ai-dossier/sched';
13
+ import { type Journal, type SchedStore } from '@ai-dossier/sched';
14
+ export declare const ENSURE_DISABLED_MARKER = ".ensure-running-disabled";
15
+ /**
16
+ * No respawn storm. The minimum gap between watchdog starts is
17
+ * `ENSURE_BASE_INTERVAL_MS * 2^streak` (capped), where `streak` counts starts
18
+ * that were NOT followed by a live engine at the next check. At a 60 s cron
19
+ * cadence: the first restart is immediate, the second waits 60 s, then 120 s,
20
+ * 240 s … up to {@link ENSURE_MAX_INTERVAL_MS}. Seeing a live engine resets it.
21
+ */
22
+ export declare const ENSURE_BASE_INTERVAL_MS = 30000;
23
+ export declare const ENSURE_MAX_INTERVAL_MS: number;
24
+ export type EnsureOutcome = 'alive' | 'disabled' | 'throttled' | 'started' | 'start-failed';
25
+ export interface EnsureDeps {
26
+ store: SchedStore;
27
+ journal: Journal;
28
+ notify: AlertNotifier;
29
+ /** Spawn the engine detached; returns its pid or null when the spawn failed. */
30
+ spawnEngine: (logFile: string) => number | null;
31
+ now: () => Date;
32
+ }
33
+ export declare function setEnsureDisabled(store: SchedStore, disabled: boolean): void;
34
+ export declare function ensureRunning(deps: EnsureDeps): EnsureOutcome;
35
+ //# sourceMappingURL=sched-ensure.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sched-ensure.d.ts","sourceRoot":"","sources":["../src/sched-ensure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,EAAwB,KAAK,OAAO,EAAE,KAAK,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAExF,eAAO,MAAM,sBAAsB,6BAA6B,CAAC;AACjE;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,QAAS,CAAC;AAC9C,eAAO,MAAM,sBAAsB,QAAc,CAAC;AAsBlD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,UAAU,GAAG,WAAW,GAAG,SAAS,GAAG,cAAc,CAAC;AAE5F,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,UAAU,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,aAAa,CAAC;IACtB,gFAAgF;IAChF,WAAW,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;IAChD,GAAG,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,EAAE,QAAQ,EAAE,OAAO,GAAG,IAAI,CAQ5E;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,UAAU,GAAG,aAAa,CA8C7D"}
@@ -0,0 +1,128 @@
1
+ "use strict";
2
+ /**
3
+ * `sched ensure-running` — the non-systemd watchdog (#945 AC1). Run every
4
+ * minute (cron): if no live engine holds the lease, raise the once-per-episode
5
+ * stale-lease alert and start one, detached, with its output appended to
6
+ * `<sched-dir>/engine.log`. Starting is race-safe by construction — the engine
7
+ * itself takes the lease atomically, so two watchdogs (or a watchdog and a
8
+ * systemd unit) can never run two engines.
9
+ *
10
+ * `--disable` / `--enable` toggle a marker file so an operator can stop the
11
+ * engine on purpose without the watchdog resurrecting it.
12
+ */
13
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ var desc = Object.getOwnPropertyDescriptor(m, k);
16
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
17
+ desc = { enumerable: true, get: function() { return m[k]; } };
18
+ }
19
+ Object.defineProperty(o, k2, desc);
20
+ }) : (function(o, m, k, k2) {
21
+ if (k2 === undefined) k2 = k;
22
+ o[k2] = m[k];
23
+ }));
24
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
25
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
26
+ }) : function(o, v) {
27
+ o["default"] = v;
28
+ });
29
+ var __importStar = (this && this.__importStar) || (function () {
30
+ var ownKeys = function(o) {
31
+ ownKeys = Object.getOwnPropertyNames || function (o) {
32
+ var ar = [];
33
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
34
+ return ar;
35
+ };
36
+ return ownKeys(o);
37
+ };
38
+ return function (mod) {
39
+ if (mod && mod.__esModule) return mod;
40
+ var result = {};
41
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
42
+ __setModuleDefault(result, mod);
43
+ return result;
44
+ };
45
+ })();
46
+ Object.defineProperty(exports, "__esModule", { value: true });
47
+ exports.ENSURE_MAX_INTERVAL_MS = exports.ENSURE_BASE_INTERVAL_MS = exports.ENSURE_DISABLED_MARKER = void 0;
48
+ exports.setEnsureDisabled = setEnsureDisabled;
49
+ exports.ensureRunning = ensureRunning;
50
+ const fs = __importStar(require("node:fs"));
51
+ const path = __importStar(require("node:path"));
52
+ const sched_1 = require("@ai-dossier/sched");
53
+ exports.ENSURE_DISABLED_MARKER = '.ensure-running-disabled';
54
+ /**
55
+ * No respawn storm. The minimum gap between watchdog starts is
56
+ * `ENSURE_BASE_INTERVAL_MS * 2^streak` (capped), where `streak` counts starts
57
+ * that were NOT followed by a live engine at the next check. At a 60 s cron
58
+ * cadence: the first restart is immediate, the second waits 60 s, then 120 s,
59
+ * 240 s … up to {@link ENSURE_MAX_INTERVAL_MS}. Seeing a live engine resets it.
60
+ */
61
+ exports.ENSURE_BASE_INTERVAL_MS = 30_000;
62
+ exports.ENSURE_MAX_INTERVAL_MS = 30 * 60_000;
63
+ const ENSURE_LAST_MARKER = '.ensure-running-last';
64
+ function readRecord(file) {
65
+ try {
66
+ const raw = fs.readFileSync(file, 'utf8').trim();
67
+ // Older format: a bare timestamp.
68
+ if (/^\d+$/.test(raw))
69
+ return { at: Number(raw), streak: 1 };
70
+ const rec = JSON.parse(raw);
71
+ return typeof rec.at === 'number' && typeof rec.streak === 'number'
72
+ ? { at: rec.at, streak: rec.streak }
73
+ : null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ function setEnsureDisabled(store, disabled) {
80
+ const marker = path.join(store.dir, exports.ENSURE_DISABLED_MARKER);
81
+ if (disabled) {
82
+ fs.mkdirSync(store.dir, { recursive: true, mode: 0o700 });
83
+ fs.writeFileSync(marker, `${new Date().toISOString()}\n`, { mode: 0o600 });
84
+ }
85
+ else {
86
+ fs.rmSync(marker, { force: true });
87
+ }
88
+ }
89
+ function ensureRunning(deps) {
90
+ const { store, journal } = deps;
91
+ if (fs.existsSync(path.join(store.dir, exports.ENSURE_DISABLED_MARKER)))
92
+ return 'disabled';
93
+ const lease = store.engineLeaseStatus();
94
+ if (lease?.alive) {
95
+ // A healthy engine ends the crash-loop episode.
96
+ fs.rmSync(path.join(store.dir, ENSURE_LAST_MARKER), { force: true });
97
+ return 'alive';
98
+ }
99
+ // Dead or absent: alert (deduped per episode), then bring the engine back.
100
+ try {
101
+ (0, sched_1.checkStaleLeaseAlert)(store, journal, deps.notify, deps.now());
102
+ }
103
+ catch (err) {
104
+ // An unreadable state must not stop the restart attempt — the alert is advisory.
105
+ process.stderr.write(`⚠ sched ensure-running: stale-lease alert skipped: ${err.message}\n`);
106
+ }
107
+ const lastFile = path.join(store.dir, ENSURE_LAST_MARKER);
108
+ const last = readRecord(lastFile);
109
+ const streak = last === null ? 0 : last.streak;
110
+ if (last !== null) {
111
+ const gap = Math.min(exports.ENSURE_BASE_INTERVAL_MS * 2 ** (streak - 1), exports.ENSURE_MAX_INTERVAL_MS);
112
+ if (deps.now().getTime() - last.at < gap)
113
+ return 'throttled';
114
+ }
115
+ fs.mkdirSync(store.dir, { recursive: true, mode: 0o700 });
116
+ fs.writeFileSync(lastFile, `${JSON.stringify({ at: deps.now().getTime(), streak: streak + 1 })}\n`, { mode: 0o600 });
117
+ const pid = deps.spawnEngine(path.join(store.dir, 'engine.log'));
118
+ journal.append({
119
+ event: 'engine-started',
120
+ ...(pid !== null ? { pid } : {}),
121
+ reason: pid !== null ? 'ensure-running' : 'ensure-running-spawn-failed',
122
+ detail: pid !== null
123
+ ? `watchdog started the engine (${lease ? `stale lease pid ${lease.pid}` : 'no lease'})`
124
+ : 'watchdog could not spawn the engine',
125
+ }, deps.now());
126
+ return pid !== null ? 'started' : 'start-failed';
127
+ }
128
+ //# sourceMappingURL=sched-ensure.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sched-ensure.js","sourceRoot":"","sources":["../src/sched-ensure.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Supervised engine deployment (#945 AC1): `sched service install|uninstall|status`.
3
+ *
4
+ * The engine is safe to kill at any instant (lease + heartbeat, crash-safe
5
+ * state, work preserved before respawn) — what it cannot do is restart ITSELF.
6
+ * This module makes the supervisor the default rather than a paragraph in a
7
+ * README:
8
+ *
9
+ * - systemd *user* unit `dossier-sched-<project>.service`: Restart=always,
10
+ * KillMode=process (agents outlive the engine, #679), the node/nvm PATH
11
+ * captured at install time, WorkingDirectory = the project checkout (the
12
+ * engine treats its cwd as the repo). Output goes to the journal
13
+ * (`journalctl --user -u <unit>`); the engine's own `events.jsonl` journal
14
+ * is the durable per-project trail.
15
+ * - non-systemd fallback: a tagged crontab block — `@reboot` plus a
16
+ * once-a-minute `sched ensure-running` watchdog that starts the engine when
17
+ * its lease is stale or absent.
18
+ *
19
+ * Everything is injectable (exec, fs roots, crontab I/O) so unit generation and
20
+ * both install paths are tested against temp directories — no test, and no
21
+ * dry run, ever touches the real systemd or crontab.
22
+ */
23
+ export type ServiceMode = 'systemd' | 'cron';
24
+ export interface ServiceSpec {
25
+ project: string;
26
+ /** Node binary (`process.execPath`) — an absolute path, so nvm needs no shell init. */
27
+ nodePath: string;
28
+ /** The CLI entry the unit runs (`process.argv[1]`, the `ai-dossier` launcher — stable across `npm i -g`). */
29
+ cliPath: string;
30
+ /** The project's checkout: WorkingDirectory / cron `cd`. */
31
+ repoDir: string;
32
+ /** PATH captured at install time (nvm's node dir, gh, git, claude). */
33
+ envPath: string;
34
+ autoUpgrade: boolean;
35
+ alertIssue?: number;
36
+ }
37
+ /**
38
+ * PATH for a long-lived service: absolute entries only (a relative one such as
39
+ * `./node_modules/.bin` would resolve against the service's cwd — the project
40
+ * checkout — letting a repo's files shadow `git`/`gh`), de-duplicated in order.
41
+ */
42
+ export declare function serviceEnvPath(raw: string | undefined): string;
43
+ export declare const SERVICE_PREFIX = "dossier-sched-";
44
+ export declare function unitName(project: string): string;
45
+ export declare function startArgs(spec: ServiceSpec): string[];
46
+ export declare const START_LIMIT_INTERVAL_SEC = 600;
47
+ export declare const START_LIMIT_BURST = 10;
48
+ export declare function renderSystemdUnit(spec: ServiceSpec): string;
49
+ export declare function renderCronBlock(spec: ServiceSpec): string;
50
+ /** Remove this project's tagged block from a crontab; returns the text unchanged when there is none. */
51
+ export declare function stripCronBlock(crontab: string, project: string): string;
52
+ export declare function hasCronBlock(crontab: string, project: string): boolean;
53
+ /** New crontab text with exactly one up-to-date block for `spec.project` (idempotent). */
54
+ export declare function upsertCronBlock(crontab: string, spec: ServiceSpec): string;
55
+ /**
56
+ * A supervisor pins absolute paths at install time, so an ephemeral one (a temp
57
+ * dir, a batch/issue worktree, a removed checkout) silently breaks it later.
58
+ * Returns hard errors (refuse to install) and soft warnings.
59
+ */
60
+ export declare function validateServicePaths(spec: ServiceSpec, exists?: (p: string) => boolean, tmp?: string): {
61
+ errors: string[];
62
+ warnings: string[];
63
+ };
64
+ /** Run a command; null on failure (the `ExecFn` contract). */
65
+ export type RunFn = (file: string, args: string[]) => string | null;
66
+ export interface ServiceIo {
67
+ run: RunFn;
68
+ /** Directory for the systemd user unit (default `~/.config/systemd/user`). */
69
+ unitDir: string;
70
+ /**
71
+ * The current crontab text. `''` ONLY when the user verifiably has none
72
+ * ("no crontab for <user>"); any other failure THROWS — installing over an
73
+ * unreadable crontab would overwrite the user's whole schedule.
74
+ */
75
+ readCrontab: () => string;
76
+ writeCrontab: (text: string) => boolean;
77
+ /** When true no `systemctl`/`crontab` is invoked — files are only rendered (tests, `--no-activate`). */
78
+ render: boolean;
79
+ }
80
+ export declare function defaultUnitDir(home?: string): string;
81
+ /** Whether `systemctl --user` is usable here (a user manager is running). */
82
+ export declare function systemdUserAvailable(run: RunFn): boolean;
83
+ export interface InstallResult {
84
+ mode: ServiceMode;
85
+ changed: boolean;
86
+ /** False when anything the install had to do failed — the caller must exit non-zero. */
87
+ ok: boolean;
88
+ /** True only when the supervisor is really active (enabled/started, or the crontab written); false under render-only. */
89
+ activated: boolean;
90
+ unitPath?: string;
91
+ notes: string[];
92
+ }
93
+ export declare function installService(spec: ServiceSpec, mode: ServiceMode, io: ServiceIo): InstallResult;
94
+ export declare function uninstallService(project: string, io: ServiceIo): {
95
+ removed: string[];
96
+ errors: string[];
97
+ };
98
+ export interface ServiceStatus {
99
+ project: string;
100
+ systemd: {
101
+ installed: boolean;
102
+ enabled: string | null;
103
+ active: string | null;
104
+ unitPath: string;
105
+ };
106
+ cron: {
107
+ installed: boolean;
108
+ error?: string;
109
+ };
110
+ supervised: boolean;
111
+ }
112
+ export declare function serviceStatus(project: string, io: ServiceIo): ServiceStatus;
113
+ //# sourceMappingURL=sched-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sched-service.d.ts","sourceRoot":"","sources":["../src/sched-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,364 @@
1
+ "use strict";
2
+ /**
3
+ * Supervised engine deployment (#945 AC1): `sched service install|uninstall|status`.
4
+ *
5
+ * The engine is safe to kill at any instant (lease + heartbeat, crash-safe
6
+ * state, work preserved before respawn) — what it cannot do is restart ITSELF.
7
+ * This module makes the supervisor the default rather than a paragraph in a
8
+ * README:
9
+ *
10
+ * - systemd *user* unit `dossier-sched-<project>.service`: Restart=always,
11
+ * KillMode=process (agents outlive the engine, #679), the node/nvm PATH
12
+ * captured at install time, WorkingDirectory = the project checkout (the
13
+ * engine treats its cwd as the repo). Output goes to the journal
14
+ * (`journalctl --user -u <unit>`); the engine's own `events.jsonl` journal
15
+ * is the durable per-project trail.
16
+ * - non-systemd fallback: a tagged crontab block — `@reboot` plus a
17
+ * once-a-minute `sched ensure-running` watchdog that starts the engine when
18
+ * its lease is stale or absent.
19
+ *
20
+ * Everything is injectable (exec, fs roots, crontab I/O) so unit generation and
21
+ * both install paths are tested against temp directories — no test, and no
22
+ * dry run, ever touches the real systemd or crontab.
23
+ */
24
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ var desc = Object.getOwnPropertyDescriptor(m, k);
27
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
28
+ desc = { enumerable: true, get: function() { return m[k]; } };
29
+ }
30
+ Object.defineProperty(o, k2, desc);
31
+ }) : (function(o, m, k, k2) {
32
+ if (k2 === undefined) k2 = k;
33
+ o[k2] = m[k];
34
+ }));
35
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
36
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
37
+ }) : function(o, v) {
38
+ o["default"] = v;
39
+ });
40
+ var __importStar = (this && this.__importStar) || (function () {
41
+ var ownKeys = function(o) {
42
+ ownKeys = Object.getOwnPropertyNames || function (o) {
43
+ var ar = [];
44
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
45
+ return ar;
46
+ };
47
+ return ownKeys(o);
48
+ };
49
+ return function (mod) {
50
+ if (mod && mod.__esModule) return mod;
51
+ var result = {};
52
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
53
+ __setModuleDefault(result, mod);
54
+ return result;
55
+ };
56
+ })();
57
+ Object.defineProperty(exports, "__esModule", { value: true });
58
+ exports.START_LIMIT_BURST = exports.START_LIMIT_INTERVAL_SEC = exports.SERVICE_PREFIX = void 0;
59
+ exports.serviceEnvPath = serviceEnvPath;
60
+ exports.unitName = unitName;
61
+ exports.startArgs = startArgs;
62
+ exports.renderSystemdUnit = renderSystemdUnit;
63
+ exports.renderCronBlock = renderCronBlock;
64
+ exports.stripCronBlock = stripCronBlock;
65
+ exports.hasCronBlock = hasCronBlock;
66
+ exports.upsertCronBlock = upsertCronBlock;
67
+ exports.validateServicePaths = validateServicePaths;
68
+ exports.defaultUnitDir = defaultUnitDir;
69
+ exports.systemdUserAvailable = systemdUserAvailable;
70
+ exports.installService = installService;
71
+ exports.uninstallService = uninstallService;
72
+ exports.serviceStatus = serviceStatus;
73
+ const fs = __importStar(require("node:fs"));
74
+ const os = __importStar(require("node:os"));
75
+ const path = __importStar(require("node:path"));
76
+ /**
77
+ * PATH for a long-lived service: absolute entries only (a relative one such as
78
+ * `./node_modules/.bin` would resolve against the service's cwd — the project
79
+ * checkout — letting a repo's files shadow `git`/`gh`), de-duplicated in order.
80
+ */
81
+ function serviceEnvPath(raw) {
82
+ const seen = new Set();
83
+ for (const entry of (raw ?? '').split(':')) {
84
+ if (entry.startsWith('/') && !seen.has(entry))
85
+ seen.add(entry);
86
+ }
87
+ return seen.size > 0 ? [...seen].join(':') : '/usr/local/bin:/usr/bin:/bin';
88
+ }
89
+ exports.SERVICE_PREFIX = 'dossier-sched-';
90
+ function unitName(project) {
91
+ return `${exports.SERVICE_PREFIX}${project.replace(/[^A-Za-z0-9._-]+/g, '-')}.service`;
92
+ }
93
+ /** Systemd unit values may not contain newlines and are `%`-expanded — reject/escape both. */
94
+ function unitValue(v) {
95
+ if (/[\r\n]/.test(v))
96
+ throw new Error(`refusing a newline in a unit value: ${JSON.stringify(v)}`);
97
+ return v.replace(/%/g, '%%');
98
+ }
99
+ /** Quote a systemd unit value that may contain spaces, quotes, or backslashes. */
100
+ function quotedUnitValue(v) {
101
+ if (/[\r\n]/.test(v))
102
+ throw new Error(`refusing a newline in a unit value: ${JSON.stringify(v)}`);
103
+ return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/%/g, '%%')}"`;
104
+ }
105
+ /** Quote one ExecStart argument (systemd's own quoting: double quotes, `\"` and `\\`). */
106
+ function execArg(v) {
107
+ if (/[\r\n]/.test(v))
108
+ throw new Error(`refusing a newline in an ExecStart argument`);
109
+ const escaped = v
110
+ .replace(/\\/g, '\\\\')
111
+ .replace(/"/g, '\\"')
112
+ .replace(/%/g, '%%')
113
+ .replace(/\$/g, () => '$$');
114
+ return /^[A-Za-z0-9_./:@=+-]+$/.test(v) ? escaped : `"${escaped}"`;
115
+ }
116
+ function startArgs(spec) {
117
+ return [
118
+ 'sched',
119
+ 'start',
120
+ '--project',
121
+ spec.project,
122
+ ...(spec.autoUpgrade ? ['--auto-upgrade'] : []),
123
+ ...(spec.alertIssue !== undefined ? ['--alert-issue', String(spec.alertIssue)] : []),
124
+ ];
125
+ }
126
+ exports.START_LIMIT_INTERVAL_SEC = 600;
127
+ exports.START_LIMIT_BURST = 10;
128
+ function renderSystemdUnit(spec) {
129
+ const exec = [spec.nodePath, spec.cliPath, ...startArgs(spec)].map(execArg).join(' ');
130
+ return `${[
131
+ '# Managed by `ai-dossier sched service install` (#945) — re-run it to update, `sched service uninstall` to remove.',
132
+ '[Unit]',
133
+ `Description=dossier sched engine (${unitValue(spec.project)})`,
134
+ 'After=network-online.target',
135
+ 'Wants=network-online.target',
136
+ // A crash-looping engine must not restart every few seconds forever: at most
137
+ // START_LIMIT_BURST starts per START_LIMIT_INTERVAL_SEC, then the unit is left
138
+ // `failed` for an operator (`systemctl --user reset-failed <unit>` + start).
139
+ `StartLimitIntervalSec=${exports.START_LIMIT_INTERVAL_SEC}`,
140
+ `StartLimitBurst=${exports.START_LIMIT_BURST}`,
141
+ '',
142
+ '[Service]',
143
+ 'Type=simple',
144
+ `WorkingDirectory=${quotedUnitValue(spec.repoDir)}`,
145
+ `Environment=${quotedUnitValue(`PATH=${spec.envPath}`)}`,
146
+ `ExecStart=${exec}`,
147
+ // Always: a clean exit (a graceful stop, an upgrade restart) must come back too.
148
+ 'Restart=always',
149
+ // Exponential backoff between restarts (systemd >= 254; older versions ignore
150
+ // the two Restart*= lines below with a warning and keep RestartSec).
151
+ 'RestartSec=10',
152
+ 'RestartSteps=5',
153
+ 'RestartMaxDelaySec=300',
154
+ // Kill ONLY the engine on stop/restart — dispatched agents outlive it (#679).
155
+ 'KillMode=process',
156
+ 'TimeoutStopSec=60',
157
+ '',
158
+ '[Install]',
159
+ 'WantedBy=default.target',
160
+ ].join('\n')}\n`;
161
+ }
162
+ // --- cron fallback ---
163
+ const CRON_BEGIN = (project) => `# BEGIN dossier-sched ${project}`;
164
+ const CRON_END = (project) => `# END dossier-sched ${project}`;
165
+ /** Single-quote for /bin/sh. */
166
+ function shq(v) {
167
+ if (/[\r\n\0]/.test(v))
168
+ throw new Error('refusing a newline in a cron value');
169
+ return `'${v.replace(/'/g, `'\\''`)}'`;
170
+ }
171
+ function renderCronBlock(spec) {
172
+ const cmd = `cd ${shq(spec.repoDir)} && PATH=${shq(spec.envPath)} ${shq(spec.nodePath)} ${shq(spec.cliPath)}`;
173
+ // cron turns an unescaped `%` in a command into a newline (and feeds the rest to stdin).
174
+ const ensure = `${cmd} sched ensure-running --project ${shq(spec.project)}${spec.autoUpgrade ? ' --auto-upgrade' : ''}${spec.alertIssue !== undefined ? ` --alert-issue ${spec.alertIssue}` : ''}`.replace(/%/g, '\\%');
175
+ return [
176
+ CRON_BEGIN(spec.project),
177
+ `@reboot ${ensure} >/dev/null 2>&1`,
178
+ `* * * * * ${ensure} >/dev/null 2>&1`,
179
+ CRON_END(spec.project),
180
+ ].join('\n');
181
+ }
182
+ /** Remove this project's tagged block from a crontab; returns the text unchanged when there is none. */
183
+ function stripCronBlock(crontab, project) {
184
+ const lines = crontab.split('\n');
185
+ const out = [];
186
+ let skipping = false;
187
+ for (const line of lines) {
188
+ if (line === CRON_BEGIN(project)) {
189
+ skipping = true;
190
+ continue;
191
+ }
192
+ if (skipping) {
193
+ if (line === CRON_END(project))
194
+ skipping = false;
195
+ continue;
196
+ }
197
+ out.push(line);
198
+ }
199
+ return out.join('\n');
200
+ }
201
+ function hasCronBlock(crontab, project) {
202
+ return crontab.split('\n').includes(CRON_BEGIN(project));
203
+ }
204
+ /** New crontab text with exactly one up-to-date block for `spec.project` (idempotent). */
205
+ function upsertCronBlock(crontab, spec) {
206
+ const base = stripCronBlock(crontab, spec.project).replace(/\n+$/, '');
207
+ return `${base === '' ? '' : `${base}\n`}${renderCronBlock(spec)}\n`;
208
+ }
209
+ /**
210
+ * A supervisor pins absolute paths at install time, so an ephemeral one (a temp
211
+ * dir, a batch/issue worktree, a removed checkout) silently breaks it later.
212
+ * Returns hard errors (refuse to install) and soft warnings.
213
+ */
214
+ function validateServicePaths(spec, exists = fs.existsSync, tmp = os.tmpdir()) {
215
+ const errors = [];
216
+ const warnings = [];
217
+ const ephemeral = (p) => p === tmp || p.startsWith(`${tmp}${path.sep}`) || p.split(path.sep).includes('worktrees');
218
+ for (const [label, p] of [
219
+ ['node binary', spec.nodePath],
220
+ ['ai-dossier entry point', spec.cliPath],
221
+ ['repository', spec.repoDir],
222
+ ]) {
223
+ if (!path.isAbsolute(p))
224
+ errors.push(`${label} is not an absolute path: ${p}`);
225
+ else if (!exists(p))
226
+ errors.push(`${label} does not exist: ${p}`);
227
+ else if (ephemeral(p)) {
228
+ errors.push(`${label} is under a temporary or worktree directory (${p}) — it will not survive; install from the project's main checkout with a global ai-dossier`);
229
+ }
230
+ }
231
+ if (spec.nodePath.includes(`${path.sep}.nvm${path.sep}versions${path.sep}`)) {
232
+ warnings.push('node is an nvm version path: after `nvm install`/upgrading node, re-run `sched service install` to re-pin it.');
233
+ }
234
+ return { errors, warnings };
235
+ }
236
+ function defaultUnitDir(home = os.homedir()) {
237
+ return path.join(home, '.config', 'systemd', 'user');
238
+ }
239
+ /** Whether `systemctl --user` is usable here (a user manager is running). */
240
+ function systemdUserAvailable(run) {
241
+ return run('systemctl', ['--user', 'show-environment']) !== null;
242
+ }
243
+ function installService(spec, mode, io) {
244
+ const notes = [];
245
+ let ok = true;
246
+ const activated = !io.render;
247
+ if (mode === 'systemd') {
248
+ const unitPath = path.join(io.unitDir, unitName(spec.project));
249
+ const text = renderSystemdUnit(spec);
250
+ let previous = null;
251
+ try {
252
+ previous = fs.readFileSync(unitPath, 'utf8');
253
+ }
254
+ catch {
255
+ // not installed yet
256
+ }
257
+ const changed = previous !== text;
258
+ if (changed) {
259
+ fs.mkdirSync(io.unitDir, { recursive: true });
260
+ fs.writeFileSync(unitPath, text, { mode: 0o644 });
261
+ }
262
+ if (!io.render) {
263
+ const unit = unitName(spec.project);
264
+ if (changed && io.run('systemctl', ['--user', 'daemon-reload']) === null) {
265
+ ok = false;
266
+ notes.push('systemctl --user daemon-reload failed');
267
+ }
268
+ // enable --now is idempotent; it also (re)starts an inactive unit.
269
+ if (io.run('systemctl', ['--user', 'enable', '--now', unit]) === null) {
270
+ ok = false;
271
+ notes.push(`systemctl --user enable --now ${unit} failed`);
272
+ }
273
+ else if (changed && previous !== null) {
274
+ if (io.run('systemctl', ['--user', 'restart', unit]) === null) {
275
+ ok = false;
276
+ notes.push(`systemctl --user restart ${unit} failed`);
277
+ }
278
+ else {
279
+ notes.push('unit changed: restarted (agents keep running — KillMode=process)');
280
+ }
281
+ }
282
+ notes.push('Run `loginctl enable-linger $USER` once so the service starts at boot without a login session.');
283
+ }
284
+ return { mode, changed, ok, activated, unitPath, notes };
285
+ }
286
+ // cron: render-only never reads or writes the crontab.
287
+ if (io.render) {
288
+ return { mode, changed: false, ok: true, activated: false, notes };
289
+ }
290
+ let current;
291
+ try {
292
+ current = io.readCrontab();
293
+ }
294
+ catch (err) {
295
+ notes.push(`could not read the crontab — nothing was changed: ${err.message}`);
296
+ return { mode, changed: false, ok: false, activated: false, notes };
297
+ }
298
+ const next = upsertCronBlock(current, spec);
299
+ const changed = next !== current;
300
+ if (changed && !io.writeCrontab(next)) {
301
+ ok = false;
302
+ notes.push('could not write the crontab');
303
+ }
304
+ return { mode, changed, ok, activated, notes };
305
+ }
306
+ function uninstallService(project, io) {
307
+ const removed = [];
308
+ const errors = [];
309
+ const unitPath = path.join(io.unitDir, unitName(project));
310
+ if (fs.existsSync(unitPath)) {
311
+ if (!io.render &&
312
+ io.run('systemctl', ['--user', 'disable', '--now', unitName(project)]) === null) {
313
+ errors.push(`systemctl --user disable --now ${unitName(project)} failed`);
314
+ }
315
+ fs.rmSync(unitPath, { force: true });
316
+ if (!io.render && io.run('systemctl', ['--user', 'daemon-reload']) === null) {
317
+ errors.push('systemctl --user daemon-reload failed');
318
+ }
319
+ removed.push(unitPath);
320
+ }
321
+ if (!io.render) {
322
+ let crontab = null;
323
+ try {
324
+ crontab = io.readCrontab();
325
+ }
326
+ catch (err) {
327
+ errors.push(`could not read the crontab — its block (if any) was not removed: ${err.message}`);
328
+ }
329
+ if (crontab !== null && hasCronBlock(crontab, project)) {
330
+ if (io.writeCrontab(stripCronBlock(crontab, project)))
331
+ removed.push('crontab block');
332
+ else
333
+ errors.push('could not write the crontab');
334
+ }
335
+ }
336
+ return { removed, errors };
337
+ }
338
+ function serviceStatus(project, io) {
339
+ const unitPath = path.join(io.unitDir, unitName(project));
340
+ const installed = fs.existsSync(unitPath);
341
+ const enabled = installed && !io.render
342
+ ? io.run('systemctl', ['--user', 'is-enabled', unitName(project)])
343
+ : null;
344
+ const active = installed && !io.render
345
+ ? io.run('systemctl', ['--user', 'is-active', unitName(project)])
346
+ : null;
347
+ let cronInstalled = false;
348
+ let cronError;
349
+ if (!io.render) {
350
+ try {
351
+ cronInstalled = hasCronBlock(io.readCrontab(), project);
352
+ }
353
+ catch (err) {
354
+ cronError = err.message;
355
+ }
356
+ }
357
+ return {
358
+ project,
359
+ systemd: { installed, enabled, active, unitPath },
360
+ cron: { installed: cronInstalled, ...(cronError ? { error: cronError } : {}) },
361
+ supervised: (installed && (active === 'active' || io.render)) || cronInstalled,
362
+ };
363
+ }
364
+ //# sourceMappingURL=sched-service.js.map