#!/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 os from "node:os"; 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; const SUPPORTED_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls"]; // pi documented built-ins 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", "workspace", "capabilities", "session"], "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; } // Workspace (M5): absent = none; ":run" = ephemeral per-run; otherwise a // persistent named workspace under /workspaces/. let workspace = null; if (document.workspace !== undefined && document.workspace !== null) { if (typeof document.workspace !== "string" || document.workspace.length === 0) { fail(2, 'task "workspace" must be a non-empty string when present'); } if (document.workspace !== ":run") { validateId(document.workspace, "task workspace"); } workspace = document.workspace; } // Session (M6): optional named persistent session under // /sessions/. Distinct names never share state. let session = null; if (document.session !== undefined && document.session !== null) { if (typeof document.session !== "string" || document.session.length === 0) { fail(2, 'task "session" must be a non-empty string when present'); } validateId(document.session, "task session"); session = document.session; } // Capabilities (M5): optional tools allowlist mapped by adapters to their // native permission flags. Absent = no tools. let tools = null; if (document.capabilities !== undefined && document.capabilities !== null) { if (!isPlainObject(document.capabilities)) fail(2, '"capabilities" must be a JSON object'); rejectUnknownKeys(document.capabilities, ["tools"], '"capabilities"'); if (!Array.isArray(document.capabilities.tools) || document.capabilities.tools.length === 0) { fail(2, '"capabilities.tools" must be a non-empty array of tool names'); } const seen = new Set(); for (const tool of document.capabilities.tools) { if (!SUPPORTED_TOOLS.includes(tool)) { fail(2, `unsupported tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`); } if (seen.has(tool)) fail(2, `duplicate tool in capabilities.tools: ${tool}`); seen.add(tool); } tools = [...seen]; } return { taskVersion: document.taskVersion, id: document.id, prompt: document.prompt, mission, missionId, missionSnapshot, expectExact, timeoutSeconds, workspace, tools, session, }; } 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, options = {}) { 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"); // Sanctioned mission injection: point the container at the run snapshot's // CONTAINER path (dataRoot maps to /var/lib/mosaic in the image). const spawnEnv = { ...process.env }; spawnEnv.MOSAIC_ADAPTER = resolved.execution.adapter; // Self-sufficient env: direct invocation (e.g. `retry`) skips the shell // launcher exports, so derive them from the resolved config and release. // (Names here are the compose interpolation consumers, not PI_*.) spawnEnv.MOSAIC_PROVIDER = resolved.execution.provider; spawnEnv.MOSAIC_MODEL = resolved.execution.model; spawnEnv.MOSAIC_DATA_ROOT = resolved.dataRoot; const release = fs.readFileSync(path.join(PROJECT_ROOT, "RELEASE"), "utf8").trim(); const piVersion = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, "package.json"), "utf8")).dependencies["@earendil-works/pi-coding-agent"]; spawnEnv.MOSAIC_IMAGE_TAG = `mosaic-poc-agent:${piVersion}-r${release}`; if (task.missionSnapshot) { const relative = path.relative(resolved.dataRoot, runDir); if (relative.startsWith("..") || path.isAbsolute(relative)) { fail(4, `run directory is outside the configured dataRoot: ${runDir}`); } spawnEnv.MOSAIC_MISSION_FILE = `/var/lib/mosaic/${relative.split(path.sep).join("/")}/mission.json`; } // Workspace (M5): create host-side, pass the CONTAINER path. let workspaceContainerPath = null; if (task.workspace === ":run") { fs.mkdirSync(path.join(runDir, "workspace"), { recursive: true }); workspaceContainerPath = `/var/lib/mosaic/runs/${runId}/workspace`; } else if (task.workspace) { fs.mkdirSync(path.join(resolved.dataRoot, "workspaces", task.workspace), { recursive: true }); workspaceContainerPath = `/var/lib/mosaic/workspaces/${task.workspace}`; } if (workspaceContainerPath) spawnEnv.MOSAIC_WORKSPACE = workspaceContainerPath; spawnEnv.MOSAIC_TOOLS = task.tools ? task.tools.join(",") : ""; // Session (M6): persistent named session dir, passed as container path. if (task.session) { fs.mkdirSync(path.join(resolved.dataRoot, "sessions", task.session), { recursive: true }); spawnEnv.MOSAIC_SESSION_DIR = `/var/lib/mosaic/sessions/${task.session}`; } const proc = spawnSync( "docker", ["compose", "run", "--rm", "-T", "mosaic-agent", task.prompt], { cwd: PROJECT_ROOT, env: spawnEnv, 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, ...(options.retriedFrom ? { retriedFrom: options.retriedFrom } : {}), workspace: task.workspace, tools: task.tools, session: task.session, 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 = "-"; let workspace = "-"; let session = "-"; try { const result = JSON.parse(fs.readFileSync(path.join(root, runId, "result.json"), "utf8")); status = result.status; taskId = result.taskId; workspace = result.workspace ?? "-"; session = result.session ?? "-"; } catch { // Incomplete run record; report as unknown. } process.stdout.write(`${runId} ${status.padEnd(9)} task=${taskId.padEnd(18)} ws=${String(workspace).padEnd(10)} session=${session}\n`); } } function showRun(runId) { if (!/^r-[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(runId)) { fail(4, `invalid run id: ${JSON.stringify(runId)} (expected r-)`); } const resolved = loadConfig(); const dir = path.join(runsRoot(resolved), runId); if (!fs.existsSync(dir)) { fail(4, `run not found: ${runId} (under ${runsRoot(resolved)})`); } const read = (name) => { try { return JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")); } catch { return null; } }; const result = read("result.json"); const task = read("task.json"); const mission = read("mission.json"); process.stdout.write(`run: ${runId}\n`); if (result) { process.stdout.write( [ `status: ${result.status}${result.reason ? ` (${result.reason})` : ""}`, `task: ${result.taskId}`, result.missionId ? `mission: ${result.missionId}` : null, result.workspace ? `workspace: ${result.workspace}` : null, result.session ? `session: ${result.session}` : null, result.tools ? `tools: ${result.tools.join(", ")}` : null, `adapter: ${"(see config)"} provider=${result.provider} model=${result.model}`, `request: ${JSON.stringify(result.request)}`, `response: ${JSON.stringify(result.response)}`, result.expectedExact !== null && result.expectedExact !== undefined ? `expected: ${JSON.stringify(result.expectedExact)}` : null, `timing: ${result.startedAt} -> ${result.finishedAt} (${result.durationMs} ms)`, `exit: ${result.exitCode}${result.signal ? ` signal=${result.signal}` : ""}`, ].filter((line) => line !== null).join("\n") + "\n", ); } else { process.stdout.write("result.json: (missing or unreadable)\n"); } if (task) process.stdout.write(`task snapshot: ${"task.json"} present\n`); if (mission) process.stdout.write(`mission snapshot: ${mission.id} - ${mission.objective}\n`); process.stdout.write(`artifacts: ${fs.readdirSync(dir).map((f) => `${f}`).join(", ")}\n`); process.exit(0); } function retryRun(runId) { if (!/^r-[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(runId)) { fail(4, `invalid run id: ${JSON.stringify(runId)} (expected r-)`); } const resolved = loadConfig(); const dir = path.join(runsRoot(resolved), runId); if (!fs.existsSync(dir)) { fail(4, `run not found: ${runId}`); } let snapshot; try { snapshot = fs.readFileSync(path.join(dir, "task.json"), "utf8"); } catch { fail(4, `run task snapshot unreadable: ${runId}`); } // Relative mission paths in a snapshot resolve against the ORIGINAL task // location, which no longer exists here — rewrite them to the run's own // recorded mission.json so retries stay faithful. let snapshotDoc; try { snapshotDoc = JSON.parse(snapshot); } catch { fail(4, `run task snapshot is not valid JSON: ${runId}`); } if (snapshotDoc.mission && !path.isAbsolute(snapshotDoc.mission)) { const recordedMission = path.join(dir, "mission.json"); if (!fs.existsSync(recordedMission)) { fail(4, `cannot retry ${runId}: relative mission path but no mission.json snapshot in run dir`); } snapshotDoc.mission = recordedMission; snapshot = `${JSON.stringify(snapshotDoc, null, 2)}\n`; } // A retry is a brand-new run: replay the recorded task snapshot through // the ordinary run path; existing run records stay untouched. const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mosaic-retry-")); const tempTaskFile = path.join(tempDir, "task.json"); fs.writeFileSync(tempTaskFile, snapshot); process.on("exit", () => { try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { // Best-effort cleanup only. } }); runTask(tempTaskFile, { retriedFrom: runId }); } 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 "show": if (!target) fail(4, "usage: mosaic-task.mjs show "); showRun(target); break; case "list": listRuns(); process.exit(0); case "retry": if (!target) fail(4, "usage: mosaic-task.mjs retry "); retryRun(target); break; default: fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | show | list | retry)`); }