NEO.K / MSSP FIELD LAB001-task-runner
編號001-task-runner
語言javascript
版本v1.0
日期2026-07-31
行數509
執行node src/main.js

001 — Task Runner: when two TMS want to import each other

What this program does

It takes a list of typed tasks, runs each one, and prints the outcome two ways: a human-readable report and a JSON one. Tasks can read a file or fetch a URL. Some are refused by policy. Nothing touches the network or the disk — the tools are stubbed so the example runs anywhere.

node src/main.js
node src/island-test.js

The structural decision

Both reporters need the same derived numbers: how long each task took, how many passed, and which was slowest. Whichever reporter is written first naturally grows a small summarise function, and the second one imports it:

// TMS/reporters/json.js
import { summarise } from "./text.js";   // ← the line this example is about

That line works. It also does two things that are invisible on the day it is written:

  1. json can no longer be loaded without text. The island test for json silently becomes a test of both.
  2. Deleting text breaks json, which has nothing to do with it.

This is the TMS-to-TMS dependency the method forbids (§7.3). The fix is not to copy the function into both — that produces two summaries that drift apart. It is to notice that the summary was never reporter-specific: it is a property of the run.

So runReport() lives in SMS/model.js, computes counts, totalMs, passed, and slowest once, and both reporters read those fields.

TMS/reporters/text  ──┐
                      ├──▶  SMS/model.js  (runReport)
TMS/reporters/json  ──┘

Neither reporter imports the other. scripts/build-mssp.mjs greps every example for sibling-TMS imports and fails the build if one appears, so this does not depend on anyone remembering.

Set by set

FMSsrc/FMS/manifest.json. What the system is, its non-goals, where each capability lives, and the dependency rule. Nothing at runtime reads it; it exists so a person or an agent can find a capability without loading it. No procedure lives here, which is what keeps it from becoming a second monolith (§15.4).

SCLsrc/SCL/permissions.{js,json}. Per-type limits: a path prefix for file.read, an allowed host list for http.get, outright denial for file.delete. This is a set rather than a comment because a comment is an expectation and a function is a contract (§15.5). Unknown types are denied by default.

SMS — three modules, each failing the identity test if removed:

TMS — four modules, each importing only from SMS: two handlers (file.read, http.get) and two reporters (text, json). Any one can be removed and the rest still run.

DMSsrc/DMS/trace.js. Kept separate from the reporters because a reporter renders the result while the trace records the run, including what never became a result. 4 skipped is a number; the human view is what says which four and why.

There is no Router here. With one task type per handler, routing is a Map lookup inside the registry, and a separate router would be an empty layer. It appears when task selection stops being a lookup.

A second decision the code makes

runner.js checks permission before capability. The reverse reads more naturally — why consult policy about something you cannot do anyway? — and it is what this example did first. It produced this line:

skipped wipe-data  no handler loaded for this type

True, and quietly wrong: file.delete is denied by policy, and the run reported the loading state instead. The moment someone registers a delete handler, that message changes meaning without anyone editing the policy. With the checks in the right order it reads:

skipped wipe-data  denied: irreversible; no handler is loaded for it and policy denies it regardless

The general form: a policy answer that depends on which modules happen to be loaded is not a policy answer.

The island test

src/island-test.js builds a system with the minimal SMS and exactly one TMS, stubs every tool, and asserts nothing else was loaded.

$ node src/island-test.js
island test: TMS/reporters/json, alone
  [OK]   only one TMS loaded
  [OK]   renders a report with no sibling TMS present
  [OK]   absent handler is a skip, not a crash
  [OK]   DMS explains the skip

island test: TMS/handlers/file-read, alone
  [OK]   runs with a stubbed tool and no reporter registered
  [OK]   SCL refuses the traversal path, and the core does the refusing

all island checks passed

The first block is the one that matters. If json still imported text, that file would not even load — the failure is structural, not a wrong assertion.

What this example does not solve

Source

FMS

