feat(task): mission/task schemas, validation, and run runner (#6) (#7)

- scripts/mosaic-task.mjs: validate | run | list
- Strict v1 schemas: unknown keys rejected; ids/prompt/expectExact/
  timeoutSeconds bounds enforced; optional mission file resolved against
  the task file and validated too
- run: executes through the config-driven container path with stdin
  detached (issue #5 class), SIGKILL timeout (default 120s), trimmed
  response capture
- Immutable run records under <dataRoot>/runs/r-<utcstamp>-<rand>/:
  task.json + mission.json snapshots (write-once), stderr.txt, result.json
- expectExact gate: mismatch -> status failed, exit 1; result.json is
  always written
- scripts/run-task.sh: load_config + bootstrap_runtime_dir before exec
- M2 scope: mission directives are snapshotted for provenance, not yet
  injected into the runtime prompt (later policy layer)

Closes #6, closes #7
This commit is contained in:
2026-09-02 19:56:29 -05:00
parent 0734b1f3a5
commit d2a9e26395
2 changed files with 334 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env node
/**
* Mosaic mission/task layer (M2, host-side only).
*
* Operations:
* validate <file> Strictly validate a task (and its referenced mission);
* print the resolved task on success. Writes nothing.
* run <taskFile> Execute the task through the config-driven container
* path; record an immutable run under <dataRoot>/runs/.
* list List runs recorded under <dataRoot>/runs/.
*
* Exit codes:
* 0 success (run: status "succeeded")
* 1 run failed (mismatch, nonzero exit, timeout) — result.json still written
* 2 invalid task/mission data
* 3 configuration problem
* 4 file/environment problem
*
* M2 scope: mission directives are validated and snapshotted into run
* records for provenance; they are NOT yet injected into the runtime
* system prompt (capability/policy layer comes later).
*
* Invariants carried over from the configuration layer:
* - Strict schemas; unknown keys rejected; versions pinned at 1.
* - Validation never writes.
* - Run records are write-once and never rewritten by a later run.
* - No secrets in mission/task data or run records.
*/
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const RUNS_DIRNAME = "runs";
const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const DEFAULT_TIMEOUT_SECONDS = 120;
function fail(exitCode, message) {
process.stderr.write(`mosaic-task: ${message}\n`);
process.exit(exitCode);
}
function isPlainObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function rejectUnknownKeys(object, allowed, where) {
for (const key of Object.keys(object)) {
if (!allowed.includes(key)) fail(2, `unsupported ${where} key: "${key}"`);
}
}
function readJsonFile(file, what) {
let stat;
try {
stat = fs.lstatSync(file);
} catch {
fail(4, `${what} not found: ${file}`);
}
if (!stat.isFile() || stat.isSymbolicLink()) {
fail(4, `${what} must be a regular, non-symbolic-link file: ${file}`);
}
let document;
try {
document = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
fail(2, `${what} is not valid JSON (${file}): ${error.message}`);
}
return document;
}
function validateId(value, what) {
if (typeof value !== "string" || !ID_PATTERN.test(value)) {
fail(2, `${what} must match ${ID_PATTERN} (got ${JSON.stringify(value)})`);
}
return value;
}
function validateMission(document, file) {
if (!isPlainObject(document)) fail(2, "mission must be a JSON object");
rejectUnknownKeys(document, ["missionVersion", "id", "objective", "directives"], "mission");
if (document.missionVersion !== 1) {
fail(2, `unsupported missionVersion: ${JSON.stringify(document.missionVersion)} (supported: 1)`);
}
validateId(document.id, "mission id");
if (typeof document.objective !== "string" || document.objective.trim().length === 0 || document.objective.length > 4000) {
fail(2, "mission objective must be a non-empty string of at most 4000 characters");
}
let directives = [];
if (document.directives !== undefined) {
if (!Array.isArray(document.directives)) fail(2, "mission directives must be an array of strings");
directives = document.directives.map((d, i) => {
if (typeof d !== "string" || d.trim().length === 0 || d.length > 2000 || d.includes("\0")) {
fail(2, `mission directive ${i} must be a non-empty string of at most 2000 characters`);
}
return d;
});
}
return { missionVersion: document.missionVersion, id: document.id, objective: document.objective, directives };
}
function validateTask(document, file) {
if (!isPlainObject(document)) fail(2, "task must be a JSON object");
rejectUnknownKeys(document, ["taskVersion", "id", "prompt", "mission", "expectExact", "timeoutSeconds"], "task");
if (document.taskVersion !== 1) {
fail(2, `unsupported taskVersion: ${JSON.stringify(document.taskVersion)} (supported: 1)`);
}
validateId(document.id, "task id");
if (typeof document.prompt !== "string" || document.prompt.length === 0 || document.prompt.length > 20000 || document.prompt.includes("\0")) {
fail(2, "task prompt must be a non-empty string of at most 20000 characters");
}
let mission = null;
let missionId = null;
let missionSnapshot = null;
if (document.mission !== undefined && document.mission !== null) {
if (typeof document.mission !== "string" || document.mission.length === 0) {
fail(2, 'task "mission" must be a path string when present');
}
const missionPath = path.resolve(path.dirname(path.resolve(file)), document.mission);
missionSnapshot = validateMission(readJsonFile(missionPath, "mission file"), missionPath);
mission = document.mission;
missionId = missionSnapshot.id;
}
let expectExact = null;
if (document.expectExact !== undefined && document.expectExact !== null) {
if (typeof document.expectExact !== "string" || document.expectExact.length === 0 || document.expectExact.length > 500 || /[^\P{C}\t\n]/u.test(document.expectExact)) {
fail(2, "task expectExact must be a non-empty string of at most 500 characters without control characters (tab/newline allowed)");
}
expectExact = document.expectExact;
}
let timeoutSeconds = DEFAULT_TIMEOUT_SECONDS;
if (document.timeoutSeconds !== undefined && document.timeoutSeconds !== null) {
if (!Number.isInteger(document.timeoutSeconds) || document.timeoutSeconds < 5 || document.timeoutSeconds > 600) {
fail(2, "task timeoutSeconds must be an integer between 5 and 600");
}
timeoutSeconds = document.timeoutSeconds;
}
return {
taskVersion: document.taskVersion,
id: document.id,
prompt: document.prompt,
mission,
missionId,
missionSnapshot,
expectExact,
timeoutSeconds,
};
}
function loadConfig() {
const proc = spawnSync(process.execPath, [path.join(PROJECT_ROOT, "scripts", "mosaic-config.mjs"), "validate"], {
cwd: PROJECT_ROOT,
encoding: "utf8",
maxBuffer: 1024 * 1024,
});
if (proc.status !== 0) {
fail(3, `configuration problem (exit ${proc.status}); run scripts/bootstrap.sh or fix config.json`);
}
return JSON.parse(proc.stdout);
}
function runsRoot(resolvedConfig) {
return path.join(resolvedConfig.dataRoot, RUNS_DIRNAME);
}
function utcStamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
}
function writeOnce(file, content) {
const fd = fs.openSync(file, "wx", 0o644); // exclusive: never overwrite
try {
fs.writeFileSync(fd, content);
} finally {
fs.closeSync(fd);
}
}
function runTask(taskFile) {
const resolved = JSON.parse(
spawnSync(process.execPath, [path.join(PROJECT_ROOT, "scripts", "mosaic-config.mjs"), "validate"], {
cwd: PROJECT_ROOT,
encoding: "utf8",
maxBuffer: 1024 * 1024,
}).stdout,
);
const task = validateTask(readJsonFile(taskFile, "task file"), path.resolve(taskFile));
const runId = `r-${utcStamp()}-${randomBytes(3).toString("hex")}`;
const runDir = path.join(runsRoot(resolved), runId);
fs.mkdirSync(runDir, { recursive: true });
// Immutable input snapshots (evidence of exactly what was executed).
const rawTask = fs.readFileSync(path.resolve(taskFile), "utf8");
writeOnce(path.join(runDir, "task.json"), rawTask);
if (task.missionSnapshot) {
const missionPath = path.resolve(path.dirname(path.resolve(taskFile)), task.mission);
writeOnce(path.join(runDir, "mission.json"), fs.readFileSync(missionPath, "utf8"));
}
const startedAt = new Date();
const stderrFile = path.join(runDir, "stderr.txt");
const proc = spawnSync(
"docker",
["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt],
{
cwd: PROJECT_ROOT,
env: process.env, // MOSAIC_DATA_ROOT / MOSAIC_PROVIDER / MOSAIC_MODEL resolved by run-task.sh
input: "", // stdin detached: print mode must never wait on a terminal (see issue #5)
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
timeout: task.timeoutSeconds * 1000,
killSignal: "SIGKILL",
},
);
const finishedAt = new Date();
fs.writeFileSync(stderrFile, proc.stderr ?? "", { flag: "wx" });
const response = (proc.stdout ?? "").replace(/^[^\S\n]+/, "").replace(/[^\S\n]+$/, "").trim();
let status = "succeeded";
let reason = null;
let expected = task.expectExact;
if (proc.error && proc.error.code === "ETIMEDOUT") {
status = "failed";
reason = "timeout";
} else if (proc.error) {
status = "failed";
reason = `spawn-error: ${proc.error.code ?? proc.error.message}`;
} else if (proc.status !== 0) {
status = "failed";
reason = "exit-nonzero";
} else if (expected !== null && response !== expected) {
status = "failed";
reason = "expect-mismatch";
}
const result = {
runVersion: 1,
runId,
taskId: task.id,
missionId: task.missionId,
status,
reason,
request: task.prompt,
response,
expectedExact: expected,
exitCode: proc.status,
signal: proc.signal ?? null,
provider: resolved.execution.provider,
model: resolved.execution.model,
startedAt: startedAt.toISOString(),
finishedAt: finishedAt.toISOString(),
durationMs: finishedAt.getTime() - startedAt.getTime(),
};
writeOnce(path.join(runDir, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
process.stdout.write(`run: ${runId}\nstatus: ${status}${reason ? ` (${reason})` : ""}\nresponse: ${response}\n`);
process.exit(status === "succeeded" ? 0 : 1);
}
function listRuns() {
const resolved = loadConfig();
const root = runsRoot(resolved);
let entries = [];
try {
entries = fs.readdirSync(root).filter((name) => name.startsWith("r-")).sort();
} catch {
// No runs yet.
}
for (const runId of entries) {
let status = "unknown";
let taskId = "-";
try {
const result = JSON.parse(fs.readFileSync(path.join(root, runId, "result.json"), "utf8"));
status = result.status;
taskId = result.taskId;
} catch {
// Incomplete run record; report as unknown.
}
process.stdout.write(`${runId} ${status.padEnd(9)} ${taskId}\n`);
}
}
const operation = process.argv[2];
const target = process.argv[3];
switch (operation) {
case "validate": {
if (!target) fail(4, "usage: mosaic-task.mjs validate <taskFile>");
const file = path.resolve(target);
const task = validateTask(readJsonFile(file, "task file"), file);
const { missionSnapshot, ...rest } = task;
process.stdout.write(`${JSON.stringify(rest, null, 2)}\n`);
process.exit(0);
}
case "run":
if (!target) fail(4, "usage: mosaic-task.mjs run <taskFile>");
runTask(path.resolve(target));
break;
case "list":
listRuns();
process.exit(0);
default:
fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | list)`);
}
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Run a mission/task file through the config-driven container path.
#
# Usage: scripts/run-task.sh run <task.json> (also: validate <task.json>, list)
#
# Ensures the configuration is valid and the data root carries the
# ownership marker before any task executes.
set -euo pipefail
cd "$(dirname "$0")/.."
# shellcheck source=common.sh
source scripts/common.sh
load_config
bootstrap_runtime_dir
exec node scripts/mosaic-task.mjs "$@"