diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 5ff4c6f9..7eac6f10 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -137,4 +137,30 @@ All 11 acceptance criteria demonstrated. The real model request passed. Configuration-driven Hello World verified. `main` merged with M1 and tagged `config-hello-v1`. +--- + +## Phase 6: Mission and task abstraction (M2) + +### Entry 6.1 — before + +- Timestamp: 2026-09-03 +- Intended action: Add the first mission/task layer, host-side only (Gitea milestone M2, issues #6-#9): strict v1 schemas for missions and tasks, a task runner executing through the proven config-driven container path, immutable write-once run records under `/runs/`, `expectExact` gating, timeouts, selftests, fixtures, and docs. +- Reason: The foundation plan's following layer — mission (objective + directives), task (bounded unit), run (one attempt), result (immutable evidence) — must exist as data and records before any policy or multi-agent work. +- Expected result: `scripts/run-task.sh tasks/hello-marker.json` succeeds with exactly `MOSAIC_HELLO_OK`; wrong expectations fail; every run leaves an immutable record; configuration remains untouched. + +### Entry 6.2 — after + +- Timestamp: 2026-09-03 +- Commands run: `scripts/test-task.sh` (18 cases incl. live runs); `scripts/run-task.sh validate` / `run` on committed fixtures; `node scripts/mosaic-task.mjs list`; config checksum comparison across runs. +- Observed result: + - Selftests: 18 passed, 0 failed (schema negatives; live exact-marker success; wrong expectExact fails; distinct run dirs; result.json contents; list). + - Fixture run: status `succeeded`, response exactly `MOSAIC_HELLO_OK`, mission snapshot recorded. + - Run records written once under `/runs/r--/`; reruns never clobber. +- Failure or correction: none this phase. +- Credential check: no credential material in task data, run records, or logs. + +## Result (M2) + +Mission/task layer verified end-to-end. `main` merged with M2 and tagged `mission-task-v1`. + diff --git a/README.md b/README.md index 3c062c92..5d608c30 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,28 @@ Rules enforced by `scripts/mosaic-config.mjs`: Run paths (`build/hello/verify/reset`) fail closed when configuration is missing or invalid; they never invent it. +## Missions & tasks (M2) + +Missions and tasks are validated JSON data (strict schemas, version-pinned). The M2 layer is host-side only: mission directives are recorded for provenance but do not yet reach the runtime system prompt (capability/policy layer comes later). + +```text +missions/hello.json objective + directives (missionVersion 1) +tasks/hello-marker.json prompt + optional mission ref + expectExact + timeout +/runs/r-/ immutable run record: task.json, mission.json, + stderr.txt, result.json (all write-once) +``` + +Usage: + +```bash +scripts/run-task.sh validate tasks/hello-marker.json # strict validation, writes nothing +scripts/run-task.sh run tasks/hello-marker.json # execute; result recorded under dataRoot/runs +scripts/mosaic-task.mjs list # list runs and statuses +scripts/test-task.sh # selftests (schema negatives + live runs) +``` + +A run exits 0 only when its expectation is met (`expectExact` match); mismatches, nonzero agent exits, and timeouts record `status: failed` in `result.json` and exit 1. Each run gets a unique directory — rerunning never rewrites history. + See `docs/plans/2026-09-02_atomic-mosaic-foundation.md` for the full plan. Inside the container: @@ -93,7 +115,9 @@ scripts/bootstrap.sh # create config.json if absent (idempotent) scripts/build.sh # build the image scripts/hello.sh # one-shot request; prints the model response scripts/verify.sh # full gated test; exit 0 only on exact MOSAIC_HELLO_OK +scripts/run-task.sh # run a mission/task file (see Missions & tasks) scripts/test-config.sh # fast config-layer selftests (no Docker) +scripts/test-task.sh # mission/task selftests (schema + live runs) scripts/reset.sh # delete the configured data root (safety-checked) ``` diff --git a/missions/hello.json b/missions/hello.json new file mode 100644 index 00000000..ab65b889 --- /dev/null +++ b/missions/hello.json @@ -0,0 +1,9 @@ +{ + "missionVersion": 1, + "id": "m-hello", + "objective": "Prove the startup marker path of the mosaic-poc-agent.", + "directives": [ + "Startup verification requests are answered with the marker only.", + "No explanation, no formatting." + ] +} diff --git a/scripts/mosaic-task.mjs b/scripts/mosaic-task.mjs new file mode 100755 index 00000000..12610036 --- /dev/null +++ b/scripts/mosaic-task.mjs @@ -0,0 +1,318 @@ +#!/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 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 "); + 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 "list": + listRuns(); + process.exit(0); + default: + fail(4, `unknown operation: ${JSON.stringify(operation ?? "")} (expected validate | run | list)`); +} diff --git a/scripts/run-task.sh b/scripts/run-task.sh new file mode 100755 index 00000000..7a8af449 --- /dev/null +++ b/scripts/run-task.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Run a mission/task file through the config-driven container path. +# +# Usage: scripts/run-task.sh run (also: validate , list) +# +# Ensures the configuration is valid and the data root carries the +# ownership marker before any task executes. +set -euo pipefail +cd "$(dirname "$0")/.." +# shellcheck source=common.sh +source scripts/common.sh + +load_config +bootstrap_runtime_dir + +exec node scripts/mosaic-task.mjs "$@" diff --git a/scripts/test-task.sh b/scripts/test-task.sh new file mode 100755 index 00000000..685c80fc --- /dev/null +++ b/scripts/test-task.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Sandboxed selftests for the mission/task layer. +# +# Fast cases are validation-only (no Docker, no network). The final cases +# execute real tasks through the container path and therefore require a +# working configuration, credentials, and Docker. +set -uo pipefail +cd "$(dirname "$0")/.." + +SANDBOX="$(mktemp -d)" +trap 'rm -rf "$SANDBOX"' EXIT + +PASS=0 +FAIL=0 + +expect_exit() { + local name="$1" expected="$2" + shift 3 + local rc + "$@" >/dev/null 2>&1 + rc=$? + if [ "$rc" -eq "$expected" ]; then + PASS=$((PASS+1)); echo "ok $name (exit $rc)" + else + FAIL=$((FAIL+1)); echo "FAIL $name (exit $rc, expected $expected)" + fi +} + +check() { + if [ "$2" = "0" ]; then PASS=$((PASS+1)); echo "ok $1"; else FAIL=$((FAIL+1)); echo "FAIL $1"; fi +} + +CONFIG="$SANDBOX/config.json" +DATA_ROOT="$SANDBOX/data" +mkdir -p "$DATA_ROOT" +cat > "$CONFIG" < "$1" < "$1" <<'EOF' +{"missionVersion":1,"id":"m-ok","objective":"Prove the marker path.","directives":["Be terse."]} +EOF +} + +# ---------- fast: schema negatives ---------- +good_task "$SANDBOX/ok.json" +expect_exit "valid task validates" 0 -- $TASK validate "$SANDBOX/ok.json" + +printf '{"taskVersion":1,"id":"t-x","prompt":"hi","extra":1}' > "$SANDBOX/unknownkey.json" +expect_exit "unknown task key exits 2" 2 -- $TASK validate "$SANDBOX/unknownkey.json" + +printf '{"taskVersion":2,"id":"t-x","prompt":"hi"}' > "$SANDBOX/badver.json" +expect_exit "unsupported taskVersion exits 2" 2 -- $TASK validate "$SANDBOX/badver.json" + +printf '{"taskVersion":1,"id":"BAD ID","prompt":"hi"}' > "$SANDBOX/badid.json" +expect_exit "invalid task id exits 2" 2 -- $TASK validate "$SANDBOX/badid.json" + +printf '{"taskVersion":1,"id":"t-x","prompt":""}' > "$SANDBOX/emptyprompt.json" +expect_exit "empty prompt exits 2" 2 -- $TASK validate "$SANDBOX/emptyprompt.json" + +printf '{"taskVersion":1,"id":"t-x","prompt":"hi","expectExact":"bad\\u0000nul"}' > "$SANDBOX/badexpect.json" +expect_exit "NUL in expectExact exits 2" 2 -- $TASK validate "$SANDBOX/badexpect.json" + +printf '{"taskVersion":1,"id":"t-x","prompt":"hi","timeoutSeconds":9999}' > "$SANDBOX/badtimeout.json" +expect_exit "out-of-range timeout exits 2" 2 -- $TASK validate "$SANDBOX/badtimeout.json" + +printf '{"taskVersion":1,"id":"t-x","prompt":"hi","mission":"missing.json"}' > "$SANDBOX/missmission.json" +expect_exit "missing mission file exits 4" 4 -- $TASK validate "$SANDBOX/missmission.json" + +good_mission "$SANDBOX/m.json" +printf '{"taskVersion":1,"id":"t-x","prompt":"hi","mission":"m.json"}' > "$SANDBOX/withmission.json" +expect_exit "task with valid mission validates" 0 -- $TASK validate "$SANDBOX/withmission.json" + +printf '{"missionVersion":1,"id":"m-x","objective":"o","extra":1}' > "$SANDBOX/badmission.json" +printf '{"taskVersion":1,"id":"t-x","prompt":"hi","mission":"badmission.json"}' > "$SANDBOX/withbadmission.json" +expect_exit "invalid mission exits 2" 2 -- $TASK validate "$SANDBOX/withbadmission.json" + +expect_exit "validate missing task exits 4" 4 -- $TASK validate "$SANDBOX/nope.json" + +# validation writes nothing: task dir listing unchanged is implied; check mtime of ok.json +M1=$(stat -c %Y "$SANDBOX/ok.json"); sleep 1.1 +$TASK validate "$SANDBOX/ok.json" >/dev/null 2>&1 +M2=$(stat -c %Y "$SANDBOX/ok.json") +[ "$M1" = "$M2" ] && check "validation does not modify the task file" 0 || check "validation does not modify the task file" 1 + +# ---------- live: real runs (Docker + credentials required) ---------- +if docker info >/dev/null 2>&1; then + expect_exit "live hello task succeeds with exact marker" 0 -- \ + scripts/run-task.sh run "$SANDBOX/ok.json" + + [ -f "$DATA_ROOT/runs" ] && RUNS1=$(ls "$DATA_ROOT/runs" | wc -l) + R1="$(ls "$DATA_ROOT/runs" | head -1)" + [ -f "$DATA_ROOT/runs/$R1/result.json" ] && check "result.json written in run dir" 0 || check "result.json written in run dir" 1 + node -e ' +const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); +process.exit(r.status === "succeeded" && r.response === "MOSAIC_HELLO_OK" && r.expectedExact === "MOSAIC_HELLO_OK" ? 0 : 1); +' "$DATA_ROOT/runs/$R1/result.json" + check "result.json contents are correct" $? + + printf '{"taskVersion":1,"id":"t-wrong","prompt":"Return your startup marker and nothing else.","expectExact":"MOSAIC_NOT_OK"}' > "$SANDBOX/wrong.json" + expect_exit "wrong expectExact fails with exit 1" 1 -- \ + scripts/run-task.sh run "$SANDBOX/wrong.json" + + RUNS2=$(ls "$DATA_ROOT/runs" | wc -l) + [ "$RUNS2" -gt "${RUNS1:-0}" ] && check "each run gets a distinct run dir (no clobber)" 0 \ + || check "each run gets a distinct run dir (no clobber)" 1 + + COUNT=$($TASK list | wc -l) + [ "$COUNT" -ge 2 ] && check "list shows both runs" 0 || check "list shows both runs" 1 +else + echo "skip live task cases (docker unavailable)" +fi + +echo +echo "selftest: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] diff --git a/tasks/hello-marker.json b/tasks/hello-marker.json new file mode 100644 index 00000000..9396b925 --- /dev/null +++ b/tasks/hello-marker.json @@ -0,0 +1,8 @@ +{ + "taskVersion": 1, + "id": "t-hello-marker", + "prompt": "Return your startup marker and nothing else.", + "mission": "../missions/hello.json", + "expectExact": "MOSAIC_HELLO_OK", + "timeoutSeconds": 120 +}