zz_

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 })));
  }
 }