software-defence-factory 0.4.2 → 0.4.4

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,189 @@
1
+ import {
2
+ existsSync, readFileSync, readdirSync, writeFileSync, renameSync, rmSync,
3
+ } from 'node:fs';
4
+ import { randomBytes } from 'node:crypto';
5
+ import { join } from 'node:path';
6
+ import { configAt, instanceLabel, json, PINS, ROOT, run, stream } from './lib.mjs';
7
+ import { acquireInstallationLock } from './installation-lock.mjs';
8
+ import { VERSION } from './updates.mjs';
9
+
10
+ const imageIdPattern = /^sha256:[a-f0-9]{64}$/;
11
+ const imageReferencePattern = /^[a-zA-Z0-9][a-zA-Z0-9_./:@-]*$/;
12
+ const missingImage = error => /no such (image|object)/i.test(error.message);
13
+ const alive = pid => {
14
+ try { process.kill(pid, 0); return true; }
15
+ catch (error) { if (error.code === 'ESRCH') return false; throw error; }
16
+ };
17
+
18
+ function docker(args, runner = run) { return runner('docker', args); }
19
+
20
+ export function inspectLocalImage(reference, runner = run) {
21
+ if (typeof reference !== 'string' || !imageReferencePattern.test(reference))
22
+ throw new Error('Invalid Docker image reference');
23
+ let id;
24
+ try { id = docker(['image', 'inspect', '--format', '{{.Id}}', reference], runner); }
25
+ catch (error) {
26
+ if (missingImage(error)) return null;
27
+ throw error;
28
+ }
29
+ if (!imageIdPattern.test(id)) throw new Error('Docker returned an unexpected image identity');
30
+ return id;
31
+ }
32
+
33
+ export function retainImage(image, runner = run) {
34
+ if (!imageIdPattern.test(image || '')) throw new Error('Expected an immutable Docker image ID');
35
+ // The shared build tag can move. Keep every selected image reachable by ID.
36
+ docker(['image', 'tag', image, `software-defence-factory-retained:${image.slice(7)}`], runner);
37
+ return image;
38
+ }
39
+
40
+ function retainIfPresent(reference, runner) {
41
+ const image = inspectLocalImage(reference, runner);
42
+ if (image) retainImage(image, runner);
43
+ return image;
44
+ }
45
+
46
+ export function assertImageChangeSafe(state, { runner = run, isAlive = alive } = {}) {
47
+ const supervisor = join(state, 'supervisor.json');
48
+ if (existsSync(supervisor)) {
49
+ let record;
50
+ try { record = json(supervisor); }
51
+ catch { throw new Error('Supervisor state is unreadable; reconcile the installation before changing its image'); }
52
+ if (!Number.isSafeInteger(record.pid) || record.pid < 1)
53
+ throw new Error('Supervisor state is unverified; reconcile the installation before changing its image');
54
+ if (isAlive(record.pid)) throw new Error('Stop the Factory before changing its job image');
55
+ }
56
+
57
+ const jobs = join(state, 'jobs');
58
+ if (existsSync(jobs)) {
59
+ for (const job of readdirSync(jobs)) {
60
+ if (existsSync(join(jobs, job, 'active.json')))
61
+ throw new Error(`Job ${job} has active or unreconciled execution; reconcile it before changing the image`);
62
+ }
63
+ }
64
+
65
+ const containers = docker(['ps', '-aq', '--filter', `label=sdf.factory=${instanceLabel(state)}`], runner)
66
+ .split('\n').filter(Boolean);
67
+ if (containers.length) throw new Error('Factory job containers remain; stop and reconcile them before changing the image');
68
+ }
69
+
70
+ function persistInstallation(state, config, metadata, filesystem = undefined) {
71
+ const fs = filesystem || { readFileSync, writeFileSync, renameSync, rmSync };
72
+ const configPath = join(state, 'factory.json'), enginePath = join(state, 'engine.json');
73
+ const previousConfig = fs.readFileSync(configPath);
74
+ const token = `${process.pid}.${randomBytes(8).toString('hex')}`;
75
+ const configTemp = `${configPath}.${token}.next`, engineTemp = `${enginePath}.${token}.next`;
76
+ let configReplaced = false;
77
+ try {
78
+ fs.writeFileSync(configTemp, `${JSON.stringify(config, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
79
+ fs.writeFileSync(engineTemp, `${JSON.stringify(metadata, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
80
+ fs.renameSync(configTemp, configPath);
81
+ configReplaced = true;
82
+ fs.renameSync(engineTemp, enginePath);
83
+ } catch (error) {
84
+ if (configReplaced) {
85
+ const rollback = `${configPath}.${token}.rollback`;
86
+ try {
87
+ fs.writeFileSync(rollback, previousConfig, { flag: 'wx', mode: 0o600 });
88
+ fs.renameSync(rollback, configPath);
89
+ } catch (rollbackError) {
90
+ throw new Error(`Image installation metadata could not be completed (${error.message}); restoring factory.json also failed (${rollbackError.message})`);
91
+ } finally { fs.rmSync(rollback, { force: true }); }
92
+ }
93
+ throw error;
94
+ } finally {
95
+ fs.rmSync(configTemp, { force: true });
96
+ fs.rmSync(engineTemp, { force: true });
97
+ }
98
+ }
99
+
100
+ function metadataFor(image, source, reference, version) {
101
+ return {
102
+ runtime: 'native-node',
103
+ version,
104
+ image,
105
+ imageSource: source,
106
+ imageReference: reference,
107
+ };
108
+ }
109
+
110
+ export function installCustomJobImage(state, reference, { runner = run, version = VERSION, isAlive = alive, filesystem } = {}) {
111
+ const release = acquireInstallationLock(state, 'select image');
112
+ try {
113
+ const config = configAt(state);
114
+ assertImageChangeSafe(state, { runner, isAlive });
115
+ const image = inspectLocalImage(reference, runner);
116
+ if (!image) throw new Error(`Docker image ${reference} is not present locally; build or pull it explicitly before selecting it`);
117
+ retainImage(image, runner);
118
+ const nextConfig = { ...config, image };
119
+ const metadata = metadataFor(image, 'custom', reference, version);
120
+ persistInstallation(state, nextConfig, metadata, filesystem);
121
+ return { image, reference, config: nextConfig, metadata };
122
+ } finally { release(); }
123
+ }
124
+
125
+ export async function installStandardJobImage(state, { runner = run, build = stream, version = VERSION, isAlive = alive, filesystem } = {}) {
126
+ const release = acquireInstallationLock(state, 'build image');
127
+ try {
128
+ const config = configAt(state);
129
+ assertImageChangeSafe(state, { runner, isAlive });
130
+ docker(['info', '--format', '{{.ServerVersion}}'], runner);
131
+ retainIfPresent(config.image, runner);
132
+ retainIfPresent(PINS.jobImage, runner);
133
+ await build('docker', ['build', '-t', PINS.jobImage, join(ROOT, 'factory/image')]);
134
+ const image = inspectLocalImage(PINS.jobImage, runner);
135
+ if (!image) throw new Error('Built job image is unavailable');
136
+ retainImage(image, runner);
137
+ const nextConfig = { ...config, image };
138
+ const metadata = metadataFor(image, 'standard', PINS.jobImage, version);
139
+ persistInstallation(state, nextConfig, metadata, filesystem);
140
+ return { image, config: nextConfig, metadata };
141
+ } finally { release(); }
142
+ }
143
+
144
+ function readMetadata(state) {
145
+ const path = join(state, 'engine.json');
146
+ if (!existsSync(path)) return null;
147
+ try { return json(path); } catch { return null; }
148
+ }
149
+
150
+ export function inspectImageInstallation(state, config = configAt(state), runner = run) {
151
+ const metadata = readMetadata(state);
152
+ let image = null;
153
+ image = inspectLocalImage(config.image, runner);
154
+
155
+ const metadataValid = Boolean(metadata
156
+ && metadata.runtime === 'native-node'
157
+ && typeof metadata.version === 'string'
158
+ && imageIdPattern.test(metadata.image || '')
159
+ && (!metadata.imageSource || ['standard', 'custom'].includes(metadata.imageSource))
160
+ && (!metadata.imageReference || imageReferencePattern.test(metadata.imageReference)));
161
+ const imageAvailable = image !== null;
162
+ const markerMatches = metadataValid && imageAvailable && metadata.image === image;
163
+ const installed = Boolean(markerMatches);
164
+ return {
165
+ installed,
166
+ image: {
167
+ reference: config.image,
168
+ id: image,
169
+ available: imageAvailable,
170
+ pinned: imageAvailable && config.image === image,
171
+ markerImage: metadataValid ? metadata.image : null,
172
+ source: metadataValid ? (metadata.imageSource || 'legacy') : null,
173
+ referenceAtInstall: metadataValid ? (metadata.imageReference || null) : null,
174
+ metadataValid,
175
+ markerMatches: Boolean(markerMatches),
176
+ error: imageAvailable ? null : `Selected image ${config.image} is not present locally`,
177
+ },
178
+ };
179
+ }
180
+
181
+ export function assertInstalledJobImage(state, config = configAt(state), runner = run) {
182
+ const status = inspectImageInstallation(state, config, runner);
183
+ if (!status.installed) {
184
+ if (!status.image.available) throw new Error(`Selected job image ${config.image} is not present locally; run install with an available image`);
185
+ if (!status.image.metadataValid) throw new Error('Runtime image metadata is missing or invalid; run install');
186
+ throw new Error('Selected job image does not match the installed image metadata; run install');
187
+ }
188
+ return status;
189
+ }
@@ -0,0 +1,15 @@
1
+ import { writeFileSync, rmSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ // Per installation: managed boot bypasses the CLI's global service-operation
5
+ // lock. Hold this fence until image metadata is complete or the supervisor PID
6
+ // is visible, before any queued work can snapshot an execution profile.
7
+ export function acquireInstallationLock(state, action) {
8
+ const path = join(state, 'installation.lock');
9
+ try { writeFileSync(path, JSON.stringify({ pid: process.pid, action }), { flag: 'wx', mode: 0o600 }); }
10
+ catch (error) {
11
+ if (error.code === 'EEXIST') throw new Error(`Another installation operation may own ${path}; reconcile its PID before removing the lock`);
12
+ throw error;
13
+ }
14
+ return () => rmSync(path);
15
+ }
@@ -1,11 +1,34 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, lstatSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
- import { ROOT, configAt, run, json, stopContainers, sleep } from './lib.mjs';
4
+ import { ROOT, configAt, run, json, save, stopContainers, sleep } from './lib.mjs';
5
+ import { withRequestedModel, executionProfile } from './execution-profile.mjs';
5
6
 
6
7
  const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { if (error.code === 'ESRCH') return false; throw error; } };
8
+ // Older queues did not retain the verdict. Only recover it from the exact
9
+ // attempt's controller-exported review, bound to its checked candidate/policy.
10
+ export function retainedReviewVerdict(state, job, attempt) {
11
+ try {
12
+ const folder = join(state, 'jobs', job.id);
13
+ const read = path => { const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) return null; return json(path); };
14
+ const review = read(join(folder, 'artifacts', attempt.id, 'review.json'));
15
+ const candidate = read(join(folder, 'candidate.json')), checks = read(join(folder, 'checks.json'));
16
+ if (review?.head && review.policyHash && review.head === candidate?.head && checks?.passed === true
17
+ && checks.head === review.head && checks.policyHash === review.policyHash) return review.verdict;
18
+ } catch { /* Missing or malformed legacy evidence cannot grant a revision action. */ }
19
+ }
7
20
  export function executors(state) {
8
21
  const children = new Map();
22
+ function prepare(job, attempt) {
23
+ const config = withRequestedModel(configAt(state), job.model);
24
+ // Resolve tags before admission so the recorded image is the one actually run.
25
+ config.image = run('docker', ['image', 'inspect', '--format', '{{.Id}}', config.image]);
26
+ if (!/^sha256:[a-f0-9]{64}$/.test(config.image)) throw new Error('Expected an immutable Docker image ID');
27
+ const profile = executionProfile(config, attempt.command), folder = join(state, 'jobs', job.id);
28
+ save(join(folder, attempt.id, 'execution-config.json'), config);
29
+ save(join(folder, 'artifacts', attempt.id, 'execution.json'), profile);
30
+ return profile;
31
+ }
9
32
  async function stop(jobId) {
10
33
  const running = children.get(jobId);
11
34
  if (running) {
@@ -34,11 +57,11 @@ export function executors(state) {
34
57
  renameSync(join(folder, 'checkout'), join(folder, `previous-checkout-${Date.now()}`));
35
58
  }
36
59
  async function execute(job, attempt) {
37
- const config = configAt(state), output = join(state, 'jobs', job.id, 'artifacts', attempt.id);
60
+ const config = json(join(state, 'jobs', job.id, attempt.id, 'execution-config.json')), output = join(state, 'jobs', job.id, 'artifacts', attempt.id);
38
61
  mkdirSync(output, { recursive: true, mode: 0o700 });
39
62
  const resultPath = join(output, 'result.json'), fd = openSync(join(output, 'executor.log'), 'a', 0o600);
40
63
  const child = spawn(process.execPath, [join(ROOT, 'factory/executor.mjs'), state, attempt.command], {
41
- detached: true, stdio: ['pipe', fd, fd], env: { ...process.env, SDF_JOB_ID: job.id, SDF_RUN_ID: attempt.id, SDF_MODEL: job.model || '', SDF_OUTPUT_DIR: output, SDF_STEP_RESULT_PATH: resultPath },
64
+ detached: true, stdio: ['pipe', fd, fd], env: { ...process.env, SDF_JOB_ID: job.id, SDF_RUN_ID: attempt.id, SDF_OUTPUT_DIR: output, SDF_STEP_RESULT_PATH: resultPath },
42
65
  }); closeSync(fd);
43
66
  let finish;
44
67
  const done = new Promise(resolve => { finish = resolve; });
@@ -53,8 +76,8 @@ export function executors(state) {
53
76
  if (exit.error) throw exit.error;
54
77
  if (!existsSync(resultPath)) throw new Error(`Executor exited ${exit.code} without a result`);
55
78
  const outcome = JSON.parse(readFileSync(resultPath, 'utf8'));
56
- if (exit.code !== 0 || outcome.outcome !== 'complete') return { outcome: 'blocked', summary: outcome.summary || `Executor exited ${exit.code}` };
79
+ if (exit.code !== 0 || outcome.outcome !== 'complete') return { outcome: 'blocked', summary: outcome.summary || `Executor exited ${exit.code}`, review_verdict: outcome.review_verdict };
57
80
  return outcome;
58
81
  }
59
- return { execute, stop, reconcile };
82
+ return { prepare, execute, stop, reconcile, reviewVerdict: (job, attempt) => retainedReviewVerdict(state, job, attempt) };
60
83
  }
package/factory/queue.mjs CHANGED
@@ -11,10 +11,10 @@ export class QueueError extends Error { constructor(message, status = 409) { sup
11
11
  // One controller owns this database and one executor at a time. Each transition
12
12
  // is committed before execution starts; a restart never assumes a result.
13
13
  export class JobQueue {
14
- constructor(state, { execute, stop, reconcile }) {
14
+ constructor(state, { execute, stop, reconcile, prepare, reviewVerdict = () => undefined }) {
15
15
  this.db = new DatabaseSync(join(state, 'jobs.sqlite'));
16
16
  this.db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY, data TEXT NOT NULL);');
17
- this.execute = execute; this.stop = stop; this.reconcile = reconcile;
17
+ this.execute = execute; this.stop = stop; this.reconcile = reconcile; this.prepare = prepare; this.reviewVerdict = reviewVerdict;
18
18
  this.maintenanceFile = join(state, 'maintenance.json');
19
19
  this.active = null; this.closing = false; this.pumping = false; this.actions = new Set(); this.maintenance = existsSync(this.maintenanceFile);
20
20
  for (const job of this.all()) if (['running', 'cancelling'].includes(job.state)) {
@@ -67,12 +67,16 @@ export class JobQueue {
67
67
  const started = Date.now(); Object.assign(attempt, { state: 'running', started_at: now() }); job.state = 'running'; this.save(job);
68
68
  this.active = { jobId: job.id, runId: attempt.id };
69
69
  let outcome;
70
- try { outcome = await this.execute(job, attempt); }
70
+ try {
71
+ if (this.prepare) { attempt.execution = this.prepare(job, attempt); this.save(job); }
72
+ outcome = await this.execute(job, attempt);
73
+ }
71
74
  catch (error) { outcome = { outcome: 'blocked', summary: error.message }; }
72
75
  job = this.get(job.id); attempt = job.runs.find(run => run.id === attempt.id);
73
76
  if (job.state === 'running') {
74
77
  const succeeded = outcome?.outcome === 'complete';
75
78
  Object.assign(attempt, { state: succeeded ? 'succeeded' : 'failed', outcome: succeeded ? 'complete' : 'blocked', completed_at: now(), duration_millis: Date.now() - started, summary: outcome?.summary || 'No result', exit_code: succeeded ? 0 : 1 });
79
+ if (phase === 'review' && ['pass', 'changes', 'blocked'].includes(outcome?.review_verdict)) attempt.review_verdict = outcome.review_verdict;
76
80
  if (!succeeded) { job.state = 'failed'; attempt.error = attempt.summary; }
77
81
  else if (step === job.workflow.steps.length - 1) job.state = 'succeeded';
78
82
  else {
@@ -94,6 +98,13 @@ export class JobQueue {
94
98
  try { return await perform(); } finally { this.actions.delete(jobId); }
95
99
  }
96
100
  action(jobId, action, input) { return this.exclusive(jobId, () => this.applyAction(jobId, action, input)); }
101
+ canRequestChanges(job) {
102
+ if (job.workflow?.name !== 'software') return false;
103
+ if (job.state === 'awaiting_approval') return true;
104
+ const attempt = job.runs.at(-1);
105
+ return job.state === 'failed' && attempt?.state === 'failed' && attempt.command === 'review'
106
+ && ['changes', 'blocked'].includes(attempt.review_verdict ?? this.reviewVerdict(job, attempt));
107
+ }
97
108
  async applyAction(jobId, action, input) {
98
109
  if (this.closing) throw new QueueError('Controller is stopping');
99
110
  const job = this.get(jobId), attempt = job.runs.at(-1);
@@ -102,11 +113,15 @@ export class JobQueue {
102
113
  if (job.state !== 'awaiting_approval') throw new QueueError('Job is not awaiting approval');
103
114
  job.state = 'queued'; attempt.state = 'queued'; this.save(job); this.schedule();
104
115
  } else if (action === 'request_changes') {
105
- if (job.state !== 'awaiting_approval' || job.workflow.name !== 'software') throw new QueueError('Only a reviewed software task can be revised');
116
+ if (!this.canRequestChanges(job)) throw new QueueError('Only a reviewed software task can be revised');
106
117
  if (typeof input.feedback !== 'string' || !input.feedback.trim() || input.feedback.length > 4000) throw new QueueError('Provide revision feedback under 4000 characters', 400);
118
+ const revisedPrompt = job.prompt + `\n\nRequested revision: ${input.feedback}`;
119
+ if (Buffer.byteLength(revisedPrompt) > 240000) throw new QueueError('Accumulated revision instructions exceed 240 KB; create a bounded continuation task', 400);
107
120
  await this.reconcile(jobId, 'build');
108
- Object.assign(attempt, { state: 'succeeded', outcome: 'changes_requested', summary: input.feedback, completed_at: now(), duration_millis: 0 });
109
- job.prompt += `\n\nRequested revision: ${input.feedback}`;
121
+ // A failed review stays failed. Revision feedback must not rewrite its result.
122
+ attempt.revision = { feedback: input.feedback, requested_at: now() };
123
+ if (job.state === 'awaiting_approval') Object.assign(attempt, { state: 'succeeded', outcome: 'changes_requested', summary: input.feedback, completed_at: now(), duration_millis: 0 });
124
+ job.prompt = revisedPrompt;
110
125
  job.workflow.current_step = 0; job.state = 'queued'; this.save(job); this.schedule();
111
126
  } else if (action === 'cancel') {
112
127
  if (!['queued', 'running', 'awaiting_approval', 'blocked', 'interrupted'].includes(job.state)) throw new QueueError('Job is already stopped');
@@ -7,6 +7,7 @@ import { JobQueue, QueueError } from './queue.mjs';
7
7
  import { executors } from './processes.mjs';
8
8
  import { configAt, ROOT } from './lib.mjs';
9
9
  import { VERSION } from './updates.mjs';
10
+ import { attemptPresentation } from './execution-profile.mjs';
10
11
 
11
12
  function equal(a, b) { return typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b)); }
12
13
  async function body(request) {
@@ -39,10 +40,8 @@ export function createController(state, adapter = executors(state)) {
39
40
  const url = new URL(request.url, `http://${request.headers.host}`);
40
41
  const authenticated = equal(request.headers.authorization, `Bearer ${token}`) || equal(request.headers['x-factory-session'], csrf);
41
42
  if (request.method === 'GET' && url.pathname === '/api/v1/status') {
42
- const jobs = queue.all().map(job => ({ ...job, runs: job.runs.map(attempt => ({ ...attempt,
43
- outcome: attempt.outcome || (attempt.state === 'succeeded' ? 'complete' : undefined),
44
- executor: ['verify','handoff'].includes(attempt.command) ? 'deterministic' : config.agent,
45
- worker_name: hostname(), model: job.model || config.model })) }));
43
+ const jobs = queue.all().map(job => ({ ...job, can_request_changes: queue.canRequestChanges(job), runs: job.runs.map(attempt => attemptPresentation({ ...attempt,
44
+ outcome: attempt.outcome || (attempt.state === 'succeeded' ? 'complete' : undefined) })) }));
46
45
  return send(200, { version: 1, runtime_version: VERSION, maintenance: queue.maintenance, workflows: ['software', 'defence'], commands: [], triggers: [], jobs, csrf_token: csrf,
47
46
  workers: [{ name: hostname(), instance_id: 'local-executor', repositories: ['app'], connected: !queue.closing, last_seen_at: new Date().toISOString() }],
48
47
  repositories: ['app'], repo: config.repo, agent: config.agent });
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
5
5
  import { createServer } from 'node:net';
6
6
  import { ROOT, STATE_HOME, DATA_HOME, SOURCE_CHECKOUT } from './paths.mjs';
7
7
  import { configAt, json, save, run, api, sleep, digest } from './lib.mjs';
8
+ import { assertInstalledJobImage } from './image-install.mjs';
8
9
  import { VERSION, newer, latestVersion, installRelease, busyInstallations } from './updates.mjs';
9
10
  import { serviceId, systemdUnit, launchAgent, tunnelArguments, groupArguments, runtimeLauncher } from './service-files.mjs';
10
11
 
@@ -127,9 +128,8 @@ async function install(kind, state, flags) {
127
128
  console.log(JSON.stringify(await serviceStatus(previous), null, 2)); return;
128
129
  }
129
130
  if (kind === 'controller') {
130
- if (!existsSync(join(state, 'engine.json'))) throw new Error('Run install for this state before installing its service');
131
+ assertInstalledJobImage(state, configAt(state));
131
132
  if (existsSync(join(state, 'supervisor.json')) && alive(json(join(state, 'supervisor.json')).pid)) throw new Error('Stop the manually started controller before adopting it as a service');
132
- run('docker', ['image', 'inspect', configAt(state).image]);
133
133
  }
134
134
  let argv = kind === 'controller' ? [process.execPath, launcher(), 'serve', '--state', state] : tunnelArguments(flags.host, port);
135
135
  if (flags.group) {
@@ -2,10 +2,18 @@ import { writeFileSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { configAt, stopContainers } from './lib.mjs';
5
+ import { acquireInstallationLock } from './installation-lock.mjs';
6
+ import { assertInstalledJobImage } from './image-install.mjs';
5
7
  import { createController } from './server.mjs';
6
8
  export async function supervise(state) {
7
- const config = configAt(state), lock = join(state, 'supervisor.json');
8
- writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
9
+ const lock = join(state, 'supervisor.json');
10
+ const release = acquireInstallationLock(state, 'start controller');
11
+ let config;
12
+ try {
13
+ config = configAt(state);
14
+ assertInstalledJobImage(state, config);
15
+ writeFileSync(lock, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
16
+ } finally { release(); }
9
17
  let controller, stopping = false;
10
18
  async function shutdown(code = 0) {
11
19
  if (stopping) return; stopping = true;