mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-01 18:31:55 +00:00
Add reversible system schedule launcher
This commit is contained in:
1 parent
1cf1f1b00f
commit
fec36fba29
9 files changed
+342
-9
No files matched your search
@@ -59,7 +59,9 @@ and the first Claude reference-fleet path:
|
||||
- identity-bound context-usage telemetry that separates measurements from
|
||||
estimates and relates cost/cache behavior to verified work;
|
||||
- validated system/session schedules with shell-free actions, authority,
|
||||
retries, durable run evidence, and explicit approval for mutation.
|
||||
retries, durable run evidence, and explicit approval for mutation;
|
||||
- an owner-approved user-systemd launcher for system schedules, bound to the
|
||||
exact tracked configuration digest and recorded for exact reversal.
|
||||
- durable Fleet evolution: the Session Manager can propose a justified new
|
||||
persistent domain, while owner approval gates the configuration commit,
|
||||
identity, prompt, branch, and worktree activation; it can also propose safe
|
||||
@@ -67,8 +69,8 @@ and the first Claude reference-fleet path:
|
||||
branch and refusing live, dirty, guarded, leased, or active work.
|
||||
|
||||
Live mixed-provider qualification, stable self-host bootstrapping, COMBATRIG
|
||||
cutover, persistent system-schedule launcher integration, and public deployment
|
||||
remain staged work.
|
||||
cutover, installation of the implemented system-schedule launcher on this
|
||||
machine, and public deployment remain staged work.
|
||||
|
||||
The canonical architecture and delivery plan is
|
||||
[docs/PORTABLE_AGENT_FLEET_SPEC.md](docs/PORTABLE_AGENT_FLEET_SPEC.md).
|
||||
|
||||
@@ -1133,8 +1133,13 @@ fields. The service plans without mutation, blocks session schedules while the
|
||||
Session Manager is offline, requires explicit approval for mutating actions,
|
||||
records durable run evidence, retries deterministically, and reports failures
|
||||
through durable blocker messages. CLI and MCP surfaces share the service.
|
||||
Cron definitions currently require an external launcher; persistent timer
|
||||
installation remains open and will use the normal ownership manifest.
|
||||
System schedules can now use an owner-approved user-systemd launcher. The
|
||||
launcher dispatches once per minute, evaluates the validated cron/interval
|
||||
definitions in TORCH, suppresses duplicate minute runs, and refuses to run
|
||||
after tracked configuration changes because its command is bound to the exact
|
||||
configuration digest. Unit files are recorded in the installation ownership
|
||||
manifest and removal refuses changed files. Installing that persistent
|
||||
launcher on a real machine remains an explicit owner action.
|
||||
|
||||
## 23. Repository and forge operating modes
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"./observability": "./src/observability/snapshot.mjs",
|
||||
"./resources": "./src/resources/service.mjs",
|
||||
"./schedules": "./src/schedules/service.mjs",
|
||||
"./schedules/launcher": "./src/schedules/launcher.mjs",
|
||||
"./runtime/lifecycle": "./src/runtime/lifecycle.mjs",
|
||||
"./self-host": "./src/self-host/service.mjs",
|
||||
"./telemetry/context": "./src/telemetry/context.mjs"
|
||||
|
||||
@@ -186,6 +186,10 @@ test_integrity_note:
|
||||
given: system and session schedules with different owners, behavior, retries, and authority
|
||||
when: schedules are planned and executed through a virtualized shell-free runner
|
||||
then: offline sessions and unauthorized actors are blocked, mutation needs approval, failures retry exactly and notify durably, and each run is recorded
|
||||
- id: SCN-system-schedule-launcher
|
||||
given: an installed Fleet with an owner-approved system schedule
|
||||
when: TORCH plans, installs, dispatches, detects drift, and removes its user-systemd launcher in a temporary config home
|
||||
then: units are bound to the exact tracked config digest, duplicate minute dispatch is suppressed, systemctl is virtualized, ownership is manifest-recorded, and reversal removes only unchanged TORCH units
|
||||
- id: SCN-cli-schedules
|
||||
given: an installed Fleet with an active Session Manager and a read-only session schedule
|
||||
when: public CLI commands list, plan, and run it
|
||||
|
||||
+35
@@ -25,6 +25,7 @@ import { CANDIDATE_ACCEPTANCE_SCENARIOS, createVersionService } from './self-hos
|
||||
import { observeProject } from './observability/snapshot.mjs';
|
||||
import { ContextTelemetryService } from './telemetry/context.mjs';
|
||||
import { ScheduleService } from './schedules/service.mjs';
|
||||
import { assertLauncherDigest, ScheduleLauncherService } from './schedules/launcher.mjs';
|
||||
import { createFleetDesignBrief } from './design/brief.mjs';
|
||||
import {
|
||||
createArchitectRunPlan, loadArchitectArtifact, runSessionArchitect, validateArchitectProposal,
|
||||
@@ -103,6 +104,7 @@ Usage:
|
||||
torch schedules runs [--id <schedule>] [--json]
|
||||
torch schedules plan --id <schedule> --actor <id|owner> [--json]
|
||||
torch schedules run --id <schedule> --actor <id|owner> [--yes] [--json]
|
||||
torch schedules launcher <plan|status|install|remove> [--yes] [--json]
|
||||
torch fleet changes [--state <state>] [--json]
|
||||
torch fleet get --change <id> [--json]
|
||||
torch fleet propose --from session-manager --proposal <path> [--json]
|
||||
@@ -747,11 +749,44 @@ export async function runCli(argv = process.argv.slice(2), {
|
||||
}
|
||||
if (command === 'schedules') {
|
||||
const operation = argv[1] ?? 'list';
|
||||
if (operation === 'launcher') {
|
||||
const launcherOperation = argv[2] ?? 'status';
|
||||
const launcher = new ScheduleLauncherService({
|
||||
repositoryRoot: repository.root, env,
|
||||
command: [process.execPath, new URL('../bin/torch.mjs', import.meta.url).pathname],
|
||||
});
|
||||
if (launcherOperation === 'plan') {
|
||||
const plan = launcher.plan();
|
||||
print(plan, { json });
|
||||
return plan.canProceed ? 0 : 1;
|
||||
}
|
||||
if (launcherOperation === 'status') {
|
||||
print(launcher.status(), { json });
|
||||
return 0;
|
||||
}
|
||||
if (!['install', 'remove'].includes(launcherOperation)) {
|
||||
throw new TorchError(`Unknown schedule launcher operation: ${launcherOperation}`, { code: 'UNKNOWN_COMMAND' });
|
||||
}
|
||||
if (!argv.includes('--yes')) {
|
||||
throw new TorchError('Persistent schedule launcher changes user systemd state. Review the plan, then re-run with --yes.', {
|
||||
code: 'APPROVAL_REQUIRED',
|
||||
});
|
||||
}
|
||||
print(launcherOperation === 'install' ? launcher.install() : launcher.remove(), { json });
|
||||
return 0;
|
||||
}
|
||||
const control = openControlPlane({ repositoryRoot: repository.root, env });
|
||||
try {
|
||||
const schedules = new ScheduleService({ repositoryRoot: repository.root, controlPlane: control });
|
||||
const scheduleId = optionValue(argv, '--id');
|
||||
const actorId = optionValue(argv, '--actor') ?? 'owner';
|
||||
if (operation === 'dispatch-system') {
|
||||
if (!argv.includes('--yes')) throw new TorchError('System schedule dispatch requires installed-launcher approval.', { code: 'APPROVAL_REQUIRED' });
|
||||
assertLauncherDigest(repository.root, optionValue(argv, '--launcher-digest'));
|
||||
const dispatched = schedules.dispatchSystem({ actorId: 'owner', approved: true });
|
||||
print(dispatched, { json });
|
||||
return dispatched.runs.every((run) => run.result === 'succeeded') ? 0 : 1;
|
||||
}
|
||||
if (operation === 'list') print({ schedules: schedules.list({ actorId }) }, { json });
|
||||
else if (operation === 'runs') print({ runs: schedules.runs({ scheduleId }) }, { json });
|
||||
else if (operation === 'plan') print(schedules.plan({ scheduleId, actorId }), { json });
|
||||
|
||||
@@ -287,6 +287,9 @@ export function planUninstall({ repository, purge = false }) {
|
||||
const state = inspectManagedWorktree(repository.root, entry, mainBranch);
|
||||
if (!state.safeToRemove) problems.push({ type: 'unsafe-worktree', path: entry.path, area: entry.area, problems: state.problems });
|
||||
}
|
||||
if (entry.type === 'systemd-user-unit') {
|
||||
problems.push({ type: 'persistent-integration-installed', path: entry.path, name: entry.name });
|
||||
}
|
||||
}
|
||||
return {
|
||||
action: purge ? 'purge' : 'detach', manifest, problems,
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
import { readInstallManifest, writeInstallManifest } from '../kernel/install.mjs';
|
||||
import { loadProjectConfig } from '../kernel/config.mjs';
|
||||
|
||||
function sha256(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function unitQuote(value) {
|
||||
return `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
function configPath(repositoryRoot) {
|
||||
return join(repositoryRoot, '.torch', 'torch.yaml');
|
||||
}
|
||||
|
||||
export function scheduleConfigDigest(repositoryRoot) {
|
||||
return sha256(readFileSync(configPath(repositoryRoot)));
|
||||
}
|
||||
|
||||
function unitDirectory(env) {
|
||||
return join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'systemd', 'user');
|
||||
}
|
||||
|
||||
function render({ repositoryRoot, projectId, command, digest, env }) {
|
||||
const base = `torch-${projectId}-schedules`;
|
||||
const serviceName = `${base}.service`;
|
||||
const timerName = `${base}.timer`;
|
||||
const directory = unitDirectory(env);
|
||||
const args = [
|
||||
...command, 'schedules', 'dispatch-system', '--launcher-digest', digest, '--yes', '--json',
|
||||
];
|
||||
const environment = env.XDG_DATA_HOME
|
||||
? `Environment=${unitQuote(`XDG_DATA_HOME=${resolve(env.XDG_DATA_HOME)}`)}\n` : '';
|
||||
const service = [
|
||||
'[Unit]', `Description=TORCH system schedules for ${projectId}`, '', '[Service]', 'Type=oneshot',
|
||||
`WorkingDirectory=${unitQuote(repositoryRoot)}`, environment.trimEnd(),
|
||||
`ExecStart=${args.map(unitQuote).join(' ')}`, '',
|
||||
].filter((line) => line !== '').join('\n') + '\n';
|
||||
const timer = [
|
||||
'[Unit]', `Description=Dispatch TORCH system schedules for ${projectId}`, '', '[Timer]',
|
||||
'OnCalendar=*-*-* *:*:00', 'Persistent=true', `Unit=${serviceName}`, '', '[Install]',
|
||||
'WantedBy=timers.target', '',
|
||||
].join('\n');
|
||||
return {
|
||||
serviceName, timerName, directory,
|
||||
files: [
|
||||
{ name: serviceName, path: join(directory, serviceName), content: service },
|
||||
{ name: timerName, path: join(directory, timerName), content: timer },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function runSystemctl(executor, args) {
|
||||
const result = executor('systemctl', ['--user', ...args], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], shell: false,
|
||||
});
|
||||
if (result.status !== 0 || result.error) {
|
||||
throw new TorchError(`systemctl --user ${args.join(' ')} failed`, {
|
||||
code: 'SCHEDULE_LAUNCHER_SYSTEMD_FAILED', details: result.stderr?.toString().trim() || result.error?.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ScheduleLauncherService {
|
||||
constructor({
|
||||
repositoryRoot, env = process.env, command,
|
||||
executor = (file, args, options) => spawnSync(file, args, options),
|
||||
} = {}) {
|
||||
this.repositoryRoot = resolve(repositoryRoot);
|
||||
this.env = env;
|
||||
this.command = command ?? [process.execPath, new URL('../../bin/torch.mjs', import.meta.url).pathname];
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
plan() {
|
||||
const config = loadProjectConfig(this.repositoryRoot);
|
||||
const manifest = readInstallManifest(this.repositoryRoot);
|
||||
const systemSchedules = config.schedules.filter((schedule) => schedule.lifetime === 'system');
|
||||
const digest = scheduleConfigDigest(this.repositoryRoot);
|
||||
const rendered = render({
|
||||
repositoryRoot: this.repositoryRoot, projectId: manifest.projectId,
|
||||
command: this.command, digest, env: this.env,
|
||||
});
|
||||
const blockers = [];
|
||||
if (!systemSchedules.length) blockers.push({ code: 'NO_SYSTEM_SCHEDULES' });
|
||||
for (const file of rendered.files) if (existsSync(file.path)) blockers.push({ code: 'LAUNCHER_PATH_EXISTS', path: file.path });
|
||||
return {
|
||||
action: 'install-system-schedule-launcher', projectId: manifest.projectId,
|
||||
systemSchedules: systemSchedules.map((schedule) => schedule.id), digest,
|
||||
...rendered, blockers, canProceed: blockers.length === 0, mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
install() {
|
||||
const plan = this.plan();
|
||||
if (!plan.canProceed) throw new TorchError('System schedule launcher installation is blocked', {
|
||||
code: 'SCHEDULE_LAUNCHER_BLOCKED', details: plan.blockers,
|
||||
});
|
||||
const manifest = readInstallManifest(this.repositoryRoot);
|
||||
const originalManifest = structuredClone(manifest);
|
||||
const created = [];
|
||||
try {
|
||||
mkdirSync(plan.directory, { recursive: true });
|
||||
for (const file of plan.files) {
|
||||
const temporary = `${file.path}.tmp-${process.pid}`;
|
||||
writeFileSync(temporary, file.content, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
||||
renameSync(temporary, file.path);
|
||||
created.push(file.path);
|
||||
manifest.external.push({
|
||||
type: 'systemd-user-unit', name: file.name, path: file.path,
|
||||
sha256: sha256(file.content), configDigest: plan.digest,
|
||||
});
|
||||
}
|
||||
writeInstallManifest(this.repositoryRoot, manifest);
|
||||
runSystemctl(this.executor, ['daemon-reload']);
|
||||
runSystemctl(this.executor, ['enable', '--now', plan.timerName]);
|
||||
return { ...plan, mutationPerformed: true, requiresCommit: ['.torch/install-manifest.json'] };
|
||||
} catch (error) {
|
||||
try { runSystemctl(this.executor, ['disable', '--now', plan.timerName]); } catch { /* best-effort rollback */ }
|
||||
for (const path of created.reverse()) if (existsSync(path)) rmSync(path);
|
||||
writeInstallManifest(this.repositoryRoot, originalManifest);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
status() {
|
||||
const manifest = readInstallManifest(this.repositoryRoot);
|
||||
const units = (manifest.external ?? []).filter((entry) => entry.type === 'systemd-user-unit');
|
||||
return {
|
||||
installed: units.length > 0,
|
||||
units: units.map((entry) => ({ ...entry, exists: existsSync(entry.path) })),
|
||||
currentConfigDigest: scheduleConfigDigest(this.repositoryRoot),
|
||||
stale: units.some((entry) => entry.configDigest !== scheduleConfigDigest(this.repositoryRoot)),
|
||||
mutationPerformed: false,
|
||||
};
|
||||
}
|
||||
|
||||
remove() {
|
||||
const manifest = readInstallManifest(this.repositoryRoot);
|
||||
const units = (manifest.external ?? []).filter((entry) => entry.type === 'systemd-user-unit');
|
||||
if (!units.length) return { removed: [], changed: false, mutationPerformed: false };
|
||||
const modified = units.filter((entry) => !existsSync(entry.path) || sha256(readFileSync(entry.path)) !== entry.sha256);
|
||||
if (modified.length) throw new TorchError('Refusing to remove changed or missing system schedule launcher files', {
|
||||
code: 'SCHEDULE_LAUNCHER_MODIFIED', details: modified.map((entry) => entry.path),
|
||||
});
|
||||
const timer = units.find((entry) => entry.name.endsWith('.timer'));
|
||||
if (timer) runSystemctl(this.executor, ['disable', '--now', timer.name]);
|
||||
for (const entry of units) rmSync(entry.path);
|
||||
manifest.external = manifest.external.filter((entry) => entry.type !== 'systemd-user-unit');
|
||||
writeInstallManifest(this.repositoryRoot, manifest);
|
||||
runSystemctl(this.executor, ['daemon-reload']);
|
||||
return {
|
||||
removed: units.map((entry) => entry.path), changed: true, mutationPerformed: true,
|
||||
requiresCommit: ['.torch/install-manifest.json'],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function assertLauncherDigest(repositoryRoot, expected) {
|
||||
const actual = scheduleConfigDigest(repositoryRoot);
|
||||
if (!expected || expected !== actual) throw new TorchError('System schedule launcher is stale for the tracked configuration', {
|
||||
code: 'SCHEDULE_LAUNCHER_STALE', details: { expected: expected ?? null, actual },
|
||||
});
|
||||
return actual;
|
||||
}
|
||||
@@ -3,6 +3,39 @@ import { spawnSync } from 'node:child_process';
|
||||
import { loadFleetDefinition } from '../kernel/worktrees.mjs';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
|
||||
function cronPartMatches(part, value, { sunday = false } = {}) {
|
||||
return part.split(',').some((segment) => {
|
||||
const [rangeText, stepText] = segment.split('/');
|
||||
const step = stepText === undefined ? 1 : Number(stepText);
|
||||
if (!Number.isInteger(step) || step < 1) return false;
|
||||
const normalize = (number) => (sunday && number === 7 ? 0 : number);
|
||||
if (rangeText === '*') return value % step === 0;
|
||||
const [startText, endText] = rangeText.split('-');
|
||||
const start = normalize(Number(startText));
|
||||
const end = endText === undefined ? start : normalize(Number(endText));
|
||||
if (!Number.isInteger(start) || !Number.isInteger(end) || value < start || value > end) return false;
|
||||
return (value - start) % step === 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function cronMatches(expression, at) {
|
||||
const parts = expression.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return false;
|
||||
const [minute, hour, day, month, weekday] = parts;
|
||||
if (!cronPartMatches(minute, at.getMinutes()) || !cronPartMatches(hour, at.getHours())
|
||||
|| !cronPartMatches(month, at.getMonth() + 1)) return false;
|
||||
const dayMatches = cronPartMatches(day, at.getDate());
|
||||
const weekdayMatches = cronPartMatches(weekday, at.getDay(), { sunday: true });
|
||||
if (day === '*' && weekday === '*') return true;
|
||||
if (day === '*') return weekdayMatches;
|
||||
if (weekday === '*') return dayMatches;
|
||||
return dayMatches || weekdayMatches;
|
||||
}
|
||||
|
||||
function sameMinute(left, right) {
|
||||
return left && right && left.slice(0, 16) === right.slice(0, 16);
|
||||
}
|
||||
|
||||
function text(value, field) {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new TorchError(`Schedule ${field} is required`, { code: 'SCHEDULE_CONFIG_INVALID', details: { field } });
|
||||
@@ -125,7 +158,10 @@ export class ScheduleService {
|
||||
nextAt = new Date(Date.parse(lastRun.startedAt) + definition.trigger.seconds * 1000).toISOString();
|
||||
due = at.getTime() >= Date.parse(nextAt);
|
||||
}
|
||||
if (definition.trigger.type === 'cron') due = 'external-launcher';
|
||||
if (definition.trigger.type === 'cron') {
|
||||
due = cronMatches(definition.trigger.expression, at)
|
||||
&& !sameMinute(lastRun?.startedAt, at.toISOString());
|
||||
}
|
||||
return {
|
||||
schedule: definition, actorId, due, nextAt, blockers,
|
||||
canRun: blockers.length === 0, mutationPerformed: false,
|
||||
@@ -175,4 +211,17 @@ export class ScheduleService {
|
||||
list({ actorId } = {}) {
|
||||
return [...this.definitions.values()].map((definition) => this.plan({ scheduleId: definition.id, actorId }));
|
||||
}
|
||||
|
||||
dispatchSystem({ actorId = 'owner', approved = false, at = this.clock() } = {}) {
|
||||
const considered = [...this.definitions.values()].filter((definition) => definition.lifetime === 'system');
|
||||
const due = considered.filter((definition) => {
|
||||
if (definition.trigger.type === 'manual') return false;
|
||||
return this.plan({ scheduleId: definition.id, actorId, at }).due === true;
|
||||
});
|
||||
const runs = due.map((definition) => this.run({ scheduleId: definition.id, actorId, approved }));
|
||||
return {
|
||||
at: at.toISOString(), considered: considered.map((definition) => definition.id),
|
||||
due: due.map((definition) => definition.id), runs, mutationPerformed: runs.length > 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -8,8 +8,9 @@ import { openControlPlane } from '../../src/control-plane/service.mjs';
|
||||
import { analyzeRepository } from '../../src/kernel/analyze.mjs';
|
||||
import { proposeDomains } from '../../src/kernel/domains.mjs';
|
||||
import { inspectRepository } from '../../src/kernel/git.mjs';
|
||||
import { installProject } from '../../src/kernel/install.mjs';
|
||||
import { installProject, planUninstall } from '../../src/kernel/install.mjs';
|
||||
import { ScheduleService } from '../../src/schedules/service.mjs';
|
||||
import { assertLauncherDigest, ScheduleLauncherService } from '../../src/schedules/launcher.mjs';
|
||||
|
||||
const CLI = new URL('../../bin/torch.mjs', import.meta.url).pathname;
|
||||
|
||||
@@ -50,7 +51,11 @@ function fixture() {
|
||||
proposal.review = {
|
||||
status: 'approved', reviewedAt: '2026-09-27T00:00:00Z', reviewedBy: 'fixture-owner', notes: [],
|
||||
};
|
||||
const env = { ...process.env, XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-state-')) };
|
||||
const env = {
|
||||
...process.env,
|
||||
XDG_DATA_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-state-')),
|
||||
XDG_CONFIG_HOME: mkdtempSync(join(tmpdir(), 'torch-schedules-config-')),
|
||||
};
|
||||
installProject({ repository, proposal, env, projectId: 'schedule-fixture' });
|
||||
return { root, env, worker };
|
||||
}
|
||||
@@ -103,3 +108,61 @@ test('SCN-cli-schedules: public commands list, plan, and run a read-only session
|
||||
assert.equal(executed.status, 0, executed.stderr || executed.stdout);
|
||||
assert.equal(JSON.parse(executed.stdout).stdout, 'healthy');
|
||||
});
|
||||
|
||||
test('SCN-system-schedule-launcher: exact-config user units install, dispatch, and reverse safely', () => {
|
||||
const context = fixture();
|
||||
const systemctl = [];
|
||||
const launcher = new ScheduleLauncherService({
|
||||
repositoryRoot: context.root, env: context.env,
|
||||
command: ['/opt/torch/node', '/opt/torch/bin/torch.mjs'],
|
||||
executor: (command, args, options) => {
|
||||
systemctl.push({ command, args, options });
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
});
|
||||
const plan = launcher.plan();
|
||||
assert.equal(plan.canProceed, true);
|
||||
assert.deepEqual(plan.systemSchedules, ['release']);
|
||||
assert.equal(plan.files.every((file) => !existsSync(file.path)), true);
|
||||
|
||||
const installed = launcher.install();
|
||||
assert.equal(installed.mutationPerformed, true);
|
||||
assert.equal(installed.files.every((file) => existsSync(file.path)), true);
|
||||
assert.match(readFileSync(installed.files[0].path, 'utf8'), new RegExp(installed.digest));
|
||||
assert.deepEqual(systemctl.slice(0, 2).map((call) => call.args.slice(0, 2)), [
|
||||
['--user', 'daemon-reload'], ['--user', 'enable'],
|
||||
]);
|
||||
assert.equal(launcher.status().stale, false);
|
||||
assert.equal(planUninstall({ repository: inspectRepository(context.root), purge: true }).problems
|
||||
.some((problem) => problem.type === 'persistent-integration-installed'), true);
|
||||
|
||||
const originalConfig = readFileSync(join(context.root, '.torch', 'torch.yaml'), 'utf8');
|
||||
writeFileSync(join(context.root, '.torch', 'torch.yaml'), `${originalConfig.trimEnd()} \n`);
|
||||
assert.throws(
|
||||
() => assertLauncherDigest(context.root, installed.digest),
|
||||
(error) => error.code === 'SCHEDULE_LAUNCHER_STALE',
|
||||
);
|
||||
writeFileSync(join(context.root, '.torch', 'torch.yaml'), originalConfig);
|
||||
|
||||
const removed = launcher.remove();
|
||||
assert.equal(removed.mutationPerformed, true);
|
||||
assert.equal(installed.files.every((file) => !existsSync(file.path)), true);
|
||||
assert.equal(launcher.status().installed, false);
|
||||
|
||||
const control = openControlPlane({ repositoryRoot: context.root, env: context.env });
|
||||
let at = new Date(2026, 8, 27, 16, 0, 0);
|
||||
const executed = [];
|
||||
const schedules = new ScheduleService({
|
||||
repositoryRoot: context.root, controlPlane: control, clock: () => at,
|
||||
executor: (command, args) => {
|
||||
executed.push({ command, args });
|
||||
return { status: 0, stdout: 'released', stderr: '' };
|
||||
},
|
||||
});
|
||||
const first = schedules.dispatchSystem({ actorId: 'owner', approved: true, at });
|
||||
assert.deepEqual(first.due, ['release']);
|
||||
assert.equal(executed.length, 1);
|
||||
at = new Date(2026, 8, 27, 16, 0, 30);
|
||||
assert.deepEqual(schedules.dispatchSystem({ actorId: 'owner', approved: true, at }).due, []);
|
||||
control.close();
|
||||
});
|
||||
Reference in new issue
Block a user