Files
msd-core/gsd-core/bin/lib/external-job.cjs
Tom Boucher 5ae4ea4c84 feat(#1105): add external-job capability (SLURM scheduler-adapter producer half) (#1998)
* feat(#1105): add external-job capability (SLURM scheduler-adapter producer half)

The async external-job consumer half (#1165) shipped long ago: the core loop
reads .planning/async-jobs/<job>.json manifests and treats a non-terminal one
as the legal external_job_waiting half-state. The PRODUCER half (#1164) was
the remaining unimplemented piece of #1105.

This adds the producer as a default-off capability:

- capabilities/external-job/ — capability.json (execute:wave:post -> executor,
  plan:post -> planner contributions, external_job.* config keys, default-off)
  + fragments teaching runtime-budget classification and externalization.
- src/external-job.cts -> gsd-core/bin/lib/external-job.cjs — pure producer
  module: SLURM state -> manifest-status map (no guessing), manifest
  build/validate (versioned stability contract), sbatch/squeue/sacct parsers,
  and a fail-closed manifest writer (refuses a second non-terminal job for a
  plan_id already in flight; refuses to clobber a malformed manifest). fs/clock
  seams for deterministic tests.
- scripts/slurm-adapter.cjs — operator CLI (submit/poll/show) wrapping bounded
  sbatch/squeue/sacct subprocesses; surfaces manifest commands for confirmation
  and never auto-runs them (trust boundary).
- tests/external-job.test.cjs — 23 behavioral + fast-check property tests.
- docs/reference/long-running-operations.md + docs/how-to/async-external-jobs.md.
- CONTEXT.md glossary entry for the External-job Capability.
- Regenerated capability-registry.cjs; pruned the now-stale test-file-count
  allowlist entry (external-job is at the 2-file cap).

* chore(#1105): backfill PR number in changeset

* fix(#1105): sync capability artifacts + update registry shape-pin tests

gsd-test caught that adding the external-job capability requires its
dependent artifacts regenerated and its registry-shape drift absorbed:

- sync-manifest-versions: stamp 1.7.0-rc.2 into capability.json (was 1.0.0).
- gen-capability-matrix --write: regenerate docs/reference/capability-matrix.md.
- gen-inventory-manifest --write: regenerate docs/INVENTORY-MANIFEST.json.
- check-gap-analysis-plan-post-e2e: plan:post now has 1 contribution
  (external-job planner fragment) instead of 0.
- execute-wave-post-gate-pipeline-e2e: execute:wave:post now has 2
  contributions (mempalace + external-job) instead of 1.

* fix(#1105): regenerate capability-registry after version stamp

sync-manifest-versions re-stamped external-job/capability.json from
1.0.0 to 1.7.0-rc.2 after the last registry regeneration, leaving the
committed capability-registry.cjs stale (CI gen-capability-registry
--check failed). gsd-test masked this because its setup runs the full
'npm run build' (which regenerates the registry); CI's 'npm test'
pretest only runs build:lib.
2026-07-03 19:37:38 -04:00

288 lines
11 KiB
JavaScript

"use strict";
/**
* external-job.cts — scheduler-adapter producer module for the async
* external-job contract (#1164 / #1105).
*
* The CORE loop CONSUMES manifests at `.planning/async-jobs/<job>.json`
* (#1165 — external_job_waiting half-state); this module is the Capability
* half that PRODUCES them. SLURM is the first backend; the design stays
* scheduler-pluggable via the `backend` field (planning-artifacts.md).
*
* Pure helpers (state map, build, validate, parsers) take no I/O; the writer
* takes injected `fs` and `clock` seams so tests drive it without touching disk
* or wall-clock time (CLAUDE.md clock-seam + injectable-deps conventions).
*
* Build: `src/*.cts` -> `gsd-core/bin/lib/*.cjs` (ADR-457 build-at-publish).
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const node_path_1 = __importDefault(require("node:path"));
const node_fs_1 = __importDefault(require("node:fs"));
// ─── Closed status enum (stability contract — Hyrum's Law) ────────────────────
const MANIFEST_VERSION = '1.0';
const MANIFEST_STATUS = [
'submitted',
'running',
'completed-unverified',
'failed',
'cancelled',
'timeout',
];
const NON_TERMINAL_STATUSES = ['submitted', 'running'];
const TERMINAL_FAILURE_STATUSES = ['failed', 'cancelled', 'timeout'];
// ─── SLURM state -> manifest status ───────────────────────────────────────────
//
// Source: SLURM job state codes (squeue/sacct State column). Producers for
// other backends map their own states onto the closed enum above; this table
// is SLURM-specific and lives behind the `backend: 'slurm'` field.
const SLURM_STATE_MAP = {
PENDING: 'submitted',
CONFIGURING: 'submitted',
RUNNING: 'running',
COMPLETING: 'running',
COMPLETED: 'completed-unverified',
FAILED: 'failed',
CANCELLED: 'cancelled',
TIMEOUT: 'timeout',
OUT_OF_MEMORY: 'failed',
BOOT_FAIL: 'failed',
NODE_FAIL: 'failed',
PREEMPTED: 'failed',
};
/**
* Map a raw SLURM state string to the closed, scheduler-agnostic manifest
* status. Case-insensitive; trims whitespace; strips a trailing by-part
* ("CANCELLED by 1001" -> "CANCELLED"). Returns `null` for any unknown
* state so the caller can decide whether to surface or fail — never guesses
* (CLAUDE.md anti-guessing).
*/
function mapSlurmState(raw) {
if (typeof raw !== 'string')
return null;
const key = raw.trim().toUpperCase();
const head = key.split(/\s+/)[0];
if (head && Object.prototype.hasOwnProperty.call(SLURM_STATE_MAP, head)) {
return SLURM_STATE_MAP[head];
}
return null;
}
const REQUIRED_FIELDS = [
'plan_id',
'phase',
'job_id',
'backend',
'submit_command',
'status',
'expected_artifacts',
'verification_command',
'resume_command',
];
function assertString(v, key) {
if (typeof v !== 'string' || v.length === 0) {
throw new Error(`buildManifest: field "${key}" must be a non-empty string`);
}
}
const STATUS_LIST = MANIFEST_STATUS;
/**
* Build a versioned, frozen manifest. Stamps `version` and `submitted_at`
* (via the injected clock seam) and normalises `terminal_details`:
* `null` unless the status is a terminal failure AND details were supplied.
* Throws on missing required fields or an out-of-enum status.
*/
function buildManifest(input, opts = {}) {
const inputRecord = input;
for (const key of REQUIRED_FIELDS) {
const v = inputRecord[key];
if (key === 'expected_artifacts') {
if (!Array.isArray(v) || v.length === 0 || !v.every((x) => typeof x === 'string')) {
throw new Error('buildManifest: field "expected_artifacts" must be a non-empty string[]');
}
continue;
}
assertString(v, key);
}
if (!STATUS_LIST.includes(input.status)) {
throw new Error(`buildManifest: field "status" must be one of ${MANIFEST_STATUS.join(', ')}`);
}
const clock = opts.clock ?? { nowIso: () => new Date().toISOString() };
const isTerminalFailure = TERMINAL_FAILURE_STATUSES.includes(input.status);
const terminal_details = isTerminalFailure && input.terminal_details ? input.terminal_details : null;
return Object.freeze({
version: MANIFEST_VERSION,
job_id: input.job_id,
plan_id: input.plan_id,
phase: input.phase,
backend: input.backend,
submit_command: input.submit_command,
status: input.status,
expected_artifacts: [...input.expected_artifacts],
verification_command: input.verification_command,
resume_command: input.resume_command,
submitted_at: clock.nowIso(),
terminal_details,
});
}
/**
* Producer-side schema validator — the mirror of the consumer trust boundary
* (planning-artifacts.md). Producers MUST emit a manifest this accepts; the
* core loop re-validates defensively on read.
*/
function validateManifest(value) {
if (!value || typeof value !== 'object') {
return { ok: false, errors: ['manifest must be an object'] };
}
const m = value;
const errors = [];
if (m.version !== MANIFEST_VERSION)
errors.push(`version must be "${MANIFEST_VERSION}"`);
for (const f of ['job_id', 'plan_id', 'phase', 'backend', 'submit_command', 'verification_command', 'resume_command', 'submitted_at']) {
if (typeof m[f] !== 'string' || m[f].length === 0) {
errors.push(`field "${f}" must be a non-empty string`);
}
}
if (typeof m.status !== 'string' || !STATUS_LIST.includes(m.status)) {
errors.push(`status must be one of ${MANIFEST_STATUS.join(', ')}`);
}
if (!Array.isArray(m.expected_artifacts) || !m.expected_artifacts.every((x) => typeof x === 'string')) {
errors.push('expected_artifacts must be a string[]');
}
if (m.terminal_details !== null && typeof m.terminal_details !== 'object') {
errors.push('terminal_details must be null or an object');
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}
/**
* Parse `sbatch --parsable` output. Accepts either a bare job id
* (`"12345"`) or the `"12345;clustername"` form. Rejects prose like
* `"Submitted batch job 12345"` (that is the non-parsable default format).
*/
function parseSbatchParsable(stdout) {
if (typeof stdout !== 'string')
return { ok: false, kind: 'non_string', raw: String(stdout) };
const trimmed = stdout.trim();
if (!trimmed)
return { ok: false, kind: 'empty', raw: stdout };
const head = trimmed.split(';')[0].split(/\s+/)[0];
if (!/^\d+$/.test(head))
return { ok: false, kind: 'not_numeric', raw: trimmed };
return { ok: true, job_id: head };
}
/**
* Parse a single `squeue` line of the form `"<jobid> <state>"`. Returns `null`
* for a header or any row that does not have at least two tokens.
*/
function parseSqueueLine(line) {
if (typeof line !== 'string')
return null;
const parts = line.trim().split(/\s+/);
if (parts.length < 2)
return null;
const [job_id, state] = parts;
if (!/^\d+$/.test(job_id))
return null;
return { job_id, state };
}
/**
* Parse a `sacct -P` row given as pre-split columns where index 0 is the job
* id and index 1 is the state. Returns `null` for malformed rows.
*/
function parseSacctRow(cols) {
if (cols.length < 2)
return null;
const job_id = cols[0];
const state = cols[1];
if (typeof job_id !== 'string' || typeof state !== 'string')
return null;
if (!/^\d+$/.test(job_id))
return null;
return { job_id, state };
}
/**
* Pure path projection: `.planning/async-jobs/<job_id>.json`.
*/
function manifestPath(planningDir, jobId) {
return node_path_1.default.join(planningDir, 'async-jobs', `${jobId}.json`);
}
function _isNonTerminal(status) {
return typeof status === 'string' && NON_TERMINAL_STATUSES.includes(status);
}
/**
* Write a manifest to `.planning/async-jobs/<job_id>.json`.
*
* Fail-closed rules (mirror of the consumer contract, planning-artifacts.md):
* - If any existing manifest in the dir shares `plan_id` but has a different
* `job_id` AND is non-terminal -> refuse (`duplicate_plan_id`); dispatching
* again would duplicate the external job.
* - If the target file exists but is not valid JSON -> refuse
* (`malformed_existing`); never silently clobber.
* - Same `job_id` for the same `plan_id` -> allowed (status progression).
* - A prior job for the same `plan_id` that is already terminal -> allowed
* (the duplicate guard only protects against re-dispatching live work).
*/
function writeManifest(manifest, planningDir, opts = {}) {
const fs = opts.fs ?? node_fs_1.default;
const dir = node_path_1.default.join(planningDir, 'async-jobs');
const target = manifestPath(planningDir, manifest.job_id);
let names;
try {
fs.mkdirSync(dir, { recursive: true });
names = fs.readdirSync(dir);
}
catch (e) {
return { ok: false, kind: 'io_error', message: e.message };
}
for (const name of names) {
if (!name.endsWith('.json'))
continue;
const p = node_path_1.default.join(dir, name);
let raw;
try {
raw = String(fs.readFileSync(p));
}
catch {
continue;
}
let existing;
try {
existing = JSON.parse(raw);
}
catch {
if (p === target) {
return { ok: false, kind: 'malformed_existing', message: `target manifest ${p} is not valid JSON` };
}
continue;
}
const samePlan = existing.plan_id === manifest.plan_id;
const sameJob = existing.job_id === manifest.job_id;
if (samePlan && !sameJob && _isNonTerminal(existing.status)) {
return {
ok: false,
kind: 'duplicate_plan_id',
message: `plan_id "${manifest.plan_id}" already has non-terminal job "${String(existing.job_id)}" at ${p}; dispatching again would duplicate the external job`,
};
}
}
try {
fs.writeFileSync(target, JSON.stringify(manifest, null, 2) + '\n');
}
catch (e) {
return { ok: false, kind: 'io_error', message: e.message };
}
return { ok: true, path: target };
}
module.exports = {
MANIFEST_VERSION,
MANIFEST_STATUS,
NON_TERMINAL_STATUSES,
TERMINAL_FAILURE_STATUSES,
mapSlurmState,
buildManifest,
validateManifest,
parseSbatchParsable,
parseSqueueLine,
parseSacctRow,
writeManifest,
manifestPath,
};