Darkwing's round 2 candidate, approved by Filbert (#1518 comment 26730). build-r2.patch a27890d5, manifest 869168c7, 34 files, applied on HEAD and checked 34/34. Integration gate on an export of HEAD plus the patch: business 60/60 on Node 24 and 26, every package test and every scripts/test-*.sh green, test-task 98/98 with the live-provider cases. Conductor, queue, conversation and discord confirmed in git worktrees of HEAD with and without the patch, identical results. Lead decision 63 accepts the vocabulary location, the example path and the business branch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
673 lines
26 KiB
JavaScript
Executable File
673 lines
26 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";
|
|
import { BusinessError, validateRoleDocument } from "../packages/business/src/index.mjs";
|
|
|
|
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", "sessionForkFrom"], "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;
|
|
}
|
|
|
|
// Session fork (M11): optional source session whose newest session file
|
|
// is branched (pi --fork) into the target session dir. Requires session.
|
|
let sessionForkFrom = null;
|
|
if (document.sessionForkFrom !== undefined && document.sessionForkFrom !== null) {
|
|
if (typeof document.sessionForkFrom !== "string" || document.sessionForkFrom.length === 0) {
|
|
fail(2, 'task "sessionForkFrom" must be a non-empty string when present');
|
|
}
|
|
validateId(document.sessionForkFrom, "task sessionForkFrom");
|
|
if (!session) {
|
|
fail(2, 'task "sessionForkFrom" requires "session" (the fork target) to be set');
|
|
}
|
|
if (document.sessionForkFrom === session) {
|
|
fail(2, 'task "sessionForkFrom" must differ from "session" (cannot fork onto itself)');
|
|
}
|
|
sessionForkFrom = document.sessionForkFrom;
|
|
}
|
|
|
|
// 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,
|
|
sessionForkFrom,
|
|
};
|
|
}
|
|
|
|
// Role contract (M18, slice 1 row S1): seat-declared role authority. The
|
|
// tools array is a ceiling - seats may narrow it, never escalate past it.
|
|
// Version 2 adds a contract, an authority map and credential needs;
|
|
// packages/business validates both versions. Unknown keys refuse, name must
|
|
// match the filename, wrong document kind refuses.
|
|
function validateRole(document, file) {
|
|
try {
|
|
return validateRoleDocument(document, file);
|
|
} catch (error) {
|
|
if (error instanceof BusinessError) fail(error.exitCode, error.message);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
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}`;
|
|
}
|
|
|
|
// Session fork (M11): resolve the source session's newest file; pi --fork
|
|
// branches it into the target dir without modifying the ancestor.
|
|
if (task.sessionForkFrom) {
|
|
const sourceDir = path.join(resolved.dataRoot, "sessions", task.sessionForkFrom);
|
|
let sources = [];
|
|
try {
|
|
sources = fs.readdirSync(sourceDir).filter((f) => f.endsWith(".jsonl")).sort();
|
|
} catch {
|
|
fail(4, `cannot fork: source session dir not found: ${sourceDir}`);
|
|
}
|
|
if (sources.length === 0) {
|
|
fail(4, `cannot fork: source session '${task.sessionForkFrom}' has no session files`);
|
|
}
|
|
const relative = path.relative(resolved.dataRoot, path.join(sourceDir, sources[sources.length - 1]));
|
|
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
fail(4, `source session is outside the configured dataRoot: ${sourceDir}`);
|
|
}
|
|
spawnEnv.MOSAIC_SESSION_FORK = `/var/lib/mosaic/${relative.split(path.sep).join("/")}`;
|
|
}
|
|
|
|
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,
|
|
sessionForkFrom: task.sessionForkFrom,
|
|
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 "resolve-role": {
|
|
if (!target) fail(4, "usage: mosaic-task.mjs resolve-role <roleFile>");
|
|
const file = path.resolve(target);
|
|
const role = validateRole(readJsonFile(file, "role contract"), file);
|
|
const contract = role.contractPath === null ? "" : `MOSAIC_ROLE_CONTRACT=${role.contractPath}\n`;
|
|
process.stdout.write(`MOSAIC_ROLE_TOOLS=${role.tools.join(",")}\nMOSAIC_ROLE_NETWORK=${role.network}\n${contract}`);
|
|
process.exit(0);
|
|
}
|
|
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 | resolve-role)`);
|
|
}
|