FMS/manifest.json
{
  "system": {
    "id": "001-task-runner",
    "version": "1.0.0",
    "purpose": "Run a list of typed tasks and report the outcome.",
    "non_goals": [
      "scheduling or cron",
      "parallel execution",
      "retry and backoff",
      "persistence between runs"
    ]
  },
  "capability_index": {
    "SMS": {
      "model": "src/SMS/model.js — task result, run report, the handler and reporter interfaces",
      "registry": "src/SMS/registry.js — how TMS modules are made present",
      "runner": "src/SMS/runner.js — the loop, permission check, trace emission"
    },
    "SCL": {
      "permissions": "src/SCL/permissions.js + permissions.json — per-type limits the runner enforces"
    },
    "TMS": {
      "handlers/file-read": "type file.read",
      "handlers/http-get": "type http.get",
      "reporters/text": "human-readable render of a run report",
      "reporters/json": "machine-readable render of the same report"
    },
    "DMS": {
      "trace": "src/DMS/trace.js — run events, plus the human view of skips and refusals"
    }
  },
  "dependency_rule": "TMS may import SMS. No TMS imports another TMS. Checked mechanically by scripts/build-mssp.mjs.",
  "notes": "FMS is metadata only. No procedure lives in this file, and nothing at runtime reads it — it exists so a person or an agent can locate a capability without loading it."
}

SCL

SCL/permissions.js
// SCL — what a task is allowed to do, checkable by the runtime.
//
// The point of putting this in its own set is that it is a check, not a
// sentence. "Please do not write outside the output directory" in a handler's
// comment is an expectation; this function is a contract, and the runner
// consults it before any handler runs.

import policy from "./permissions.json" with { type: "json" };

/** @returns {{allowed: boolean, reason?: string}} */
export function permits(task) {
  const rule = policy.types[task.type];
  if (!rule) return { allowed: false, reason: `type not in policy: ${task.type}` };

  if (rule.deny_all) return { allowed: false, reason: rule.note ?? "denied by policy" };

  if (rule.path_prefix && task.path) {
    // Compared after normalising separators so a Windows-style path in a task
    // definition is not silently treated as escaping the allowed prefix.
    const path = task.path.replaceAll("\\", "/");
    if (!path.startsWith(rule.path_prefix)) {
      return { allowed: false, reason: `path outside ${rule.path_prefix}` };
    }
    if (path.includes("..")) return { allowed: false, reason: "path traversal" };
  }

  if (rule.hosts && task.url) {
    const host = new URL(task.url).host;
    if (!rule.hosts.includes(host)) return { allowed: false, reason: `host not allowed: ${host}` };
  }

  return { allowed: true };
}

export const riskLevel = (task) => policy.types[task.type]?.risk ?? "unknown";
SCL/permissions.json
{
  "version": "1.0.0",
  "types": {
    "file.read": {
      "risk": "L0",
      "path_prefix": "data/"
    },
    "http.get": {
      "risk": "L1",
      "hosts": ["example.org", "example.com"]
    },
    "file.delete": {
      "risk": "L4",
      "deny_all": true,
      "note": "irreversible; no handler is loaded for it and policy denies it regardless"
    }
  }
}

SMS

SMS/model.js
// SMS — the shared data contract.
//
// This file is the whole point of the example. Both reporters need the same
// derived numbers: per-task outcome, duration, and the run totals. The obvious
// move is to compute them in whichever reporter is written first and import
// that from the second. This is where they live instead.
//
// It is SMS by the identity test: delete it and there is nothing for the runner
// to hand to a reporter, nothing for a handler to return, and no shape for the
// trace to record. The system stops being a task runner.

/** @typedef {"ok" | "failed" | "skipped"} Outcome */

/** One task's result. Handlers produce this; nothing else constructs it. */
export function taskResult({ id, type, outcome, detail = "", ms = 0 }) {
  if (!id || !type) throw new Error("taskResult requires id and type");
  if (!["ok", "failed", "skipped"].includes(outcome)) {
    throw new Error(`unknown outcome: ${outcome}`);
  }
  return Object.freeze({ id, type, outcome, detail, ms });
}

/**
 * The run report. Every consumer reads these fields and no consumer recomputes
 * them — that is what stops a second reporter from reaching into the first.
 */
export function runReport(results) {
  const counts = { ok: 0, failed: 0, skipped: 0 };
  let totalMs = 0;
  for (const result of results) {
    counts[result.outcome] += 1;
    totalMs += result.ms;
  }
  return Object.freeze({
    results: Object.freeze([...results]),
    counts: Object.freeze(counts),
    totalMs,
    passed: counts.failed === 0,
    // Derived here rather than in a reporter. The moment one reporter owns a
    // derived field, every other reporter either imports it or drifts from it.
    slowest: results.reduce((worst, r) => (!worst || r.ms > worst.ms ? r : worst), null),
  });
}

/**
 * The interface a handler TMS binds to. Declared in SMS so a handler never has
 * to know which other handlers exist.
 */
