mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-10-03 05:07:10 +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>
470 lines
19 KiB
TypeScript
470 lines
19 KiB
TypeScript
/**
|
|
* Where a process sits, and how a session claims or derives its record.
|
|
*
|
|
* `context` answers "which fleet, which member" from the environment the way
|
|
* the spec orders it: the record `OPENFLEET_RECORD` names, else the fleet
|
|
* `OPENFLEET_FLEET` names, else `$OPENFLEET_HOME/current`, else the implicit
|
|
* fleet. `claimOrDerive` is the engine-side rule from "Claiming and deriving"
|
|
* and rule 13, written once so the Claude Code hooks and any other starter
|
|
* make the same decision from the same ledger.
|
|
*/
|
|
|
|
import { checkCeiling, effectiveCeiling, fleetCeiling, isImplicitFleet, rootApprovals, swarmChain } from "./ceiling.js";
|
|
import { nextSwarmOfOne } from "./swarm.js";
|
|
import {
|
|
append,
|
|
appendOnce,
|
|
claimedBy,
|
|
endOf,
|
|
findEvents,
|
|
hasMark,
|
|
home as homeOf,
|
|
implicitFleet,
|
|
isoNow,
|
|
markName,
|
|
readCurrent,
|
|
readLedger,
|
|
readRecord,
|
|
recordPath,
|
|
replaceUnclaimedRecord,
|
|
spawnOf,
|
|
swarmEndOf,
|
|
writeRecord,
|
|
type Env,
|
|
type ImplicitFleet,
|
|
} from "./store.js";
|
|
import type { Approvals, Ceiling, EndState, FleetRecord, LedgerInput, LedgerLine, Refusal } from "./types.js";
|
|
import { OPENFLEET_VERSION } from "./types.js";
|
|
|
|
export interface Context {
|
|
home: string;
|
|
fleet: string;
|
|
sysop: string;
|
|
/** No `fleet.open` in the ledger: the fleet is the implicit `<user>@<host>`. */
|
|
implicit: boolean;
|
|
record: FleetRecord | null;
|
|
recordPath: string | null;
|
|
/** `OPENFLEET_MEMBER`, else the record's member. Present means this process is an agent. */
|
|
member: string | null;
|
|
swarm: string | null;
|
|
host: string;
|
|
ceiling: Ceiling;
|
|
}
|
|
|
|
export interface ContextOptions {
|
|
implicit?: ImplicitFleet;
|
|
host?: string;
|
|
}
|
|
|
|
/** The sysop an opened fleet's `fleet.open` names, or null for the implicit fleet. */
|
|
export function fleetSysop(lines: LedgerLine[], fleet: string): string | null {
|
|
const opens = findEvents(lines, "fleet.open", { fleet });
|
|
const sysop = opens.length ? opens[opens.length - 1].sysop : undefined;
|
|
return typeof sysop === "string" ? sysop : null;
|
|
}
|
|
|
|
export function context(env: Env = process.env, opts: ContextOptions = {}): Context {
|
|
const implicit = opts.implicit ?? implicitFleet();
|
|
const host = opts.host ?? implicit.host;
|
|
const home = homeOf(env);
|
|
let record: FleetRecord | null = null;
|
|
let path: string | null = null;
|
|
if (env.OPENFLEET_RECORD) {
|
|
record = readRecord(env.OPENFLEET_RECORD);
|
|
if (record) path = env.OPENFLEET_RECORD;
|
|
}
|
|
const fleet = record?.fleet ?? env.OPENFLEET_FLEET ?? readCurrent(home) ?? implicit.id;
|
|
const lines = readLedger(home, fleet);
|
|
const implicitHere = isImplicitFleet(lines, fleet);
|
|
const sysop = record?.sysop ?? fleetSysop(lines, fleet) ?? implicit.sysop;
|
|
const member = env.OPENFLEET_MEMBER ?? record?.member ?? null;
|
|
const swarm = env.OPENFLEET_SWARM ?? record?.swarm ?? null;
|
|
const ceiling = record ? memberCeiling(home, lines, record, implicit) : fleetCeiling(lines, fleet, implicit.ceiling);
|
|
return { home, fleet, sysop, implicit: implicitHere, record, recordPath: path, member, swarm, host, ceiling };
|
|
}
|
|
|
|
/**
|
|
* The root above a record, following `parent` through record files, then
|
|
* `member.start` lines when a record is missing (it may live on another host).
|
|
* Null when the chain breaks, which reads as approvals native.
|
|
*/
|
|
export function rootOf(homeDir: string, lines: LedgerLine[], record: FleetRecord): { approvals?: Approvals; orphan?: boolean; member: string } | null {
|
|
let current: { approvals?: Approvals; orphan?: boolean; member: string; parent?: string } = record;
|
|
const seen = new Set<string>([record.member]);
|
|
while (current.parent) {
|
|
const parent = current.parent;
|
|
if (seen.has(parent)) return null;
|
|
seen.add(parent);
|
|
const file = readRecord(recordPath(homeDir, record.fleet, parent));
|
|
if (file) {
|
|
current = file;
|
|
continue;
|
|
}
|
|
const start = claimedBy(lines, parent);
|
|
if (!start) return null;
|
|
current = { member: parent, approvals: start.approvals, parent: start.parent };
|
|
}
|
|
return current;
|
|
}
|
|
|
|
/**
|
|
* The effective ceiling a member is under now, rebuilt from the ledger every
|
|
* time. The record's own `ceiling` is a snapshot of what it was started
|
|
* under and never an input here: the latest `fleet.cap` for its fleet wins
|
|
* over that copy (rule 7), widening included, so the sysop can raise a fleet
|
|
* and have its running members read the new ceiling. The order is the spec's:
|
|
* the fleet's whole ceiling (latest fleet-target cap, else `fleet.open`, else
|
|
* the implicit fleet's), with the root's own approvals entering at the root
|
|
* in the implicit fleet when no cap names one, then each `swarm.spawn`
|
|
* narrowing down the member's path, then the latest cap on any swarm on that
|
|
* path, applied last. A merge never widens.
|
|
*/
|
|
export function memberCeiling(homeDir: string, lines: LedgerLine[], record: FleetRecord, implicit: ImplicitFleet): Ceiling {
|
|
const base = fleetCeiling(lines, record.fleet, implicit.ceiling);
|
|
if (base.approvals === undefined && isImplicitFleet(lines, record.fleet)) base.approvals = rootApprovals(rootOf(homeDir, lines, record));
|
|
return effectiveCeiling(base, lines, swarmChain(lines, record.swarm));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Claiming and deriving
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface SessionFacts {
|
|
/** The engine's own id for this session. */
|
|
id: string;
|
|
engine: string;
|
|
cwd: string;
|
|
/** The process id, the handle a `claude -p` is stopped by. */
|
|
pid?: number;
|
|
/** The command line the session was started with; the task of a swarm of one. */
|
|
command?: string;
|
|
approvals: Approvals;
|
|
/** The member id a root record uses when it differs from `id`: a Claude Code job id. */
|
|
member?: string;
|
|
/** For a root: the engine's child marker was present in the invoking environment (rule 13). */
|
|
orphan?: boolean;
|
|
}
|
|
|
|
export interface StartOptions {
|
|
home: string;
|
|
env: Env;
|
|
now?: Date;
|
|
host?: string;
|
|
implicit?: ImplicitFleet;
|
|
session: SessionFacts;
|
|
}
|
|
|
|
export type Resolution =
|
|
| { kind: "claim"; record: FleetRecord; recordPath: string; session: string }
|
|
| { kind: "derive"; record: FleetRecord; recordPath: string; parent: FleetRecord; spawned: LedgerLine | null }
|
|
| { kind: "root"; record: FleetRecord; recordPath: string; existed: boolean }
|
|
| { kind: "refused"; refusal: Refusal; line: LedgerLine; parent: FleetRecord; member: string };
|
|
|
|
/**
|
|
* Resolve the record a starting session runs as.
|
|
*
|
|
* - `OPENFLEET_RECORD` names an unclaimed record: claim it (the caller writes
|
|
* `member.start` once it knows the approvals for certain).
|
|
* - It names a claimed record: derive a child record, joining the swarm
|
|
* `OPENFLEET_SWARM` names when its `swarm.spawn` was written by the claimed
|
|
* member, else a swarm of one. The merged ceiling is checked before anything
|
|
* is written; a refusal writes `ceiling.refuse` and nothing else.
|
|
* - It is unset: write a root record, `orphan: true` when the session's
|
|
* invoking environment carried a child marker.
|
|
*/
|
|
export function claimOrDerive(opts: StartOptions): Resolution {
|
|
const now = opts.now ?? new Date();
|
|
const implicit = opts.implicit ?? implicitFleet();
|
|
const host = opts.host ?? implicit.host;
|
|
const { home, env, session } = opts;
|
|
|
|
const inherited = env.OPENFLEET_RECORD ? readRecord(env.OPENFLEET_RECORD) : null;
|
|
if (inherited && env.OPENFLEET_RECORD) {
|
|
const lines = readLedger(home, inherited.fleet);
|
|
if (!claimedBy(lines, inherited.member)) {
|
|
return { kind: "claim", record: inherited, recordPath: env.OPENFLEET_RECORD, session: inherited.session ?? session.id };
|
|
}
|
|
return derive(inherited, lines, { home, env, now, host, implicit, session });
|
|
}
|
|
|
|
return root({ home, env, now, host, implicit, session });
|
|
}
|
|
|
|
function derive(
|
|
parent: FleetRecord,
|
|
lines: LedgerLine[],
|
|
opts: { home: string; env: Env; now: Date; host: string; implicit: ImplicitFleet; session: SessionFacts },
|
|
): Resolution {
|
|
const { home, env, now, host, implicit, session } = opts;
|
|
// A background job's member is its job id, as for a root; otherwise the engine's session id.
|
|
const member = session.member ?? session.id;
|
|
const depth = (parent.depth ?? 0) + 1;
|
|
|
|
// The swarm the child joins, when the parent spawned the one the environment names.
|
|
let swarm: string;
|
|
let task: string | undefined;
|
|
let spawnToWrite: LedgerInput | null = null;
|
|
const named = env.OPENFLEET_SWARM;
|
|
const namedSpawn = named && named !== parent.swarm ? spawnOf(lines, named) : null;
|
|
if (named && namedSpawn && namedSpawn.by === parent.member) {
|
|
swarm = named;
|
|
task = namedSpawn.task;
|
|
} else {
|
|
const existing = findEvents(lines, "swarm.spawn").map((line) => String(line.swarm ?? ""));
|
|
swarm = nextSwarmOfOne(parent.member, existing);
|
|
task = session.command ?? session.engine;
|
|
spawnToWrite = {
|
|
event: "swarm.spawn",
|
|
by: parent.member,
|
|
swarm,
|
|
...(parent.swarm ? { parent_swarm: parent.swarm } : {}),
|
|
task,
|
|
ceiling: {},
|
|
pieces: [{ member }],
|
|
};
|
|
}
|
|
|
|
// The parent's effective ceiling, then the joined swarm's own narrowing and
|
|
// any cap on it. A swarm of one has no `swarm.spawn` yet and narrows nothing.
|
|
const ceiling = effectiveCeiling(memberCeiling(home, lines, parent, implicit), lines, [swarm]);
|
|
const refusal = checkCeiling({ approvals: session.approvals, depth, hosts: [host], until: isoNow(now) }, ceiling);
|
|
if (refusal) {
|
|
const line = append(
|
|
home,
|
|
parent.fleet,
|
|
{ event: "ceiling.refuse", by: parent.member, member, action: "start", key: refusal.key, wanted: refusal.wanted, allowed: refusal.allowed },
|
|
{ now, host },
|
|
);
|
|
return { kind: "refused", refusal, line, parent, member };
|
|
}
|
|
|
|
let spawned: LedgerLine | null = null;
|
|
if (spawnToWrite) spawned = append(home, parent.fleet, spawnToWrite, { now, host });
|
|
|
|
const record: FleetRecord = {
|
|
openfleet: OPENFLEET_VERSION,
|
|
fleet: parent.fleet,
|
|
sysop: parent.sysop,
|
|
member,
|
|
parent: parent.member,
|
|
swarm,
|
|
...(task !== undefined ? { task } : {}),
|
|
depth,
|
|
engine: session.engine,
|
|
// Only a `claude -p` is stopped by pid; a Claude Code job is stopped by its job id, which is its member.
|
|
...(session.pid !== undefined && session.engine === "claude-p" ? { session: String(session.pid) } : {}),
|
|
host,
|
|
cwd: session.cwd,
|
|
started: isoNow(now),
|
|
approvals: session.approvals,
|
|
ceiling,
|
|
};
|
|
const path = writeRecord(home, record);
|
|
return { kind: "derive", record, recordPath: path, parent, spawned };
|
|
}
|
|
|
|
function root(opts: { home: string; env: Env; now: Date; host: string; implicit: ImplicitFleet; session: SessionFacts }): Resolution {
|
|
const { home, env, now, host, implicit, session } = opts;
|
|
const member = session.member ?? session.id;
|
|
const fleet = env.OPENFLEET_FLEET ?? readCurrent(home) ?? implicit.id;
|
|
const path = recordPath(home, fleet, member);
|
|
const existing = readRecord(path);
|
|
if (existing) return { kind: "root", record: existing, recordPath: path, existed: true };
|
|
|
|
const lines = readLedger(home, fleet);
|
|
const implicitHere = isImplicitFleet(lines, fleet);
|
|
const sysop = fleetSysop(lines, fleet) ?? implicit.sysop;
|
|
const orphan = session.orphan === true;
|
|
const ceiling: Ceiling = implicitHere
|
|
? { approvals: orphan ? "native" : session.approvals, depth: 1, hosts: [host] }
|
|
: fleetCeiling(lines, fleet, implicit.ceiling);
|
|
|
|
const record: FleetRecord = {
|
|
openfleet: OPENFLEET_VERSION,
|
|
fleet,
|
|
sysop,
|
|
member,
|
|
...(orphan ? { orphan: true } : {}),
|
|
depth: 0,
|
|
engine: session.engine,
|
|
...(session.pid !== undefined && session.engine === "claude-p" ? { session: String(session.pid) } : {}),
|
|
host,
|
|
cwd: session.cwd,
|
|
started: isoNow(now),
|
|
approvals: session.approvals,
|
|
ceiling,
|
|
};
|
|
writeRecord(home, record);
|
|
return { kind: "root", record, recordPath: path, existed: false };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// member.start and member.end
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface StartResult {
|
|
started: LedgerLine | null;
|
|
refused: { refusal: Refusal; line: LedgerLine } | null;
|
|
/**
|
|
* The record was already claimed, or another writer took the `member.start`
|
|
* marker first; nothing was written. Null beside a null `started` and
|
|
* `refused` means the marker was taken and the line is not visible yet.
|
|
*/
|
|
already: LedgerLine | null;
|
|
/** The record as it stands: rewritten when the engine's word on approvals replaced the starter's guess. */
|
|
record: FleetRecord;
|
|
}
|
|
|
|
/**
|
|
* Claim a record: check the effective ceiling, then write `member.start` with
|
|
* `by` the member itself, under the once-marker so a spawner writing the same
|
|
* line on the member's behalf cannot double it. A refusal writes
|
|
* `ceiling.refuse` instead, with `by` the record's parent, else the caller's
|
|
* `OPENFLEET_MEMBER`, else the record's own member when it carries `orphan`,
|
|
* else `sysop` for a root the human started by hand.
|
|
*/
|
|
export function startMember(
|
|
homeDir: string,
|
|
given: FleetRecord,
|
|
facts: {
|
|
sessionId: string;
|
|
approvals: Approvals;
|
|
env?: Env;
|
|
now?: Date;
|
|
host?: string;
|
|
implicit?: ImplicitFleet;
|
|
/** The record was derived by this engine at SessionStart from a guess at approvals. */
|
|
derived?: boolean;
|
|
},
|
|
): StartResult {
|
|
const now = facts.now ?? new Date();
|
|
const implicit = facts.implicit ?? implicitFleet();
|
|
let record = given;
|
|
const host = facts.host ?? record.host ?? implicit.host;
|
|
const lines = readLedger(homeDir, record.fleet);
|
|
const already = claimedBy(lines, record.member);
|
|
if (already) return { started: null, refused: null, already, record };
|
|
|
|
// A root the sysop started by hand runs under the approvals it was started
|
|
// with (rule 12), and its record's ceiling must carry them: in the implicit
|
|
// fleet there is no fleet-level approvals, each root supplies its own. The
|
|
// starter may have guessed, or left the key out; the engine's word fills
|
|
// the record while it is still unclaimed. A record this engine derived at
|
|
// SessionStart from a guess is corrected the same way, so the record, the
|
|
// check and the `member.start` agree.
|
|
const handStartedRoot = !record.parent && !record.orphan && isImplicitFleet(lines, record.fleet);
|
|
if (handStartedRoot && (record.approvals !== facts.approvals || record.ceiling?.approvals === undefined)) {
|
|
const ceiling: Ceiling = record.ceiling ? { ...record.ceiling, approvals: facts.approvals } : { approvals: facts.approvals, depth: 1, hosts: [host] };
|
|
record = { ...record, approvals: facts.approvals, ceiling };
|
|
replaceUnclaimedRecord(homeDir, record);
|
|
} else if (facts.derived && record.approvals !== facts.approvals) {
|
|
record = { ...record, approvals: facts.approvals };
|
|
replaceUnclaimedRecord(homeDir, record);
|
|
}
|
|
|
|
const allowed = memberCeiling(homeDir, lines, record, implicit);
|
|
const refusal = checkCeiling({ approvals: facts.approvals, depth: record.depth ?? 0, hosts: [host], until: isoNow(now) }, allowed);
|
|
if (refusal) {
|
|
const by = record.parent ?? facts.env?.OPENFLEET_MEMBER ?? (record.orphan ? record.member : "sysop");
|
|
const line = append(
|
|
homeDir,
|
|
record.fleet,
|
|
{ event: "ceiling.refuse", by, member: record.member, action: "start", key: refusal.key, wanted: refusal.wanted, allowed: refusal.allowed },
|
|
{ now, host },
|
|
);
|
|
return { started: null, refused: { refusal, line }, already: null, record };
|
|
}
|
|
|
|
const started = appendOnce(
|
|
homeDir,
|
|
record.fleet,
|
|
{
|
|
event: "member.start",
|
|
by: record.member,
|
|
member: record.member,
|
|
session: record.session ?? facts.sessionId,
|
|
...(record.swarm ? { swarm: record.swarm } : {}),
|
|
...(record.parent ? { parent: record.parent } : {}),
|
|
depth: record.depth ?? 0,
|
|
...(record.engine ? { engine: record.engine } : {}),
|
|
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
approvals: facts.approvals,
|
|
...(record.piece ? { piece: record.piece } : {}),
|
|
},
|
|
{ now, host, once: markName("member.start", record.member) },
|
|
);
|
|
if (!started) return { started: null, refused: null, already: claimedBy(readLedger(homeDir, record.fleet), record.member), record };
|
|
return { started, refused: null, already: null, record };
|
|
}
|
|
|
|
export interface EndFacts {
|
|
state: EndState;
|
|
by: string;
|
|
summary?: string;
|
|
total?: string;
|
|
links?: unknown[];
|
|
now?: Date;
|
|
host?: string;
|
|
}
|
|
|
|
export interface EndResult {
|
|
ended: LedgerLine | null;
|
|
swarmEnded: LedgerLine | null;
|
|
/** An end line already counted, or another writer holds the marker; nothing was written. */
|
|
already: LedgerLine | null;
|
|
}
|
|
|
|
/**
|
|
* End a member: one `member.end` that counts (a `lost` line may be superseded
|
|
* by a real one, anything else stands), then, for a swarm of one the engine
|
|
* derived, that swarm's `swarm.end` with the same state. Every line goes
|
|
* through its once-marker; a `lost` end takes `member.end.<id>.lost` so the
|
|
* real end can still follow it and take the plain marker.
|
|
*/
|
|
export function endMember(homeDir: string, fleet: string, member: string, facts: EndFacts, swarm?: string): EndResult {
|
|
const now = facts.now ?? new Date();
|
|
const lines = readLedger(homeDir, fleet);
|
|
const existing = endOf(lines, member);
|
|
if (existing && !(existing.state === "lost" && facts.state !== "lost")) return { ended: null, swarmEnded: null, already: existing };
|
|
const lost = facts.state === "lost";
|
|
// A real end in flight (marker taken, line not yet visible) beats a lost one.
|
|
if (lost && hasMark(homeDir, fleet, markName("member.end", member))) return { ended: null, swarmEnded: null, already: existing };
|
|
|
|
const ended = appendOnce(
|
|
homeDir,
|
|
fleet,
|
|
{
|
|
event: "member.end",
|
|
by: facts.by,
|
|
member,
|
|
state: facts.state,
|
|
...(facts.summary !== undefined ? { summary: facts.summary } : {}),
|
|
...(facts.total !== undefined ? { total: facts.total } : {}),
|
|
...(facts.links !== undefined ? { links: facts.links } : {}),
|
|
},
|
|
{ now, host: facts.host, once: markName("member.end", member, lost) },
|
|
);
|
|
if (!ended) return { ended: null, swarmEnded: null, already: endOf(readLedger(homeDir, fleet), member) };
|
|
|
|
let swarmEnded: LedgerLine | null = null;
|
|
if (swarm) {
|
|
const spawn = spawnOf(lines, swarm);
|
|
// Only a swarm of one the engine itself minted ends with its member: the
|
|
// spawner is a member (never the sysop) and the id is <parent>-<n>. A
|
|
// one-piece swarm a spawner such as moshcode wrote is that spawner's to end.
|
|
const minted =
|
|
typeof spawn?.by === "string" &&
|
|
spawn.by !== "sysop" &&
|
|
new RegExp(`^${spawn.by.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\d+$`).test(swarm);
|
|
const ofOne = minted && spawn?.pieces?.length === 1 && spawn.pieces[0]?.member === member;
|
|
if (ofOne && !swarmEndOf(lines, swarm)) {
|
|
swarmEnded = appendOnce(
|
|
homeDir,
|
|
fleet,
|
|
{ event: "swarm.end", by: facts.by, swarm, state: facts.state, ...(facts.summary !== undefined ? { summary: facts.summary } : {}) },
|
|
{ now, host: facts.host, once: markName("swarm.end", swarm) },
|
|
);
|
|
}
|
|
}
|
|
return { ended, swarmEnded, already: null };
|
|
}
|