- task schema: optional session (named id) -> persistent session dir at dataRoot/sessions/<name>, isolated per name - pi adapter: --session-dir when declared (ephemeral --no-session stays the default otherwise); -c resumes the most recent session when present - compose passthrough; result.json records session - fixtures: tasks/session-demo-1.json (teach) + session-demo-2.json (recall) - E2E: teach -> REMEMBERED + host-side session JSONL; resume -> recalled 'mosaico' exactly; single continued session file Closes #22, closes #23
401 lines
14 KiB
JavaScript
Executable File
401 lines
14 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 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 <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) {
|
|
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;
|
|
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,
|
|
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 = "-";
|
|
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)`);
|
|
}
|