export function defineHandler({ type, run }) {
  if (typeof run !== "function") throw new Error("handler needs a run function");
  return Object.freeze({ type, run });
}

/** The interface a reporter TMS binds to. */
export function defineReporter({ name, render }) {
  if (typeof render !== "function") throw new Error("reporter needs a render function");
  return Object.freeze({ name, render });
}
SMS/registry.js
// SMS — how TMS modules get in.
//
// A registry is what makes "load one TMS alone" possible. Without it, main.js
// would import every handler and reporter directly, and the island test would
// have no way to construct a system with exactly one TMS present.

export function createRegistry() {
  const handlers = new Map();
  const reporters = new Map();

  return {
    /** Registering the same type twice is a wiring bug, not a last-one-wins. */
    addHandler(handler) {
      if (handlers.has(handler.type)) {
        throw new Error(`handler already registered for type: ${handler.type}`);
      }
      handlers.set(handler.type, handler);
      return this;
    },
    addReporter(reporter) {
      if (reporters.has(reporter.name)) {
        throw new Error(`reporter already registered: ${reporter.name}`);
      }
      reporters.set(reporter.name, reporter);
      return this;
    },
    handlerFor(type) {
      return handlers.get(type) ?? null;
    },
    reporterList() {
      return [...reporters.values()];
    },
    loaded() {
      return {
        handlers: [...handlers.keys()],
        reporters: [...reporters.keys()],
      };
    },
  };
}
SMS/runner.js
// SMS — the task loop.
//
// Removing this ends the system's identity, so it is core by the identity test.
// It knows about the registry, the model, permissions, and the trace. It knows
// nothing about any specific handler or reporter.

import { runReport, taskResult } from "./model.js";
import { permits } from "../SCL/permissions.js";

export async function runTasks({ tasks, registry, trace, clock = () => Date.now() }) {
  const results = [];

  for (const task of tasks) {
    // Permission is checked BEFORE capability, and the order is load-bearing.
    //
    // The reverse order reads more naturally — why consult policy for something
    // you cannot do anyway? — but it makes the policy's answer depend on which
    // TMS happen to be loaded. `file.delete` is denied outright here; with the
    // checks reversed it reported "no handler loaded", which is true today and
    // becomes silently wrong the moment someone registers a delete handler.
    //
    // SCL is also consulted in the core, not inside each handler. A handler that
    // policed its own permissions could simply decide not to.
    const decision = permits(task);
    if (!decision.allowed) {
      trace.record("permission.denied", { id: task.id, type: task.type, reason: decision.reason });
      results.push(taskResult({ id: task.id, type: task.type, outcome: "skipped", detail: `denied: ${decision.reason}` }));
      continue;
    }

    const handler = registry.handlerFor(task.type);
    if (!handler) {
      // A missing handler is a normal outcome, not a crash: the whole premise
      // of on-demand loading is that some capabilities are absent by design.
      trace.record("handler.missing", { id: task.id, type: task.type });
      results.push(taskResult({ id: task.id, type: task.type, outcome: "skipped", detail: "no handler loaded for this type" }));
      continue;
    }

    const started = clock();
    try {
      const detail = await handler.run(task);
      results.push(taskResult({ id: task.id, type: task.type, outcome: "ok", detail, ms: clock() - started }));
      trace.record("task.ok", { id: task.id, type: task.type });
    } catch (error) {
      results.push(taskResult({ id: task.id, type: task.type, outcome: "failed", detail: error.message, ms: clock() - started }));
      trace.record("task.failed", { id: task.id, type: task.type, error: error.message });
    }
  }

  return runReport(results);
}

TMS

TMS/handlers/file-read.js
// TMS — one handler, for one task type.
//
// Imports only from SMS. It does not know that an http handler exists, and
// nothing breaks if that handler is absent.

import { defineHandler } from "../../SMS/model.js";

export function fileReadHandler({ readFile }) {
  // The tool arrives by injection rather than by importing node:fs directly.
  // That is what lets the island test run this module with a stub and no disk.
  return defineHandler({
    type: "file.read",
    async run(task) {
      const text = await readFile(task.path);
      return `${task.path}: ${text.length} chars`;
    },
  });
}
TMS/handlers/http-get.js
// TMS — a second handler, structurally parallel to the first and unaware of it.

import { defineHandler } from "../../SMS/model.js";

