- 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:
Executable
+318
@@ -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)`);
|
||||
}
|
||||
Reference in New Issue
Block a user