mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-02 04:43:58 +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>
176 lines
9.3 KiB
TypeScript
176 lines
9.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
checkCeiling,
|
|
describeRefusal,
|
|
effectiveCeiling,
|
|
fleetCeiling,
|
|
formatSpend,
|
|
isImplicitFleet,
|
|
isNarrower,
|
|
mergeCeiling,
|
|
parseBudget,
|
|
parseUntil,
|
|
rootApprovals,
|
|
sumSpend,
|
|
swarmChain,
|
|
} from "./ceiling.js";
|
|
import type { LedgerLine } from "./types.js";
|
|
|
|
const line = (partial: Partial<LedgerLine> & { event: string }): LedgerLine => ({ at: "2026-09-13T05:00:00Z", fleet: "f", host: "dev", by: "sysop", ...partial });
|
|
|
|
describe("narrower", () => {
|
|
it("approvals: native under bypass, never bypass under native or under nothing", () => {
|
|
expect(isNarrower("approvals", "native", "bypass")).toBe(true);
|
|
expect(isNarrower("approvals", "bypass", "bypass")).toBe(true);
|
|
expect(isNarrower("approvals", "native", "native")).toBe(true);
|
|
expect(isNarrower("approvals", "native", undefined)).toBe(true);
|
|
expect(isNarrower("approvals", "bypass", "native")).toBe(false);
|
|
expect(isNarrower("approvals", "bypass", undefined)).toBe(false);
|
|
});
|
|
|
|
it("depth and fan_out: smaller or equal; depth absent means 1, fan_out absent means uncapped", () => {
|
|
expect(isNarrower("depth", 1, 2)).toBe(true);
|
|
expect(isNarrower("depth", 2, 2)).toBe(true);
|
|
expect(isNarrower("depth", 3, 2)).toBe(false);
|
|
expect(isNarrower("depth", 1, undefined)).toBe(true);
|
|
expect(isNarrower("depth", 2, undefined)).toBe(false);
|
|
expect(isNarrower("fan_out", 4, 8)).toBe(true);
|
|
expect(isNarrower("fan_out", 9, 8)).toBe(false);
|
|
expect(isNarrower("fan_out", 99, undefined)).toBe(true);
|
|
});
|
|
|
|
it("budget: smaller or equal in the same unit, never across units, uncapped when absent", () => {
|
|
expect(isNarrower("budget", "10 USD", "20 USD")).toBe(true);
|
|
expect(isNarrower("budget", "20 USD", "20 USD")).toBe(true);
|
|
expect(isNarrower("budget", "30 USD", "20 USD")).toBe(false);
|
|
expect(isNarrower("budget", "10 USD", "500000 tokens")).toBe(false);
|
|
expect(isNarrower("budget", "10 USD", undefined)).toBe(true);
|
|
expect(isNarrower("budget", "lots", "20 USD")).toBe(false);
|
|
});
|
|
|
|
it("hosts: a subset; until: earlier or equal", () => {
|
|
expect(isNarrower("hosts", ["dev"], ["dev", "netcup"])).toBe(true);
|
|
expect(isNarrower("hosts", ["dev", "netcup"], ["dev"])).toBe(false);
|
|
expect(isNarrower("hosts", ["dev"], undefined)).toBe(true);
|
|
expect(isNarrower("until", "2026-09-13T06:00:00Z", "2026-09-13T06:11:01Z")).toBe(true);
|
|
expect(isNarrower("until", "2026-09-13T06:11:01Z", "2026-09-13T06:11:01Z")).toBe(true);
|
|
expect(isNarrower("until", "2026-09-13T07:00:00Z", "2026-09-13T06:11:01Z")).toBe(false);
|
|
expect(isNarrower("until", "2026-09-13T07:00:00Z", undefined)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("merge", () => {
|
|
const base = { approvals: "bypass" as const, depth: 2, fan_out: 8, hosts: ["dev", "netcup"], budget: "20 USD", until: "2026-09-13T08:00:00Z" };
|
|
|
|
it("takes every narrowed key and inherits the rest", () => {
|
|
expect(mergeCeiling(base, { fan_out: 4, until: "2026-09-13T06:11:01Z" })).toEqual({ ...base, fan_out: 4, until: "2026-09-13T06:11:01Z" });
|
|
expect(mergeCeiling(base, undefined)).toEqual(base);
|
|
});
|
|
|
|
it("never widens: a key a spawner tried to widen keeps the base value", () => {
|
|
expect(mergeCeiling({ approvals: "native", depth: 1 }, { approvals: "bypass", depth: 3, fan_out: 2 })).toEqual({ approvals: "native", depth: 1, fan_out: 2 });
|
|
expect(mergeCeiling(base, { hosts: ["dev", "netcup", "other"], budget: "500 USD" })).toEqual(base);
|
|
});
|
|
|
|
it("copies unknown keys through", () => {
|
|
expect(mergeCeiling({ depth: 1 }, { scopes: ["fleet:read"] })).toEqual({ depth: 1, scopes: ["fleet:read"] });
|
|
});
|
|
});
|
|
|
|
describe("the fleet's ceiling and the path down", () => {
|
|
const implicit = { depth: 1, hosts: ["dev"] };
|
|
|
|
it("is the implicit one with no lines, fleet.open once opened, and the latest fleet.cap for the fleet after that", () => {
|
|
expect(fleetCeiling([], "f", implicit)).toEqual(implicit);
|
|
expect(isImplicitFleet([], "f")).toBe(true);
|
|
const opened = [line({ event: "fleet.open", fleet: "f", ceiling: { approvals: "bypass", depth: 2 } })];
|
|
// hosts absent means the host the line was written on.
|
|
expect(fleetCeiling(opened, "f", implicit)).toEqual({ approvals: "bypass", depth: 2, hosts: ["dev"] });
|
|
expect(isImplicitFleet(opened, "f")).toBe(false);
|
|
const capped = [
|
|
...opened,
|
|
line({ event: "fleet.cap", target: "f", ceiling: { approvals: "native", depth: 1 } }),
|
|
line({ event: "fleet.cap", target: "some-swarm", ceiling: { depth: 0 } }),
|
|
line({ event: "fleet.cap", target: "f", ceiling: { approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] } }),
|
|
];
|
|
expect(fleetCeiling(capped, "f", implicit)).toEqual({ approvals: "bypass", depth: 3, hosts: ["dev", "netcup"] });
|
|
});
|
|
|
|
it("a fleet opened with an empty ceiling admits members on its own host only", () => {
|
|
const opened = [line({ event: "fleet.open", fleet: "f", host: "dev", ceiling: {} })];
|
|
const ceiling = fleetCeiling(opened, "f", implicit);
|
|
expect(ceiling).toEqual({ hosts: ["dev"] });
|
|
expect(checkCeiling({ hosts: ["dev"] }, ceiling)).toBeNull();
|
|
expect(checkCeiling({ hosts: ["netcup"] }, ceiling)).toEqual({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] });
|
|
});
|
|
|
|
it("follows parent_swarm to the top and merges spawn narrowings first, then swarm caps last", () => {
|
|
const lines = [
|
|
line({ event: "swarm.spawn", by: "root", swarm: "outer", ceiling: { fan_out: 4, until: "2026-09-13T07:00:00Z" } }),
|
|
line({ event: "swarm.spawn", by: "outer-1", swarm: "inner", parent_swarm: "outer", ceiling: { fan_out: 2, depth: 9 } }),
|
|
line({ event: "fleet.cap", target: "outer", ceiling: { until: "2026-09-13T06:00:00Z" } }),
|
|
];
|
|
expect(swarmChain(lines, "inner")).toEqual(["outer", "inner"]);
|
|
expect(swarmChain(lines, undefined)).toEqual([]);
|
|
const base = { approvals: "bypass" as const, depth: 2, hosts: ["dev"] };
|
|
expect(effectiveCeiling(base, lines, swarmChain(lines, "inner"))).toEqual({ approvals: "bypass", depth: 2, hosts: ["dev"], fan_out: 2, until: "2026-09-13T06:00:00Z" });
|
|
});
|
|
|
|
it("a root supplies its approvals to its subtree in the implicit fleet, native when orphan", () => {
|
|
expect(rootApprovals({ approvals: "bypass" })).toBe("bypass");
|
|
expect(rootApprovals({ approvals: "bypass", orphan: true })).toBe("native");
|
|
expect(rootApprovals({})).toBe("native");
|
|
expect(rootApprovals(null)).toBe("native");
|
|
});
|
|
});
|
|
|
|
describe("checkCeiling", () => {
|
|
const allowed = { approvals: "bypass" as const, depth: 1, fan_out: 4, hosts: ["dev"], until: "2026-09-13T06:11:01Z" };
|
|
|
|
it("passes what is within", () => {
|
|
expect(checkCeiling({ approvals: "bypass", depth: 1, fan_out: 2, hosts: ["dev"], until: "2026-09-13T05:41:12Z" }, allowed)).toBeNull();
|
|
expect(checkCeiling({}, allowed)).toBeNull();
|
|
});
|
|
|
|
it("names the first key exceeded, in the order approvals, depth, fan_out, hosts, until", () => {
|
|
expect(checkCeiling({ approvals: "bypass" }, { approvals: "native" })).toEqual({ key: "approvals", wanted: "bypass", allowed: "native" });
|
|
expect(checkCeiling({ approvals: "bypass" }, {})).toEqual({ key: "approvals", wanted: "bypass", allowed: "native" });
|
|
expect(checkCeiling({ depth: 2 }, allowed)).toEqual({ key: "depth", wanted: 2, allowed: 1 });
|
|
expect(checkCeiling({ depth: 2 }, {})).toEqual({ key: "depth", wanted: 2, allowed: 1 });
|
|
expect(checkCeiling({ fan_out: 5 }, allowed)).toEqual({ key: "fan_out", wanted: 5, allowed: 4 });
|
|
expect(checkCeiling({ hosts: ["netcup"] }, allowed)).toEqual({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] });
|
|
expect(checkCeiling({ until: "2026-09-13T06:30:00Z" }, allowed)).toEqual({ key: "until", wanted: "2026-09-13T06:30:00Z", allowed: "2026-09-13T06:11:01Z" });
|
|
expect(checkCeiling({ approvals: "bypass", depth: 5 }, { approvals: "native", depth: 1 })?.key).toBe("approvals");
|
|
});
|
|
|
|
it("describes a refusal for a human", () => {
|
|
expect(describeRefusal({ key: "hosts", wanted: ["netcup"], allowed: ["dev"] })).toBe("ceiling refuses hosts: wanted netcup, allowed dev");
|
|
expect(describeRefusal({ key: "fan_out", wanted: 5, allowed: null })).toBe("ceiling refuses fan_out: wanted 5, allowed none");
|
|
});
|
|
});
|
|
|
|
describe("budgets, deadlines and spend", () => {
|
|
it("parses <amount> <unit>", () => {
|
|
expect(parseBudget("20 USD")).toEqual({ amount: 20, unit: "USD" });
|
|
expect(parseBudget("1500000 tokens")).toEqual({ amount: 1500000, unit: "tokens" });
|
|
expect(parseBudget("0.5 eip155:1/slip44:60")).toEqual({ amount: 0.5, unit: "eip155:1/slip44:60" });
|
|
expect(parseBudget("20")).toBeNull();
|
|
expect(parseBudget(undefined)).toBeNull();
|
|
});
|
|
|
|
it("reads --until as a duration from now or an ISO time", () => {
|
|
const now = new Date("2026-09-13T05:41:01Z");
|
|
expect(parseUntil("2h", now)).toBe("2026-09-13T07:41:01Z");
|
|
expect(parseUntil("30m", now)).toBe("2026-09-13T06:11:01Z");
|
|
expect(parseUntil("2026-09-13T06:11:01Z", now)).toBe("2026-09-13T06:11:01Z");
|
|
expect(parseUntil("soon", now)).toBeNull();
|
|
});
|
|
|
|
it("sums spend per unit and shows it against the budget's unit", () => {
|
|
const sums = sumSpend(["10 USD", "5 USD", "1000 tokens", undefined, "junk"]);
|
|
expect(sums).toEqual({ USD: 15, tokens: 1000 });
|
|
expect(formatSpend(sums, "20 USD")).toBe("15/20 USD");
|
|
expect(formatSpend(sums)).toBe("15 USD, 1000 tokens");
|
|
expect(formatSpend({})).toBe("");
|
|
});
|
|
});
|