export function httpGetHandler({ fetchUrl }) {
  return defineHandler({
    type: "http.get",
    async run(task) {
      const { status, bytes } = await fetchUrl(task.url);
      if (status >= 400) throw new Error(`HTTP ${status}`);
      return `${task.url}: ${status}, ${bytes} bytes`;
    },
  });
}
TMS/reporters/json.js
// TMS — machine-readable report.
//
// The other half of the point. This module wants exactly what text.js wants:
// counts, totals, the slowest task. The tempting line is
//
//     import { summarise } from "./text.js";
//
// which would work, and would quietly make text.js a dependency of json.js —
// so the island test for json could never run without text present, and
// deleting text would break a module that has nothing to do with it.
//
// It reads the SMS-owned report instead. Neither reporter imports the other,
// and scripts/build-mssp.mjs greps for sibling-TMS imports to keep it that way.

import { defineReporter } from "../../SMS/model.js";

export function jsonReporter() {
  return defineReporter({
    name: "json",
    render(report) {
      return JSON.stringify(
        {
          passed: report.passed,
          counts: report.counts,
          totalMs: report.totalMs,
          slowest: report.slowest && { id: report.slowest.id, ms: report.slowest.ms },
          results: report.results,
        },
        null,
        2,
      );
    },
  });
}
TMS/reporters/text.js
// TMS — human-readable report.
//
// This is one half of the example's point. Both reporters need per-task
// duration, the counts, and which task was slowest. None of that is computed
// here: it is read from the report SMS built.

import { defineReporter } from "../../SMS/model.js";

export function textReporter() {
  return defineReporter({
    name: "text",
    render(report) {
      const lines = report.results.map(
        (r) => `  ${r.outcome.padEnd(7)} ${r.id.padEnd(14)} ${String(r.ms).padStart(4)}ms  ${r.detail}`,
      );
      const { ok, failed, skipped } = report.counts;
      return [
        "RUN REPORT",
        ...lines,
        "",
        `  ${ok} ok, ${failed} failed, ${skipped} skipped in ${report.totalMs}ms`,
        report.slowest ? `  slowest: ${report.slowest.id} (${report.slowest.ms}ms)` : "  slowest: n/a",
        `  result: ${report.passed ? "PASS" : "FAIL"}`,
      ].join("\n");
    },
  });
}

DMS

DMS/trace.js
// DMS — what happened, and what a human can see about it.
//
// Separate from the reporters on purpose. A reporter renders the *result*; the
// trace records the *run* — including the parts that never reach a result, like
// a task skipped because no handler was loaded or a permission was refused.
// Without it, "2 skipped" is a number with no explanation attached.

export function createTrace() {
  const events = [];
  return {
    record(event, data = {}) {
      events.push({ event, ...data });
    },
    /** Machine view: for tests and tooling. */
    events: () => [...events],
    /** Human view: the sentences a person needs to trust the run. */
    humanView(registry) {
      const loaded = registry.loaded();
      const denied = events.filter((e) => e.event === "permission.denied");
      const missing = events.filter((e) => e.event === "handler.missing");
      const lines = [
        `Loaded handlers: ${loaded.handlers.join(", ") || "none"}`,
        `Loaded reporters: ${loaded.reporters.join(", ") || "none"}`,
      ];
      for (const e of missing) lines.push(`Skipped ${e.id}: no handler for "${e.type}" was loaded.`);
      for (const e of denied) lines.push(`Refused ${e.id}: ${e.reason}.`);
      if (!missing.length && !denied.length) lines.push("Nothing was skipped or refused.");
      return lines.join("\n");
    },
  };
}

root

island-test.js
// The island test, as an executable file rather than a claim.
//
// Builds a system with the minimal SMS and EXACTLY ONE TMS, stubs every tool,
// runs that module's representative task, and asserts that nothing else was
// loaded. Run: node src/island-test.js

import assert from "node:assert/strict";
import { createRegistry } from "./SMS/registry.js";
import { runTasks } from "./SMS/runner.js";
import { createTrace } from "./DMS/trace.js";
import { jsonReporter } from "./TMS/reporters/json.js";
import { fileReadHandler } from "./TMS/handlers/file-read.js";

let failures = 0;
function check(name, fn) {
  try {
    fn();
    console.log(`  [OK]   ${name}`);
  } catch (error) {
    failures += 1;
    console.log(`  [FAIL] ${name} — ${error.message}`);
  }
}

console.log("island test: TMS/reporters/json, alone");

