{"version":3,"file":"ops.cjs","names":[],"sources":["../../../src/batteries/orchestration/ops.ts"],"sourcesContent":["/**\n * The op-log fold and the branch-key renderer for orchestration.\n *\n * @module @nhtio/adk/batteries/orchestration/ops\n *\n * @remarks\n * This module owns two things:\n *\n * 1. `foldOps` — the DETERMINISTIC fold that turns a plan's op log into a `RawPlanView`. The plan\n *    IS the fold of its op log: two actors folding the same op set must reach the same state, and\n *    ops may arrive out of order or twice. The fold never throws on a malformed-but-well-typed\n *    log; it surfaces `PlanIssue`s instead.\n * 2. `branchKey` — the canonical, INJECTIVE string form of a `BranchId` route, used to key\n *    `OutputTable`, identify `NodeRef.branchId`, order join contributors, and make duplicate\n *    arrivals idempotent.\n */\n\nimport { planDigest } from './encoding'\nimport { DEFAULT_PLAN_BOUNDS } from './types'\nimport type {\n  PlanOp,\n  PlanNode,\n  PlanEdge,\n  PlanBounds,\n  RawPlanView,\n  PlanIssue,\n  NodeId,\n  BranchId,\n  PlanProvenance,\n} from './types'\n\n// ── the three-part key ───────────────────────────────────────────────────────\n/**\n * Total order over ops: `(lamport, actorId, opId)`.\n *\n * @remarks\n * All three parts are REQUIRED, and the reason is convergence. `(lamport, actorId)` alone is not a\n * total order: one actor can legitimately emit two ops at the same lamport (a single logical\n * instant can author several edits), and then the arrival order would decide which of those two\n * ops is \"later\" — exactly the non-convergence the op log exists to prevent. Two offline writers\n * who each append before their logs meet would fold the same op set into different states purely\n * because of when each op happened to arrive. `opId` is unique by construction, so appending it\n * makes the triple total: no two ops share a key, and the sorted order is identical for every\n * actor regardless of arrival order. The fold therefore sorts by this triple before applying, and\n * the highest-key op touching any element is applied last and wins (LWW).\n */\nconst byKey = (a: PlanOp, b: PlanOp): number => {\n  if (a.lamport !== b.lamport) return a.lamport - b.lamport\n  if (a.actorId !== b.actorId) return a.actorId < b.actorId ? -1 : 1\n  if (a.opId !== b.opId) return a.opId < b.opId ? -1 : 1\n  return 0\n}\n\n// ── spine copy ───────────────────────────────────────────────────────────────\n/**\n * True for a PLAIN object — one whose prototype is `Object.prototype` or `null`. This is the same\n * distinction `encoding.ts`'s `isPlainObject` makes: every encoder-owned value (`Date`, `RegExp`,\n * `Map`, `Set`, typed arrays, `ArrayBuffer`, `DataView`, bigint, luxon values, and\n * `NodeRef`/`ParamRef` instances) has a non-plain prototype.\n */\nconst isPlainObject = (v: unknown): v is Record<string, unknown> => {\n  if (v === null || typeof v !== 'object') return false\n  const proto = Object.getPrototypeOf(v)\n  return proto === Object.prototype || proto === null\n}\n\n/**\n * Copy the PLAIN-OBJECT/ARRAY spine of a value, carrying every other value across BY REFERENCE.\n *\n * @remarks\n * The fold treats every op as strictly read-only input, so it must never hand the caller's op\n * objects to the working map. But a naive JSON round-trip or a recursive rebuild would destroy\n * encoder-owned values — a `Date`, `RegExp`, `Map`, `Set`, typed array, bigint, or a\n * `NodeRef`/`ParamRef` INSTANCE — and the plan digest depends on those surviving intact. So we\n * copy only the plain-object/array spine and let every other value ride through unchanged, the\n * same distinction `encoding.ts`'s `isPlainObject` makes.\n */\nconst copySpine = (value: unknown): unknown => {\n  if (Array.isArray(value)) return value.map(copySpine)\n  if (isPlainObject(value)) {\n    const out: Record<string, unknown> = {}\n    for (const key of Object.keys(value)) out[key] = copySpine(value[key])\n    return out\n  }\n  return value\n}\n\n// ── field-path setter ────────────────────────────────────────────────────────\n/**\n * Set a dot-path within a node definition, COPYING along the path so the source op's node object\n * is never mutated. Intermediate objects are created when absent. The value is assigned by\n * reference (never copied) — the fold does not own the op's values.\n */\nconst setPath = (root: unknown, path: string, value: unknown): unknown => {\n  const parts = path.split('.')\n  const copy = (v: unknown): any =>\n    // eslint-disable-next-line adk/prefer-is-object -- isObject excludes arrays, but arrays must be copied with [...v] here\n    v !== null && typeof v === 'object' ? (Array.isArray(v) ? [...v] : { ...v }) : v\n  const newRoot = copy(root)\n  let cur = newRoot\n  for (let i = 0; i < parts.length - 1; i++) {\n    const key = parts[i]\n    const next = cur[key]\n    cur[key] =\n      // eslint-disable-next-line adk/prefer-is-object -- isObject excludes arrays, but arrays must be copied via copy()\n      next !== null && typeof next === 'object' ? copy(next) : {}\n    cur = cur[key]\n  }\n  cur[parts[parts.length - 1]] = value\n  return newRoot\n}\n\n// ── foldOps ──────────────────────────────────────────────────────────────────\n/**\n * Fold an op log into a `RawPlanView`, deterministically and convergently.\n *\n * @remarks\n * The plan is the fold of its op log. Two actors folding the same op set must reach the same\n * state, and ops may arrive out of order or twice. The fold is therefore a pure function of the\n * op SET: it sorts by the three-part key `(lamport, actorId, opId)` — see {@link byKey} for why\n * all three parts are required — and applies the ops in that total order. Because the highest-key\n * op touching any element is applied last, the result is LWW (last-writer-wins) and identical for\n * every arrival order.\n *\n * **Ops are strictly read-only input.** The fold never mutates the caller's op objects: on\n * `add_node` and `set_node_definition` it copies the plain-object/array SPINE of the node or\n * definition (carrying every encoder-owned value — `Date`, `RegExp`, `Map`, `Set`, typed arrays,\n * bigint, `NodeRef`/`ParamRef` instances — across by reference), and `set_node_field`/\n * `set_node_phase` write only onto those copies. So a `PlanStore` can serve historical views from\n * the same op log without a read-only projection silently altering it.\n *\n * **Bounds are the fold SEED, not an op.** The fold starts from `DEFAULT_PLAN_BOUNDS`, so an empty\n * log folds to revision 0 with a complete view and a stable digest, and the first authoring op\n * makes revision 1. `revision` is the number of ops folded. `set_bounds` ops override the seed by\n * LWW thereafter.\n *\n * **Element semantics.** `add_node`/`remove_node` and `add_edge`/`remove_edge` are LWW-ELEMENT,\n * not add-wins: the highest-key op touching an element decides whether it exists. Add-wins is\n * deliberately NOT attempted — it needs causal context a scalar lamport cannot provide. A\n * `remove_node` also records its `incidentEdgeIds`, so removal cascades to those edges\n * order-independently. `set_node_field` is LWW per field on the same three-part key (its value may\n * contain a `NodeRef`, so it accepts `ArgValue`); `set_node_definition` replaces a whole\n * definition by LWW; `set_node_phase` sets a phase, with `null` clearing it; `set_bounds` overrides\n * the seed by LWW.\n *\n * **The fold surfaces issues rather than throwing.** It never throws on a malformed-but-well-typed\n * log:\n * - An edge whose `from` or `to` node does not exist after folding is DROPPED and surfaced as a\n *   `dangling_edge` issue — a dangling edge is never what anyone wanted.\n * - Two `add_edge` ops with the SAME id but different endpoints: LWW decides, the loser is dropped,\n *   and the issue names BOTH so the author renames one. The second is not refused at append time —\n *   that would make the fold order-dependent, and two offline writers can each legally append\n *   before their logs meet.\n * - An op referencing an unknown nodeId is surfaced as an `unknown_node` issue, not thrown.\n *\n * The `digest` is computed via `planDigest(view)` with the digest field itself empty when hashing,\n * so it cannot depend on itself.\n *\n * @param planId - The plan's id, carried into the view.\n * @param ops - The op log to fold. May be empty, out of order, or contain duplicates.\n * @param provenance - Optional lineage (clone/template), carried into the view and covered by the\n *   digest.\n * @returns The folded view and any issues the fold surfaced.\n */\nexport const foldOps = (\n  planId: string,\n  ops: readonly PlanOp[],\n  provenance?: PlanProvenance\n): { view: RawPlanView; issues: PlanIssue[] } => {\n  const sorted = [...ops].sort(byKey)\n  const nodes = new Map<NodeId, PlanNode>()\n  const edges = new Map<string, PlanEdge>()\n  let bounds: PlanBounds = { ...DEFAULT_PLAN_BOUNDS }\n  const issues: PlanIssue[] = []\n\n  // Same-id add_edge collision detection: per edgeId, the set of distinct endpoint signatures\n  // seen and the first edge that claimed the id (to name both sides of a collision).\n  const edgeSigs = new Map<string, Set<string>>()\n  const edgeFirst = new Map<string, PlanEdge>()\n\n  for (const op of sorted) {\n    switch (op.op) {\n      case 'add_node': {\n        nodes.set(op.node.id, copySpine(op.node) as PlanNode)\n        break\n      }\n      case 'remove_node': {\n        nodes.delete(op.nodeId)\n        for (const edgeId of op.incidentEdgeIds) edges.delete(edgeId)\n        break\n      }\n      case 'set_node_field': {\n        const node = nodes.get(op.nodeId)\n        if (node)\n          node.definition = setPath(node.definition, op.path, op.value) as PlanNode['definition']\n        break\n      }\n      case 'set_node_definition': {\n        const node = nodes.get(op.nodeId)\n        if (node) node.definition = copySpine(op.definition) as PlanNode['definition']\n        break\n      }\n      case 'set_node_phase': {\n        const node = nodes.get(op.nodeId)\n        if (node) {\n          if (op.phase === null) delete node.phase\n          else node.phase = op.phase\n        }\n        break\n      }\n      case 'add_edge': {\n        const sig = `${op.edge.from}\\u0000${op.edge.to}\\u0000${op.edge.handle}`\n        if (!edgeSigs.has(op.edge.id)) {\n          edgeSigs.set(op.edge.id, new Set([sig]))\n          edgeFirst.set(op.edge.id, op.edge)\n        } else {\n          const sigs = edgeSigs.get(op.edge.id)!\n          if (!sigs.has(sig)) {\n            const first = edgeFirst.get(op.edge.id)!\n            issues.push({\n              code: 'duplicate_edge_id',\n              message:\n                `Edge id \"${op.edge.id}\" is used by two different edges: ` +\n                `${first.from}→${first.to} (${first.handle}) and ${op.edge.from}→${op.edge.to} ` +\n                `(${op.edge.handle}); rename one of them.`,\n              edgeId: op.edge.id,\n              severity: 'advisory',\n            })\n            sigs.add(sig)\n          }\n        }\n        edges.set(op.edge.id, op.edge)\n        break\n      }\n      case 'remove_edge': {\n        edges.delete(op.edgeId)\n        break\n      }\n      case 'set_bounds': {\n        bounds = { ...op.bounds }\n        break\n      }\n    }\n  }\n\n  // Cleanup: drop dangling edges (from/to node absent after folding) and surface them.\n  const finalEdges = new Map<string, PlanEdge>()\n  for (const [edgeId, edge] of edges) {\n    if (nodes.has(edge.from) && nodes.has(edge.to)) {\n      finalEdges.set(edgeId, edge)\n    } else {\n      issues.push({\n        code: 'dangling_edge',\n        message:\n          `Edge \"${edgeId}\" (${edge.from} → ${edge.to}) references a node that does not ` +\n          `exist after folding; the edge was dropped.`,\n        edgeId,\n        severity: 'advisory',\n      })\n    }\n  }\n\n  // Cleanup: surface ops that reference a nodeId absent from the final folded node set.\n  const unknownNodeIds = new Set<NodeId>()\n  for (const op of sorted) {\n    if (\n      op.op === 'set_node_field' ||\n      op.op === 'set_node_definition' ||\n      op.op === 'set_node_phase'\n    ) {\n      if (!nodes.has(op.nodeId)) unknownNodeIds.add(op.nodeId)\n    }\n  }\n  for (const nodeId of unknownNodeIds) {\n    issues.push({\n      code: 'unknown_node',\n      message:\n        `Node \"${nodeId}\" does not exist in the folded plan; add it or remove the ops ` +\n        `that reference it.`,\n      nodeId,\n      severity: 'advisory',\n    })\n  }\n\n  const view: RawPlanView = {\n    planId,\n    digest: '',\n    revision: ops.length,\n    nodes: [...nodes.values()],\n    edges: [...finalEdges.values()],\n    bounds,\n    ...(provenance ? { provenance } : {}),\n  }\n  view.digest = planDigest(view)\n\n  return { view, issues }\n}\n\n// ── branchKey ────────────────────────────────────────────────────────────────\n/**\n * The canonical, INJECTIVE string form of a `BranchId` route.\n *\n * @remarks\n * `branchKey` keys `OutputTable`, identifies `NodeRef.branchId`, orders join contributors, and\n * makes duplicate arrivals idempotent — so a collision would overwrite one node's output with\n * another's or merge unrelated barriers. It MUST therefore be injective.\n *\n * Naive delimiter-joining is NOT injective: an edge id containing the delimiter (`a>b`) collides\n * with two segments (`a`, `b`), and an id shaped like `join:x(y)` collides with a join segment. So\n * the rendering is LENGTH-PREFIXED, concatenated with no separator:\n * - an edge segment renders as `` `e${id.length}:${id}` ``;\n * - a join segment renders as `` `j${nodeId.length}:${nodeId}(${of.map(len-prefixed).join('')})` ``.\n *\n * Length-prefixing is injective regardless of content: a parser reads the length, then exactly\n * that many characters for the id, so no delimiter can be forged and no two distinct routes render\n * to the same string. The `e`/`j` prefixes keep edge and join segments disjoint, and the join's\n * `of` ids are themselves length-prefixed so the closing `)` is unambiguous.\n *\n * @param b - The route to render.\n * @returns The length-prefixed, separator-free canonical string.\n */\nexport const branchKey = (b: BranchId): string => {\n  let out = ''\n  for (const seg of b.segments) {\n    if ('edge' in seg) {\n      out += `e${seg.edge.length}:${seg.edge}`\n    } else {\n      const of = seg.of.map((id) => `e${id.length}:${id}`).join('')\n      out += `j${seg.join.length}:${seg.join}(${of})`\n    }\n  }\n  return out\n}\n"],"mappings":"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"}