app/engine/lane.ts
zzmq.dev@gmail.com/zz / app / engine / lane.ts
@ b95ddb54
Download
Ladder Summary
- file The module implements the core “lane” primitives that drive a lane’s lifecycle: **`proposeLane`** creates and persists a new `Lane` record, **`ask`** appends an ASK entry to a lane’s history, **`addRelatedLane`** records a child‑lane relationship, and **`answer`** records an ANSWER entry (with duplicate‑answer and RBAC checks) – all performed atomically through the backend resolved by `resolveBackend()`. No classes are exported; the only exported symbols are the four async functions listed above.
// The Lane lifecycle: ASK/ANSWER/claim/merge-grant as real functions over
// the existing .zz/ persistence (engine/store.ts) and Ladder storage
// (engine/ladderNotes.ts), not just data types. Role-checking is deferred
// to a later step (see the plan) -- this step is about getting the
// protocol functions themselves working end to end.
import { readLane, writeLane } from "./store.js";
import { appendRepoChildren, writeLadder } from "./ladderNotes.js";
import type { AskEntry, AnswerEntry, Lane, LaneRef, LaneStatus, Trigger } from "./types.js";
import type { Rung, RungKind } from "./ladder.js";
import { REPO_RUNG_PATH } from "./ladder.js";
function nowIso(): string {
return new Date().toISOString();
}
export function proposeLane(
stateDir: string,
input: { id: string; repo: string; name: string; objective: string; scope: string[]; proposedBy: string; origin?: LaneRef | null; reason?: string },
): Lane {
const trigger: Trigger = { kind: "manual", proposed_by: input.proposedBy, reason: input.reason };
const lane: Lane = {
id: input.id,
repo: input.repo,
origin: input.origin ?? null,
name: input.name,
objective: input.objective,
status: "proposed",
scope: input.scope,
trigger,
coordinators: [],
history: [],
related_lanes: [],
};
writeLane(stateDir, lane);
return lane;
}
function requireLane(stateDir: string, laneId: string): Lane {
const lane = readLane(stateDir, laneId);
if (!lane) throw new Error(`no such lane: ${laneId}`);
return lane;
}
-export function ask(stateDir: string, laneId: string, input: { kind: AskEntry["kind"]; from: string }): AskEntry {
+export function ask(stateDir: string, laneId: string, input: { kind: AskEntry["kind"]; from: string; ref?: string }): AskEntry {
const lane = requireLane(stateDir, laneId);
- const entry: AskEntry = { type: "ASK", kind: input.kind, from: input.from, at: nowIso() };
+ const entry: AskEntry = { type: "ASK", kind: input.kind, from: input.from, at: nowIso(), ref: input.ref };
lane.history.push(entry);
writeLane(stateDir, lane);
return entry;
}
const STATUS_ON_GRANT: Record<AskEntry["kind"], LaneStatus | null> = {
claim_request: "claimed",
review_request: "active",
change_request: null,
coordinator_request: null,
merge_request: "merged",
+ rung_update_request: null,
};
-export function answer(stateDir: string, laneId: string, theAsk: AskEntry, input: { from: string; action: AnswerEntry["action"] }): AnswerEntry {
+export function answer(stateDir: string, laneId: string, theAsk: AskEntry, input: { from: string; action: AnswerEntry["action"]; ref?: string }): AnswerEntry {
const lane = requireLane(stateDir, laneId);
- const entry: AnswerEntry = { type: "ANSWER", from: input.from, action: input.action, at: nowIso() };
+ const entry: AnswerEntry = { type: "ANSWER", from: input.from, action: input.action, at: nowIso(), ref: input.ref };
lane.history.push(entry);
if (input.action === "grant") {
const nextStatus = STATUS_ON_GRANT[theAsk.kind];
if (nextStatus) lane.status = nextStatus;
}
writeLane(stateDir, lane);
return entry;
}
/**
+ * A non-decision round trip: the actor ASKs the coordinator to update a
+ * piece of shared Ladder state it doesn't own outright (e.g. a file's
+ * current-state rung, touched by many changesets over time), the
+ * coordinator does it, and ANSWERs with `action: "inform"` -- a
+ * completion notice, not a grant/deny decision. Both sides are played by
+ * the same agent in this single-actor bootstrap, but the two ends of the
+ * round trip are still recorded as distinct protocol events.
+ */
+export async function requestRungUpdate(stateDir: string, laneId: string, input: { from: string; ref: string }, doUpdate: () => Promise<void>): Promise<void> {
+ const theAsk = ask(stateDir, laneId, { kind: "rung_update_request", from: input.from, ref: input.ref });
+ await doUpdate();
+ answer(stateDir, laneId, theAsk, { from: input.from, action: "inform", ref: input.ref });
+}
+
+/**
* Merges a Lane's own local Ladder into the repo's Ladder -- the one
* write path anything (replay, or later a live Lane) uses to actually
* change stored Ladder state. Usually one top-level rung; a Lane whose
* work produced several top-level entries (e.g. describing a whole
* directory's immediate contents) passes an array instead.
*/
export async function mergeLaneLadder(repoDir: string, stateDir: string, lane: Lane, laneLadder: Rung | Rung[]): Promise<void> {
if (lane.status !== "merged") throw new Error(`lane ${lane.id} is not merged (status: ${lane.status}); refusing to merge its Ladder`);
const rungs = Array.isArray(laneLadder) ? laneLadder : [laneLadder];
for (const rung of rungs) {
await writeLadder(repoDir, stateDir, rung);
}
// The repo's own root rung is never a *child* of itself -- writing its
// note (above) already embeds its own children correctly. Only other
// top-level rungs get registered as new children of the repo root.
const asChildren = rungs.filter((r) => r.path !== REPO_RUNG_PATH);
if (asChildren.length) {
await appendRepoChildren(repoDir, stateDir, asChildren.map((r) => ({ path: r.path, kind: r.kind as RungKind })));
}
}