Files
stack/scripts/mosaic-task.mjs
T
jason.woltje 88eef507b0 feat(retention): run-record pruning - keep newest N, dry-run default (#32)
- mosaic-task.mjs prune [--keep=N] [--yes]: default keep 50; without
  --yes lists candidates without deleting
- only r-* directories under the runs root; symlinks skipped;
  sessions/workspaces/state/config untouched (asserted by suite sentinels)
- append-only receipt runs/.pruned.log records every pruned id
- test-task.sh: +8 retention cases (dry-run no-delete, keep-N, newest
  kept, receipt, isolation, invalid keep, empty no-op)

Also: suite hardening - prune section scopes its config per-command
(no export/unset leaking into later sections); duplicated check()
removed; latest_reason hoisted to helpers; status colors now green OK /
red FAIL (terminal-only, NO_COLOR-aware) per owner UX feedback.

Closes #32
2026-09-03 06:33:27 -05:00

611 lines
23 KiB
JavaScript
Executable File

#!/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 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", "capabilities"], "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;
});
}
// Governing capability constraints (M9): same validation as task
// capabilities; semantically these BOUND tasks (least-privilege
// intersection at run time), never grant beyond them.
let capabilities = null;
if (document.capabilities !== undefined && document.capabilities !== null) {
if (!isPlainObject(document.capabilities)) fail(2, 'mission "capabilities" must be a JSON object');
rejectUnknownKeys(document.capabilities, ["tools"], 'mission "capabilities"');
if (!Array.isArray(document.capabilities.tools) || document.capabilities.tools.length === 0) {
fail(2, 'mission "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 mission tool: ${JSON.stringify(tool)} (supported: ${SUPPORTED_TOOLS.join(", ")})`);
}
if (seen.has(tool)) fail(2, `duplicate tool in mission capabilities.tools: ${tool}`);
seen.add(tool);
}
capabilities = { tools: [...seen] };
}
return { missionVersion: document.missionVersion, id: document.id, objective: document.objective, directives, capabilities };
}
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 <dataRoot>/workspaces/<name>.
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
// <dataRoot>/sessions/<name>. 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;
// Capability policy (M9): least-privilege intersection. A task may narrow
// a mission's tool grant, never widen it. Empty intersection = tool-free.
let effectiveTools = task.tools;
let policyNote = null;
if (task.missionSnapshot?.capabilities) {
const missionTools = task.missionSnapshot.capabilities.tools;
if (effectiveTools) {
effectiveTools = effectiveTools.filter((t) => missionTools.includes(t));
if (effectiveTools.length === 0) {
policyNote = `capability policy: mission ${task.missionSnapshot.id} and task request no tools in common -> tool-free run`;
}
} else {
effectiveTools = [...missionTools];
}
}
if (policyNote) process.stderr.write(`mosaic-task: ${policyNote}\n`);
spawnEnv.MOSAIC_TOOLS = effectiveTools ? effectiveTools.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: effectiveTools,
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-<id>)`);
}
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,
result.retriedFrom ? `retriedFrom: ${result.retriedFrom}` : 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-<id>)`);
}
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 });
}
function pruneRuns(args) {
const resolved = loadConfig();
const root = runsRoot(resolved);
let keep = 50;
let apply = false;
for (const arg of args) {
if (arg === "--yes") apply = true;
else if (arg === "--keep") fail(4, "prune: --keep requires a value");
else if (arg.startsWith("--keep=")) {
keep = Number(arg.slice("--keep=".length));
if (!Number.isInteger(keep) || keep < 1) fail(4, `--keep must be a positive integer (got ${arg.slice(7)})`);
} else fail(4, `unknown prune option: ${arg}`);
}
let entries = [];
try {
entries = fs.readdirSync(root, { withFileTypes: true })
.filter((e) => e.name.startsWith("r-") && e.isDirectory() && !e.isSymbolicLink())
.map((e) => e.name)
.sort();
} catch {
// No runs yet.
}
if (entries.length <= keep) {
process.stdout.write(`prune: ${entries.length} run(s) present, keep=${keep} -> nothing to prune\n`);
process.exit(0);
}
const doomed = entries.slice(0, entries.length - keep); // oldest first
if (!apply) {
process.stdout.write(`prune (dry-run): would remove ${doomed.length} oldest run(s), keep ${entries.length - doomed.length}:\n`);
for (const id of doomed) process.stdout.write(` would remove: ${id}\n`);
process.stdout.write("prune: re-run with --yes to apply\n");
process.exit(0);
}
const receipt = path.join(root, ".pruned.log");
for (const id of doomed) {
fs.rmSync(path.join(root, id), { recursive: true, force: true });
fs.appendFileSync(receipt, `${JSON.stringify({ at: new Date().toISOString(), event: "pruned", runId: id })}\n`);
}
process.stdout.write(`prune: removed ${doomed.length} run(s), kept ${keep}; receipt: ${receipt}\n`);
process.exit(0);
}
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 "show":
if (!target) fail(4, "usage: mosaic-task.mjs show <runId>");
showRun(target);
break;
case "list":
listRuns();
process.exit(0);
case "prune":
pruneRuns(process.argv.slice(3));
break;
case "retry":
if (!target) fail(4, "usage: mosaic-task.mjs retry <runId>");
retryRun(target);
break;
default:
fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | show | list | retry | prune)`);
}