From d2a9e26395c32a2fc3050722a83fd8f881a5050b Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Wed, 2 Sep 2026 19:56:29 -0500 Subject: [PATCH] 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 /runs/r--/: 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 --- scripts/mosaic-task.mjs | 318 ++++++++++++++++++++++++++++++++++++++++ scripts/run-task.sh | 16 ++ 2 files changed, 334 insertions(+) create mode 100755 scripts/mosaic-task.mjs create mode 100755 scripts/run-task.sh diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs new file mode 100755 index 00000000..12610036 --- /dev/null +++ b/scripts/mosaic-task.mjs @@ -0,0 +1,318 @@ +#!/usr/bin/env node +/** + * Mosaic mission/task layer (M2, host-side only). + * + * Operations: + * validate Strictly validate a task (and its referenced mission); + * print the resolved task on success. Writes nothing. + * run Execute the task through the config-driven container + * path; record an immutable run under /runs/. + * list List runs recorded under /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 "); + 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 "); + runTask(path.resolve(target)); + break; + case "list": + listRuns(); + process.exit(0); + default: + fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | list)`); +} diff --git a/scripts/run-task.sh b/scripts/run-task.sh new file mode 100755 index 00000000..7a8af449 --- /dev/null +++ b/scripts/run-task.sh @@ -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 (also: validate , 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 "$@"