mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 12:54:03 +00:00
* OpenFleet reference implementation: @logicsrc/openfleet 0.1.0 and logicsrc fleet Ship what docs/openfleet.md describes. The new workspace package holds the record (write once, never overwrite, 0600), the ledger (append-only JSON Lines, merged across ledger*.jsonl by at), the ceiling rules (whole fleet ceiling, narrowed swarm keys, a merge that never widens, refusals by key), claiming and deriving exactly as the spec's "Claiming and deriving" and rule 13, and fold(), which turns any $OPENFLEET_HOME plus the engine rosters into the tree the landing page shows. logicsrc fleet open|cap|tree|stop|log are the sysop's verbs, every one with --json. open and cap exit 4 when OPENFLEET_MEMBER is set; stop exits 4 outside the caller's subtree, ends nested swarms first, goes through each member's own engine (claude stop, moshcode herd kill, tmux kill-pane, a signal for claude-p) and writes one swarm.end per swarm. tree reads claude agents --json --all and ~/.moshcode/herd/sessions.json when it can, draws recordless sessions as roster roots of the implicit fleet, and writes member.end lost for a recorded member its engine no longer lists. Claude Code takes part through hooks: logicsrc fleet hooks install merges SessionStart, UserPromptSubmit, PreToolUse, Stop and SessionEnd into ~/.claude/settings.json without clobbering it, and logicsrc fleet hook <Event> runs each one. SessionStart claims, derives or writes a root record and hands the member its variables through CLAUDE_ENV_FILE; UserPromptSubmit checks the ceiling with the permission mode the engine reports and writes member.start, or refuses the first prompt with exit 2 and ceiling.refuse; PreToolUse denies an edit outside piece.owns; Stop and SessionEnd write member.end. A hand-started root takes the engine's reported approvals before member.start, since the command line only guesses them. Hooks never fail the engine: everything is caught and logged to hooks.log. The spec and the landing page now say what ships, keep Status 0.1, and record the two verified Claude Code limits: a background job dispatched from claude agents gets no launcher environment, and OPENFLEET_* exported at SessionStart reach the member's tools but not later hooks, so hooks key on session_id through $OPENFLEET_HOME/sessions/<session_id>.json. PRD 0008 covers the work. CLI 0.2.1 -> 0.3.0; build and build:cli chains build the package before the CLI; README and docs/cli.md list the group. Tests: 95 in the package (record, ledger merge, every narrower case, the worked example's claim and derive, the folded tree, hook install idempotence, each hook handler including the exit-2 refusal and the PreToolUse deny, every verb with fake deps) and 4 in the CLI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV * OpenFleet fix round: rebuild the ceiling from the ledger, once-markers, rule 6 in tree, lost only for what a roster can hold The review of the reference implementation against moshcode found the two readers disagreeing on the same files. This round applies the shared rulings so both sides read a ledger the same way. Ceiling (R-A, R-B, R-C, R1, R6, R10, R15, R17): memberCeiling rebuilds the effective ceiling from the ledger on every read. The latest fleet-target fleet.cap (else fleet.open, else the implicit fleet's) replaces the copy in a record, so a sysop's widening cap reaches running members; then each swarm.spawn narrowing down the path, then swarm caps last. In the implicit fleet a parentless record's own approvals enters at the root; a ceiling a writer left without the key is never read as native, and startMember fills it with the engine's word while the record is unclaimed. A fleet.open or cap with no hosts means the host it was written on (R23). Once-markers (R-G, R28): member.start, member.end and swarm.end each take an exclusive create under fleets/<fleet>/marks/<event>.<id> before the append; a lost end takes <id>.lost so a real end can still supersede it. The hooks let a real end follow a lost line (R9). tree (R-F, R20): run by the sysop it enforces rule 6, stopping a member past its effective until with state timeout and the members of a swarm or fleet at its budget with state budget, then writes swarm.end for each swarm touched once it is complete. An agent's tree stops nothing. lost is written only for a member its engine's roster can hold: a claude-code background job (8-hex member or session) or a moshcode pane, never an interactive session claude agents does not list (R-E, R3, R14). A nested swarm is drawn under the member that spawned it and its row shows the effective ceiling (R25). stop and cap (R-D, R-H, R22, R27): swarm.end is written only once every member and every nested swarm has an end line that counts; an engine that will not end a member leaves it without an end line and the verb exits non-zero. claude stop takes the job id: the member of a background job, else the first eight characters of a session UUID; an interactive session with no job id cannot be stopped and the tool says so. cap on a swarm refuses a key that would widen. A derived claude-code job is named by its job id and carries no pid. Also: R-I (endMember ends only the engine-minted swarm of one), R35 (a derived record's guessed approvals corrected at UserPromptSubmit), R32 (the UserPromptSubmit hook passes only exit 2 through), R31 (package README), R36 (rule 13 says the launcher test is unimplemented in 0.1), docs and PRD 0008 updated for lost, rule 6 and the markers. 113 openfleet tests, 93 CLI tests, contract green. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV * openfleet hooks: no member.end for a member that never started A first prompt refused by the ceiling still lets the session wind down through Stop and SessionEnd; those handlers now write nothing when the ledger holds no member.start for the member, so a refused member is never drawn as done. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV * logicsrc-mcp test: the next free PRD id is 0009 now that PRD 0008 exists Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RZV4zJ2pDZLNN3kE5jFCmV --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
195 lines
9.4 KiB
TypeScript
195 lines
9.4 KiB
TypeScript
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import {
|
|
RecordExistsError,
|
|
append,
|
|
appendOnce,
|
|
claimMark,
|
|
claimedBy,
|
|
endOf,
|
|
hasEvent,
|
|
hasMark,
|
|
home,
|
|
implicitFleet,
|
|
ledgerPaths,
|
|
listFleets,
|
|
markName,
|
|
readCurrent,
|
|
readLedger,
|
|
readRecord,
|
|
readRecords,
|
|
readSession,
|
|
recordPath,
|
|
swarmEndState,
|
|
writeCurrent,
|
|
writeRecord,
|
|
writeSession,
|
|
} from "./store.js";
|
|
import { FLEET, ROOT, cleanup, snapshotEnv, tempHome } from "./test-helpers.js";
|
|
|
|
describe("home and the implicit fleet", () => {
|
|
let restore: () => void;
|
|
beforeEach(() => {
|
|
restore = snapshotEnv();
|
|
});
|
|
afterEach(() => restore());
|
|
|
|
it("is $OPENFLEET_HOME, else ~/.openfleet", () => {
|
|
expect(home({ OPENFLEET_HOME: "/x/fleet" })).toBe("/x/fleet");
|
|
expect(home({ HOME: "/home/someone" })).toBe("/home/someone/.openfleet");
|
|
expect(home({ OPENFLEET_HOME: " ", HOME: "/home/someone" })).toBe("/home/someone/.openfleet");
|
|
});
|
|
|
|
it("names the implicit fleet <user>@<host> with depth 1, that host, and no approvals", () => {
|
|
const fleet = implicitFleet({ user: "anthony", host: "dev" });
|
|
expect(fleet.id).toBe("anthony@dev");
|
|
expect(fleet.sysop).toBe("anthony@dev");
|
|
expect(fleet.ceiling).toEqual({ depth: 1, hosts: ["dev"] });
|
|
expect("approvals" in fleet.ceiling).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("the record", () => {
|
|
let dir: string;
|
|
beforeEach(() => {
|
|
dir = tempHome();
|
|
});
|
|
afterEach(() => cleanup(dir));
|
|
|
|
it("writes once under fleets/<fleet>/members/<member>.json, 0600 in 0700 dirs, and reads back with unknown keys kept", () => {
|
|
const path = writeRecord(dir, { ...ROOT, extra: { kept: true } });
|
|
expect(path).toBe(recordPath(dir, FLEET, "460a4502"));
|
|
expect(path).toBe(join(dir, "fleets", FLEET, "members", "460a4502.json"));
|
|
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
expect(statSync(join(dir, "fleets", FLEET, "members")).mode & 0o777).toBe(0o700);
|
|
expect(readRecord(path)).toEqual({ ...ROOT, extra: { kept: true } });
|
|
});
|
|
|
|
it("refuses to overwrite: a record never changes after it is written", () => {
|
|
writeRecord(dir, ROOT);
|
|
expect(() => writeRecord(dir, { ...ROOT, approvals: "native" })).toThrow(RecordExistsError);
|
|
expect(readRecord(recordPath(dir, FLEET, ROOT.member))?.approvals).toBe("bypass");
|
|
});
|
|
|
|
it("returns null for a missing record, a non-JSON file, or an object without fleet and member", () => {
|
|
expect(readRecord(join(dir, "nope.json"))).toBeNull();
|
|
mkdirSync(dir, { recursive: true });
|
|
writeFileSync(join(dir, "bad.json"), "{not json");
|
|
writeFileSync(join(dir, "thin.json"), JSON.stringify({ openfleet: "0.1" }));
|
|
expect(readRecord(join(dir, "bad.json"))).toBeNull();
|
|
expect(readRecord(join(dir, "thin.json"))).toBeNull();
|
|
});
|
|
|
|
it("lists every record of a fleet by member id, and every fleet with a directory", () => {
|
|
writeRecord(dir, ROOT);
|
|
writeRecord(dir, { ...ROOT, member: "b" });
|
|
expect([...readRecords(dir, FLEET).keys()].sort()).toEqual(["460a4502", "b"]);
|
|
expect(listFleets(dir)).toEqual([FLEET]);
|
|
expect(listFleets(join(dir, "missing"))).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("the ledger", () => {
|
|
let dir: string;
|
|
beforeEach(() => {
|
|
dir = tempHome();
|
|
});
|
|
afterEach(() => cleanup(dir));
|
|
|
|
it("appends one JSON line with at, event, fleet, host and by, in that order, at mode 0600", () => {
|
|
const line = append(dir, FLEET, { event: "fleet.open", by: "sysop", sysop: FLEET, ceiling: { depth: 2 } }, { now: new Date("2026-09-13T05:41:01.500Z"), host: "dev" });
|
|
expect(line).toEqual({ at: "2026-09-13T05:41:01Z", event: "fleet.open", fleet: FLEET, host: "dev", by: "sysop", sysop: FLEET, ceiling: { depth: 2 } });
|
|
const path = join(dir, "fleets", FLEET, "ledger.jsonl");
|
|
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
expect(readFileSync(path, "utf8")).toBe(`${JSON.stringify(line)}\n`);
|
|
expect(Object.keys(JSON.parse(readFileSync(path, "utf8")))).toEqual(["at", "event", "fleet", "host", "by", "sysop", "ceiling"]);
|
|
});
|
|
|
|
it("refuses a line with no by or no event: who did it is never guessed", () => {
|
|
expect(() => append(dir, FLEET, { event: "member.end", by: "" })).toThrow(/by/);
|
|
expect(() => append(dir, FLEET, { event: "", by: "sysop" })).toThrow(/event/);
|
|
});
|
|
|
|
it("merges every ledger*.jsonl under a fleet, sorted by at, keeping file order on ties and skipping bad lines", () => {
|
|
append(dir, FLEET, { at: "2026-09-13T05:41:12Z", event: "member.start", by: "a", member: "a" }, { host: "dev" });
|
|
append(dir, FLEET, { at: "2026-09-13T05:41:36Z", event: "member.end", by: "a", member: "a", state: "done" }, { host: "dev" });
|
|
const remote = join(dir, "fleets", FLEET, "ledger.netcup.jsonl");
|
|
writeFileSync(
|
|
remote,
|
|
[
|
|
JSON.stringify({ at: "2026-09-13T05:41:20Z", event: "member.start", fleet: FLEET, host: "netcup", by: "b", member: "b" }),
|
|
"this is not json",
|
|
JSON.stringify({ at: "2026-09-13T05:41:36Z", event: "member.end", fleet: FLEET, host: "netcup", by: "b", member: "b", state: "done" }),
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
expect(ledgerPaths(dir, FLEET).map((path) => path.split("/").pop())).toEqual(["ledger.jsonl", "ledger.netcup.jsonl"]);
|
|
const lines = readLedger(dir, FLEET);
|
|
expect(lines.map((line) => `${line.at} ${line.event} ${line.by}`)).toEqual([
|
|
"2026-09-13T05:41:12Z member.start a",
|
|
"2026-09-13T05:41:20Z member.start b",
|
|
"2026-09-13T05:41:36Z member.end a",
|
|
"2026-09-13T05:41:36Z member.end b",
|
|
]);
|
|
expect(readLedger(dir, "no-such-fleet")).toEqual([]);
|
|
});
|
|
|
|
it("answers claimed, ended and hasEvent from the lines", () => {
|
|
append(dir, FLEET, { at: "2026-09-13T05:41:12Z", event: "member.start", by: "a", member: "a", session: "s1" }, { host: "dev" });
|
|
const lines = readLedger(dir, FLEET);
|
|
expect(claimedBy(lines, "a")?.session).toBe("s1");
|
|
expect(claimedBy(lines, "b")).toBeNull();
|
|
expect(hasEvent(lines, "member.start", { member: "a" })).toBe(true);
|
|
expect(hasEvent(lines, "member.end", { member: "a" })).toBe(false);
|
|
expect(endOf(lines, "a")).toBeNull();
|
|
});
|
|
|
|
it("counts the first end line, except lost, which a real end supersedes", () => {
|
|
append(dir, FLEET, { at: "2026-09-13T05:42:00Z", event: "member.end", by: "sysop", member: "a", state: "lost" }, { host: "dev" });
|
|
expect(endOf(readLedger(dir, FLEET), "a")?.state).toBe("lost");
|
|
append(dir, FLEET, { at: "2026-09-13T05:43:00Z", event: "member.end", by: "a", member: "a", state: "done" }, { host: "dev" });
|
|
expect(endOf(readLedger(dir, FLEET), "a")?.state).toBe("done");
|
|
append(dir, FLEET, { at: "2026-09-13T05:44:00Z", event: "member.end", by: "sysop", member: "a", state: "stopped" }, { host: "dev" });
|
|
expect(endOf(readLedger(dir, FLEET), "a")?.state).toBe("done");
|
|
});
|
|
|
|
it("derives a swarm's end state: done when all done, else the first of failed, stopped, budget, timeout", () => {
|
|
const end = (state: string) => ({ at: "", event: "member.end", fleet: FLEET, host: "dev", by: "x", state });
|
|
expect(swarmEndState([end("done"), end("done")])).toBe("done");
|
|
expect(swarmEndState([end("done"), end("timeout"), end("stopped")])).toBe("stopped");
|
|
expect(swarmEndState([end("budget"), end("failed")])).toBe("failed");
|
|
expect(swarmEndState([end("done"), end("timeout")])).toBe("timeout");
|
|
expect(swarmEndState([end("done"), null])).toBeNull();
|
|
expect(swarmEndState([end("lost")])).toBe("failed");
|
|
});
|
|
|
|
it("takes a once-marker with an exclusive create, so a racing second writer writes nothing", () => {
|
|
expect(markName("member.start", "a")).toBe("member.start.a");
|
|
expect(markName("member.end", "a", true)).toBe("member.end.a.lost");
|
|
expect(markName("swarm.end", "s-1")).toBe("swarm.end.s-1");
|
|
expect(hasMark(dir, FLEET, "member.start.a")).toBe(false);
|
|
expect(claimMark(dir, FLEET, "member.start.a")).toBe(true);
|
|
expect(claimMark(dir, FLEET, "member.start.a")).toBe(false);
|
|
expect(hasMark(dir, FLEET, "member.start.a")).toBe(true);
|
|
const path = join(dir, "fleets", FLEET, "marks", "member.start.a");
|
|
expect(statSync(path).mode & 0o777).toBe(0o600);
|
|
expect(statSync(join(dir, "fleets", FLEET, "marks")).mode & 0o777).toBe(0o700);
|
|
const first = appendOnce(dir, FLEET, { at: "2026-09-13T05:41:36Z", event: "member.end", by: "a", member: "a", state: "done" }, { host: "dev", once: markName("member.end", "a") });
|
|
expect(first?.state).toBe("done");
|
|
const second = appendOnce(dir, FLEET, { at: "2026-09-13T05:41:37Z", event: "member.end", by: "sysop", member: "a", state: "stopped" }, { host: "dev", once: markName("member.end", "a") });
|
|
expect(second).toBeNull();
|
|
expect(readLedger(dir, FLEET).filter((line) => line.event === "member.end").length).toBe(1);
|
|
});
|
|
|
|
it("keeps current and the per-session file under the home", () => {
|
|
expect(readCurrent(dir)).toBeNull();
|
|
writeCurrent(dir, "fleet-20260913");
|
|
expect(readCurrent(dir)).toBe("fleet-20260913");
|
|
expect(readSession(dir, "abc")).toBeNull();
|
|
writeSession(dir, "abc", { record: null, recordPath: null, member: null, fleet: FLEET, swarm: null, last_message: "hi" });
|
|
expect(readSession(dir, "abc")?.last_message).toBe("hi");
|
|
expect(existsSync(join(dir, "sessions", "abc.json"))).toBe(true);
|
|
expect(statSync(join(dir, "sessions", "abc.json")).mode & 0o777).toBe(0o600);
|
|
});
|
|
});
|