// Only the json reporter is registered. text.js is never imported. If json had
// reached into text for its summary numbers, this file would not even load.
const registry = createRegistry().addReporter(jsonReporter());
const trace = createTrace();
let tick = 0;
const report = await runTasks({
  tasks: [{ id: "read-config", type: "file.read", path: "data/config.yaml" }],
  registry,
  trace,
  clock: () => (tick += 5),
});
const rendered = JSON.parse(registry.reporterList()[0].render(report));

check("only one TMS loaded", () => {
  const loaded = registry.loaded();
  assert.deepEqual(loaded.reporters, ["json"]);
  assert.deepEqual(loaded.handlers, []);
});
check("renders a report with no sibling TMS present", () => {
  assert.equal(typeof rendered.counts.skipped, "number");
  assert.equal(rendered.counts.skipped, 1, "no handler was loaded, so the task is skipped");
});
check("absent handler is a skip, not a crash", () => {
  assert.equal(report.results[0].outcome, "skipped");
});
check("DMS explains the skip", () => {
  assert.match(trace.humanView(registry), /no handler for "file\.read"/);
});

console.log("\nisland test: TMS/handlers/file-read, alone");

const handlerOnly = createRegistry().addHandler(fileReadHandler({ readFile: async () => "abc" }));
const handlerTrace = createTrace();
let tick2 = 0;
const handlerReport = await runTasks({
  tasks: [
    { id: "allowed", type: "file.read", path: "data/ok.txt" },
    { id: "escaping", type: "file.read", path: "data/../../secret" },
  ],
  registry: handlerOnly,
  trace: handlerTrace,
  clock: () => (tick2 += 5),
});

check("runs with a stubbed tool and no reporter registered", () => {
  assert.equal(handlerReport.results[0].outcome, "ok");
  assert.deepEqual(handlerOnly.loaded().reporters, []);
});
check("SCL refuses the traversal path, and the core does the refusing", () => {
  assert.equal(handlerReport.results[1].outcome, "skipped");
  assert.match(handlerReport.results[1].detail, /denied: path traversal/);
});

console.log(failures ? `\n${failures} check(s) failed` : "\nall island checks passed");
process.exit(failures ? 1 : 0);
main.js
// Entry point. The only file that knows which TMS exist — which is what lets
// every other file stay unaware of them.

import { createRegistry } from "./SMS/registry.js";
import { runTasks } from "./SMS/runner.js";
import { createTrace } from "./DMS/trace.js";
import { fileReadHandler } from "./TMS/handlers/file-read.js";
import { httpGetHandler } from "./TMS/handlers/http-get.js";
import { textReporter } from "./TMS/reporters/text.js";
import { jsonReporter } from "./TMS/reporters/json.js";

// Tools are stubbed so the example runs anywhere with no network and no disk
// layout. Real implementations would be injected the same way.
const tools = {
  readFile: async (path) => `<contents of ${path}>`.repeat(3),
  fetchUrl: async (url) => ({ status: url.includes("/missing") ? 404 : 200, bytes: 1024 }),
};

const tasks = [
  { id: "read-config", type: "file.read", path: "data/config.yaml" },
  { id: "read-escape", type: "file.read", path: "../../etc/passwd" },
  { id: "fetch-home", type: "http.get", url: "https://example.org/" },
  { id: "fetch-missing", type: "http.get", url: "https://example.org/missing" },
  { id: "fetch-elsewhere", type: "http.get", url: "https://not-allowed.test/" },
  { id: "wipe-data", type: "file.delete", path: "data/" },
  { id: "send-mail", type: "mail.send", to: "someone@example.org" },
];

const trace = createTrace();
const registry = createRegistry()
  .addHandler(fileReadHandler(tools))
  .addHandler(httpGetHandler(tools))
  .addReporter(textReporter())
  .addReporter(jsonReporter());

// A monotonic fake clock keeps the printed durations identical on every run, so
// the output in the README stays true and a diff means something changed.
let tick = 0;
const clock = () => (tick += 5);

const report = await runTasks({ tasks, registry, trace, clock });

for (const reporter of registry.reporterList()) {
  console.log(`\n--- ${reporter.name} ---`);
  console.log(reporter.render(report));
}

console.log("\n--- DMS human view ---");
console.log(trace.humanView(registry));

// `wipe-data` and `send-mail` are both absent from the result for different
// reasons: one is denied by SCL, the other has no handler loaded. The trace is
// where that distinction survives.
process.exit(report.passed ? 0 : 0); // reporting demo, not a CI gate