@melite/estate-drafts 0.1.4-alpha → 0.1.5-pre-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/remote.js CHANGED
@@ -33,13 +33,13 @@ var __esDecorate = (this && this.__esDecorate) || function (ctor, descriptorIn,
33
33
  done = true;
34
34
  };
35
35
  import { Remote, RemoteError, TypertRemoteService, } from "@deepseek-ai/dsh-typert-protocol";
36
- import { admitWiring, benchBlockOf, strippedForAdmission } from "./bench.js";
36
+ import { admitWiring, benchBlockOf, strippedForAdmission, toleratedByRow } from "./bench.js";
37
37
  import { STAGE_PATCH_KEYS, RESTORED_PROVENANCE_SPELLING } from "@melite/contracts/benchStage";
38
38
  export { STAGE_PATCH_KEYS };
39
39
  import { unwrapItemArrays } from "@melite/comfyui-card-kit";
40
40
  import { openDraftsDomain } from "./store.js";
41
41
  import { DRAFT_ID_RE } from "./spec.js";
42
- import { addStageBench, ensureBench, notifyBenchChange, notifyBenchDocWrite, onBenchChange, setBenchChangeSeam, getBench, groupBench, patchBench, clearBench, removeStageBench, reorderBench, rewireBench, undoBench, ungroupBench, } from "./bench.js";
42
+ import { addStageBench, ensureBench, notifyBenchChange, notifyBenchDocWrite, onBenchChange, clearBenchChangeSeam, setBenchChangeSeam, getBench, groupBench, patchBench, clearBench, removeStageBench, reorderBench, rewireBench, undoBench, ungroupBench, } from "./bench.js";
43
43
  import { benchDraftIdOf } from "@melite/contracts/benchStage";
44
44
 
45
45
 
@@ -59,17 +59,21 @@ import { benchDraftIdOf } from "@melite/contracts/benchStage";
59
59
 
60
60
 
61
61
 
62
+
62
63
  export function installBenchChangeSeam(ctx) {
63
64
  let detach = null;
64
65
  ctx.effect(() => {
65
- setBenchChangeSeam({
66
+ const mine = {
66
67
  emit: (session) => { ctx.emit("bench.changed", session); },
67
68
  on: (fn) => {
68
69
  const stop = ctx.on("bench.changed", fn);
69
70
  return () => { stop(); };
70
71
  },
71
- });
72
- detach = () => { setBenchChangeSeam(null); };
72
+ };
73
+ setBenchChangeSeam(mine);
74
+ // the ownership stamp: an older fiber's unwind clears only its own
75
+ // install — a displaced seam detaches nothing (clearBenchChangeSeam)
76
+ detach = () => { clearBenchChangeSeam(mine); };
73
77
  return detach;
74
78
  });
75
79
  return () => { detach?.(); };
@@ -84,34 +88,8 @@ function thrownMessage(error) {
84
88
  return error instanceof Error ? error.message : String(error);
85
89
  }
86
90
  /** ── THE OUTPUT HISTORY HELPERS (stroke 40) ──────────────────────
87
- * A result's OUTPUT urls, one spelling: the artifacts name→url map
88
- * first, the flat assetUrls list second, the lone url third (the
89
- * same harvest order the client's preview reads — the history and
90
- * the hero can never disagree about what a run produced). */
91
- export function resultOutputUrls(result) {
92
- if (typeof result !== "object" || result === null || Array.isArray(result))
93
- return [];
94
- const row = result;
95
- const urls = [];
96
- if (row["artifacts"] !== null && typeof row["artifacts"] === "object" && !Array.isArray(row["artifacts"])) {
97
- for (const v of Object.values(row["artifacts"])) {
98
- if (typeof v === "string" && v !== "")
99
- urls.push(v);
100
- }
101
- }
102
- if (urls.length === 0 && Array.isArray(row["assetUrls"])) {
103
- for (const v of row["assetUrls"]) {
104
- if (typeof v === "string" && v !== "")
105
- urls.push(v);
106
- }
107
- }
108
- if (urls.length === 0 && typeof row["url"] === "string" && row["url"] !== "")
109
- urls.push(row["url"]);
110
- return urls;
111
- }
112
- /** Same OUTPUTS (the uniqueness key): two results match when their
113
- * url sets are equal — a re-run landing the same urls is not a new
114
- * history entry (the operator's "when it is unique"). */
91
+ * resultOutputUrls now lives in trail.ts (the cycle-free seam) and
92
+ * is re-exported below with the trail laws. ───────────────────── */
115
93
  export function sameResultOutputs(a, b) {
116
94
  return sameUrlSet(resultOutputUrls(a), resultOutputUrls(b));
117
95
  }
@@ -121,6 +99,15 @@ function sameUrlSet(a, b) {
121
99
  const sa = new Set(a);
122
100
  return b.every((u) => sa.has(u));
123
101
  }
102
+
103
+
104
+
105
+
106
+ export { canonicalOutputUrl, resultOutputUrls, isTerminalTrailWord as isTerminalWord, trailDedupe } from "./trail.js";
107
+ import { canonicalOutputUrl, isTerminalTrailWord as isTerminalWord, resultOutputUrls, sameTrailRunIdentity, } from "./trail.js";
108
+ function sameRunIdentity(a, b) {
109
+ return sameTrailRunIdentity(a, b);
110
+ }
124
111
  function resolverOf(ctx) {
125
112
  let seat;
126
113
  try {
@@ -263,6 +250,20 @@ export function laneAdmissionOf(ctx) {
263
250
 
264
251
 
265
252
 
253
+
254
+
255
+
256
+ export function laneMigrationOf(ctx) {
257
+ const reg = resolverOf(ctx);
258
+ if (reg === null || reg.laneMigrate === undefined)
259
+ return undefined;
260
+ return reg.laneMigrate.bind(reg);
261
+ }
262
+
263
+
264
+
265
+
266
+
266
267
  export function benchAdmissionOf(ctx) {
267
268
  const modelAdmit = admissionOf(ctx);
268
269
  const laneAdmit = laneAdmissionOf(ctx);
@@ -341,6 +342,37 @@ function docIndexFields(doc) {
341
342
  return { model, name: disp };
342
343
  }
343
344
  /** The drafts Remote owner: list/get over the storage domain. */
345
+
346
+
347
+
348
+
349
+
350
+
351
+
352
+
353
+
354
+
355
+
356
+
357
+
358
+
359
+ const benchTransactions = new Map();
360
+ async function withBenchWrite(draftId, body) {
361
+ const prev = benchTransactions.get(draftId) ?? Promise.resolve();
362
+ let release;
363
+ const gate = new Promise((r) => { release = r; });
364
+ const mine = prev.then(() => gate);
365
+ benchTransactions.set(draftId, mine);
366
+ await prev;
367
+ try {
368
+ return await body();
369
+ }
370
+ finally {
371
+ release();
372
+ if (benchTransactions.get(draftId) === mine)
373
+ benchTransactions.delete(draftId);
374
+ }
375
+ }
344
376
  let DraftsRemoteService = (() => {
345
377
  let _classSuper = TypertRemoteService;
346
378
  let _instanceExtraInitializers = [];
@@ -455,8 +487,14 @@ let DraftsRemoteService = (() => {
455
487
  async remoteExportPatch(draftId, stageId, patch, expectRev, signal) {
456
488
  return await this.patch(draftId, stageId, patch, expectRev, signal);
457
489
  }
458
- /** The patch arm the adapter above delegates to. */
490
+ /** The patch arm the adapter above delegates to — one write
491
+ * transaction on the draft's own key, shared with the bench
492
+ * verbs (the round-14 race audit's R3: the author arm's
493
+ * spread-copy of the log forked against their in-place push). */
459
494
  async patch(draftId, stageId, patch, expectRev, signal) {
495
+ return withBenchWrite(draftId, () => this.patchBody(draftId, stageId, patch, expectRev, signal));
496
+ }
497
+ async patchBody(draftId, stageId, patch, expectRev, signal) {
460
498
  signal.throwIfAborted();
461
499
  if (!DRAFT_ID_RE.test(draftId)) {
462
500
  throw new RemoteError("draft/bad-id", `bad draft id ${JSON.stringify(draftId)}`, {
@@ -485,6 +523,27 @@ let DraftsRemoteService = (() => {
485
523
  if (Object.keys(patch).length === 0) {
486
524
  throw new RemoteError("draft/bad-patch", "patch is empty (nothing to patch)", {});
487
525
  }
526
+ // THE INPUT KEYS ARE SHAPE-GATED AT THE DOOR (audit S-S8,
527
+
528
+ // wire type permits any JsonValue) and the next compile refused
529
+ // the poisoned row far from the write; `inputOutputs` stored
530
+ // verbatim on BOTH seats while the bench verb refuses the key
531
+ // outright. One shape per key now: inputs is the channel ->
532
+ // wiring record (the bench arm's own isObj law), inputOutputs
533
+ // is the per-slot output-name list (string or null per slot —
534
+ // the wire contract FlowStage declares). The compile seat keeps
535
+ // the deep wiring law; this gate only refuses what could never
536
+ // compile, at the write that would have poisoned the doc.
537
+ if ("inputs" in patch && !isObj(patch["inputs"])) {
538
+ throw new RemoteError("draft/bad-patch", "inputs must be an object (channel -> wiring)", {});
539
+ }
540
+ if ("inputOutputs" in patch) {
541
+ const io = patch["inputOutputs"];
542
+ if (!Array.isArray(io) ||
543
+ !io.every((entry) => entry === null || typeof entry === "string")) {
544
+ throw new RemoteError("draft/bad-patch", "inputOutputs must be a list of output names (string or null per input slot)", {});
545
+ }
546
+ }
488
547
  let drafts;
489
548
  try {
490
549
  drafts = await openDraftsDomain(this.ctx);
@@ -534,18 +593,26 @@ let DraftsRemoteService = (() => {
534
593
  // the domain's cached object; a refusal must leave no trace),
535
594
  // then bump and log below. A bench that moved under the caller
536
595
  // refuses loud, never merges under it.
537
- if (!telemetry && expectRev !== null && bench.rev !== expectRev) {
538
- throw new RemoteError("bench/cas-conflict", `bench moved under the patch (expect_rev ${expectRev}, current ${bench.rev}) — re-read and re-apply`, { current_rev: bench.rev, expect_rev: expectRev });
596
+ if (!telemetry) {
597
+
598
+ // null skipped the CAS fence the bench verbs mandate (their
599
+ // law: a non-number expect_rev is a guaranteed refusal) — an
600
+ // unfenced author write merged silently under a moved bench.
601
+ // The door speaks the bench's voice, and the refusal carries
602
+ // current_rev so a rebase-and-retry lands (the client's CAS
603
+ // predicate already names this code).
604
+ if (expectRev === null) {
605
+ throw new RemoteError("bench/cas-conflict", "expect_rev is required (the CAS law)", { current_rev: bench.rev });
606
+ }
607
+ if (bench.rev !== expectRev) {
608
+ throw new RemoteError("bench/cas-conflict", `bench moved under the patch (expect_rev ${expectRev}, current ${bench.rev}) — re-read and re-apply`, { current_rev: bench.rev, expect_rev: expectRev });
609
+ }
539
610
  }
540
611
  // THE TELEMETRY WRITE IS DEFERRED (the bench audit's gap 1,
541
-
542
- // assigned into the CACHED record here, ABOVE the model and
543
- // wiring gates — so a patch carrying a result AND a bad model
544
- // wrote the result into the live doc and then threw, leaving a
545
- // result with no rev and no log (the copy-on-judge law's exact
546
- // shape: a refused write must leave NOTHING behind). The arms
547
- // now run as one closure AFTER both gates pass.
548
- const writeTelemetry = () => {
612
+ // The arms run as one closure AFTER both gates pass. The closure
613
+ // takes the row as its PARAM (shadowing this section's binding):
614
+ // the R6 cure below re-resolves the LIVE row at call time.
615
+ const writeTelemetry = (stage) => {
549
616
  if ("result" in patch) {
550
617
  // THE OUTPUT HISTORY (stroke 40 — the operator's QOL law: the
551
618
  // preview's mini row is a CARD OUTPUT HISTORY): a stage's
@@ -566,6 +633,29 @@ let DraftsRemoteService = (() => {
566
633
  // edited between runs must not retroactively re-label the old
567
634
  // generation. The archive entry carries the snapshot; the
568
635
  // client diffs it against the live params for the chip.
636
+ // THE TERMINAL-HOLDS LAW (bench-trail W3, judged PRE-write): a
637
+ // settled run's row never un-settles — a stale poll/tail pass
638
+ // re-stamping a non-terminal word for the SAME run_id writes
639
+ // NOTHING (result included; the tile's stuck face). A
640
+ // different run_id is a fresh press and runs.
641
+ const settledRunId = typeof stage["result"] === "object" && stage["result"] !== null && !Array.isArray(stage["result"])
642
+ ? stage["result"]["run_id"]
643
+ : undefined;
644
+ const patchRunId = typeof patch["result"] === "object" && patch["result"] !== null && !Array.isArray(patch["result"])
645
+ ? patch["result"]["run_id"]
646
+ : undefined;
647
+ if (isTerminalWord(stage["runStatus"]) && !isTerminalWord(patch["runStatus"])
648
+ && typeof patchRunId === "string" && sameRunIdentity(settledRunId, patchRunId))
649
+ return;
650
+ // THE HUSK-DOWNGRADE REFUSAL (trail-defect-hunt §2): a
651
+ // terminal, url-less result for a run whose settled face
652
+ // carries urls is bookkeeping (the seat's own mirror husk
653
+ // racing the client's rich patch) — the hero slot never
654
+ // downgrades. A DIFFERENT run_id is a fresh press and runs.
655
+ if (isTerminalWord(patch["runStatus"]) && sameRunIdentity(settledRunId, patchRunId)
656
+ && resultOutputUrls(stage["result"]).length > 0
657
+ && resultOutputUrls(patch["result"]).length === 0)
658
+ return;
569
659
  const prev = stage["result"];
570
660
  const next = patch["result"];
571
661
 
@@ -575,25 +665,49 @@ let DraftsRemoteService = (() => {
575
665
  // archives ride the SAME write, oldest-first so the order in
576
666
  // the list is append-stable:
577
667
  // 1. SUPERSEDE — a cleared/overwritten result that carried
578
- // outputs is archived (the stroke-40 law, unchanged).
668
+ // outputs is archived (the stroke-40 law, unchanged: a
669
+ // face that would LOSE urls never drops them).
579
670
  // 2. LANDING — the incoming result itself, when it CARRIES
580
- // outputs, enters the trail at its own landing. The old
581
- // supersede-only law kept the CURRENT generation outside
582
- // history (it lived only in the result slot, one
583
- // view-pick away from unreachable). With the landing
584
- // archived, history[0] is ALWAYS the newest gen — no
585
- // pick, overwrite, or crash can orphan it. Live mid-run
586
- // patches ({run_id, progress} — no urls) archive
587
- // nothing, and the url-set dedup makes re-landings of
588
- // the same outputs idempotent.
671
+ // outputs AND the write is not a LIVE face (the
672
+ // settled-landing law, bench-trail W1: the current run's
673
+ // urls never enter history before the card finishes; a
674
+ // mid-run face archives nothing). A result-only patch (no
675
+ // runStatus arm) is a pick/manual spell, not a run face —
676
+ // it lands unless the row reads a live word (the landing
677
+ // gate below).
678
+ // History[0] is ALWAYS the newest SETTLED gen. Live
679
+ // mid-run patches ({run_id, progress} — no urls) archive
680
+ // nothing either way, and the dedup makes re-landings
681
+ // idempotent.
589
682
  const archiveInto = (urls, entry) => {
590
683
  const hist = Array.isArray(stage["history"]) ? [...stage["history"]] : [];
591
- const already = hist.some((e) => sameUrlSet(resultOutputUrls(e), urls));
684
+ // THE ONE-ROW-PER-RUN LAW (bench-trail W2): a run is one
685
+ // history entry — the media route and the gallery proxy
686
+ // spell the same file differently, so identity is the
687
+ // run_id, with the canonical-url set as the no-id fallback
688
+ const entryRunId = typeof entry["run_id"] === "string" ? entry["run_id"] : undefined;
689
+ if (entryRunId !== undefined) {
690
+ const already = hist.some((e) => sameRunIdentity(e["run_id"], entryRunId));
691
+ if (already)
692
+ return;
693
+ }
694
+ const canon = urls.map(canonicalOutputUrl);
695
+ const already = hist.some((e) => sameUrlSet(resultOutputUrls(e).map(canonicalOutputUrl), canon));
592
696
  if (!already) {
593
697
  hist.unshift(entry);
594
698
  stage["history"] = hist.slice(0, 12);
595
699
  }
596
700
  };
701
+ // the landing gate (bench-trail W1 vs the pinned pick-spell
702
+ // contract, remote.spec "empty {} snapshot"): a write that
703
+ // DECLARES a live run face (a non-terminal runStatus arm)
704
+ // never lands; a terminal arm lands; and a patch with NO
705
+ // runStatus arm at all is not a run face — it lands unless the
706
+ // row is mid-run (a live word), which is the stuck-row wound's
707
+ // re-add shape.
708
+ const LIVE_TRAIL_WORDS = new Set(["running", "queued", "pending", "stopping"]);
709
+ const landingTerminal = isTerminalWord(patch["runStatus"]) ||
710
+ (!("runStatus" in patch) && !LIVE_TRAIL_WORDS.has(String(stage["runStatus"])));
597
711
  // the params arm rides only when the snapshot NAMES something —
598
712
  // an undefined or empty {} snapshot carries no key (the chip
599
713
  // diffs against live params; an empty diff is no chip at all)
@@ -610,7 +724,7 @@ let DraftsRemoteService = (() => {
610
724
  stage["result"] = patch["result"];
611
725
  stage["resultParams"] = stage["params"];
612
726
  const landingUrls = resultOutputUrls(next);
613
- if (landingUrls.length > 0) {
727
+ if (landingUrls.length > 0 && landingTerminal) {
614
728
  archiveInto(landingUrls, {
615
729
  ...next,
616
730
  ...paramsArm(stage["resultParams"]),
@@ -634,6 +748,24 @@ let DraftsRemoteService = (() => {
634
748
  // when the body carries no params arm) — judged as half of the
635
749
  // (params, inputs) PAIR before either assignment rides.
636
750
  let pendingParams;
751
+
752
+
753
+ // array (the batch-P corruption family) or boolean body answered
754
+ // delivered:true with a rev bump while merging NOTHING, and the
755
+ // bench verb refuses the identical body. The GUI's keystroke
756
+ // door speaks the bench's voice.
757
+ if ("params" in patch && patch["params"] !== null &&
758
+ (typeof patch["params"] !== "object" || Array.isArray(patch["params"]))) {
759
+ throw new RemoteError("bench/bad-body", "params must be an object", {});
760
+ }
761
+ // WHOLE-MAP DELETE (the type comment's promise, S-E1's second
762
+ // half): the per-key law at map scale — presence IS the write,
763
+ // and a null params value DELETES the whole map. The empty view
764
+ // rides the SAME pair-gate below (a delete can orphan a $spell
765
+ // into a dead wire exactly like a per-key null can).
766
+ const paramsWholeDelete = "params" in patch && patch["params"] === null;
767
+ if (paramsWholeDelete)
768
+ pendingParams = {};
637
769
  if ("params" in patch && patch["params"] !== null && typeof patch["params"] === "object" && !Array.isArray(patch["params"])) {
638
770
  // PER-KEY merge (never replace) — the old door's law, now with
639
771
  // JSON Merge Patch null semantics: a null value DELETES the
@@ -745,7 +877,16 @@ let DraftsRemoteService = (() => {
745
877
  .filter((id) => id !== stageId);
746
878
  admitWiring(stage, pendingParams !== undefined
747
879
  ? pendingParams
748
- : (isObj(stage["params"]) ? stage["params"] : {}), mergedInputs, liveIds, stageId, draftId.replace(/^bench-/, ""));
880
+ : (isObj(stage["params"]) ? stage["params"] : {}), mergedInputs, liveIds, stageId, draftId.replace(/^bench-/, ""),
881
+
882
+ // author seat tolerates the row's PRE wounds — a
883
+ // pre-broken input ref (dead before this patch) must not
884
+ // wedge every later keystroke with a 400 on the GUI's own
885
+ // param door. The tolerate set is built from the PRE row's
886
+ // inputs against the pre-live ids (the assignments below
887
+ // have not landed yet), so a fresh dead ref this patch
888
+ // introduces stays strict.
889
+ toleratedByRow([stage], new Set(liveIds)).get(stageId));
749
890
  }
750
891
  stage["inputs"] = mergedInputs;
751
892
  }
@@ -755,16 +896,30 @@ let DraftsRemoteService = (() => {
755
896
  // the raw one — so a doc written here held a self-naming model
756
897
  // key that add/load never store, and the next reader judged a
757
898
  // different object than the one on disk. Strip once, judge that,
758
- // store that.
759
- stage["params"] = strippedForAdmission(typeof stage["card"] === "string" ? stage["card"] : "", pendingParams);
899
+ // store that. The whole-map delete takes the key out entirely —
900
+ // the row goes bare, the same shape a fresh add carries.
901
+ if (paramsWholeDelete)
902
+ delete stage["params"];
903
+ else
904
+ stage["params"] = strippedForAdmission(typeof stage["card"] === "string" ? stage["card"] : "", pendingParams);
760
905
  }
761
906
  if ("inputOutputs" in patch)
762
907
  stage["inputOutputs"] = patch["inputOutputs"];
763
908
  // EVERY GATE PASSED — the telemetry write lands now (deferred
764
909
  // above so a refusal left no result behind; the run seat's own
765
910
  // polls ride the same door and are judged by nothing, which is
766
- // correct: a poll is not a composition)
767
- writeTelemetry();
911
+ // correct: a poll is not a composition).
912
+ // R6 CURE (round-14 race audit, LOW — telemetry-vs-remove window):
913
+ // the row this section resolved at its top can be REMOVED while
914
+ // the gates ran (remove_stage's resume section between the lookup
915
+ // and this call) — writing into the detached row then persisted
916
+ // the removal with the outcome written nowhere. Re-resolve the
917
+ // LIVE row here, immediately before the write: a still-present
918
+ // row takes the telemetry; a removed row skips the write (its
919
+ // next poll reads the honest delivered:false at the top lookup).
920
+ const liveStage = doc["stages"].find((s) => typeof s === "object" && s !== null && s["id"] === stageId);
921
+ if (liveStage !== undefined)
922
+ writeTelemetry(liveStage);
768
923
  // THE BUMP (author patches only — the telemetry law above): the
769
924
  // rev advances and the log carries the entry; a telemetry write
770
925
  // persists the row WITHOUT touching the bench block (no bump, no
@@ -899,69 +1054,86 @@ let BenchRemoteService = (() => {
899
1054
  async *subscribe(sessionKey, signal) {
900
1055
  signal.throwIfAborted();
901
1056
  for (;;) {
902
- // THE ZERO-STATE LAW (the stream tail's birth watch): a fresh
903
- // conversation's bench does not exist until its first write.
904
- // The gateway's RemoteStream classifies a REJECTED open as
905
- // TERMINAL (no retry — terminalStreamFailure), so a throwing
906
- // first read here would kill the browser's whole bench stream
907
- // the moment a conversation starts. The watch instead serves
908
- // the ZERO STATE (rev 0, empty stages — the same row shape the
909
- // client's emptyBench served in the poll era) and stays open:
910
- // the birth (ensure's notifyBenchChange) wakes the park and the
911
- // next read serves the born bench. `bench/get` still refuses
912
- // not-found for direct unary callers — only the WATCH degrades.
913
- let row;
1057
+
1058
+ // watch attaches BEFORE the read, not after the yield. A write
1059
+ // landing while the consumer still holds the delivered row (the
1060
+ // generator suspended at the yield — no park listener attached)
1061
+ // once matched no listener and the park slept until the NEXT
1062
+ // write; the flag re-loops at once instead of parking past it.
1063
+ let changedMidFlight = false;
1064
+ const offWatch = onBenchChange((session) => {
1065
+ if (session === sessionKey)
1066
+ changedMidFlight = true;
1067
+ });
914
1068
  try {
915
- row = await this.get(sessionKey, signal);
916
- }
917
- catch (error) {
918
- if (error instanceof RemoteError &&
919
- error.code === "bench/not-found") {
920
- row = {
921
- draft_id: benchDraftIdOf(sessionKey),
922
- session: sessionKey,
923
- rev: 0,
924
- intent: "",
925
- stages: [],
926
- log: [],
927
- };
928
- }
929
- else {
930
- throw error;
1069
+ // THE ZERO-STATE LAW (the stream tail's birth watch): a fresh
1070
+ // conversation's bench does not exist until its first write.
1071
+ // The gateway's RemoteStream classifies a REJECTED open as
1072
+ // TERMINAL (no retry — terminalStreamFailure), so a throwing
1073
+ // first read here would kill the browser's whole bench stream
1074
+ // the moment a conversation starts. The watch instead serves
1075
+ // the ZERO STATE (rev 0, empty stages — the same row shape the
1076
+ // client's emptyBench served in the poll era) and stays open:
1077
+ // the birth (ensure's notifyBenchChange) wakes the park and the
1078
+ // next read serves the born bench. `bench/get` still refuses
1079
+ // not-found for direct unary callers — only the WATCH degrades.
1080
+ let row;
1081
+ try {
1082
+ row = await this.get(sessionKey, signal);
931
1083
  }
932
- }
933
- yield row;
934
- // wake on the session's next write (or abort). An abort that
935
- // landed while the generator was suspended AT A YIELD has no
936
- // listener attached — the executor re-checks signal.aborted so
937
- // the missed event still wakes the park. The stream then ends
938
- // CLEAN on abort (done: true — the holder released; a throw
939
- // would surface as an error on a normal close). One finish arm
940
- // removes BOTH listeners: no stale abort listener per park.
941
- await new Promise((resolve) => {
942
- if (signal.aborted) {
943
- resolve();
944
- return;
1084
+ catch (error) {
1085
+ if (error instanceof RemoteError &&
1086
+ error.code === "bench/not-found") {
1087
+ row = {
1088
+ draft_id: benchDraftIdOf(sessionKey),
1089
+ session: sessionKey,
1090
+ rev: 0,
1091
+ intent: "",
1092
+ stages: [],
1093
+ log: [],
1094
+ };
1095
+ }
1096
+ else {
1097
+ throw error;
1098
+ }
945
1099
  }
946
- // One finish arm, no re-entry guard needed: finish detaches
947
- // BOTH listeners before resolving, so it can never run twice
948
- // (the old settled-guard was unreachable by construction).
949
- const offAbort = () => { signal.removeEventListener("abort", onAbort); };
950
- const finish = () => {
951
- offChange();
952
- offAbort();
953
- resolve();
954
- };
955
- const offChange = onBenchChange((session) => {
956
- if (session !== sessionKey)
1100
+ yield row;
1101
+ // wake on the session's next write (or abort). An abort that
1102
+ // landed while the generator was suspended AT A YIELD has no
1103
+ // park listener attached — the executor re-checks
1104
+ // signal.aborted AND the mid-flight watch flag (S-S3) so the
1105
+ // missed event still wakes the park. The stream then ends
1106
+ // CLEAN on abort (done: true — the holder released; a throw
1107
+ // would surface as an error on a normal close). One finish arm
1108
+ // removes BOTH listeners: no stale abort listener per park.
1109
+ await new Promise((resolve) => {
1110
+ if (signal.aborted || changedMidFlight) {
1111
+ resolve();
957
1112
  return;
958
- finish();
1113
+ }
1114
+ // One finish arm, no re-entry guard needed: finish detaches
1115
+ // BOTH listeners before resolving, so it can never run twice
1116
+ // (the old settled-guard was unreachable by construction).
1117
+ const offAbort = () => { signal.removeEventListener("abort", onAbort); };
1118
+ const finish = () => {
1119
+ offChange();
1120
+ offAbort();
1121
+ resolve();
1122
+ };
1123
+ const offChange = onBenchChange((session) => {
1124
+ if (session !== sessionKey)
1125
+ return;
1126
+ finish();
1127
+ });
1128
+ const onAbort = () => { finish(); };
1129
+ signal.addEventListener("abort", onAbort, { once: true });
959
1130
  });
960
- const onAbort = () => { finish(); };
961
- signal.addEventListener("abort", onAbort, { once: true });
962
- });
963
- if (signal.aborted)
964
- return;
1131
+ if (signal.aborted)
1132
+ return;
1133
+ }
1134
+ finally {
1135
+ offWatch();
1136
+ }
965
1137
  }
966
1138
  }
967
1139
  /** Endpoint `bench/ensure` — birth or load (the only creating arm). */
@@ -996,7 +1168,8 @@ let BenchRemoteService = (() => {
996
1168
  catch (error) {
997
1169
  throw new RemoteError("bench/store-failed", thrownMessage(error), {}, { cause: error });
998
1170
  }
999
- const row = await ensureBench(drafts, doc, benchAdmissionOf(this.ctx));
1171
+ const session = typeof doc["session"] === "string" ? doc["session"] : "";
1172
+ const row = await withBenchWrite(benchDraftIdOf(session), () => ensureBench(drafts, doc, benchAdmissionOf(this.ctx)));
1000
1173
  // ensureBench gates doc.session as a valid key before this line —
1001
1174
  // the row's own session can never differ here (no dead fallback).
1002
1175
  notifyBenchChange(String(doc["session"]));
@@ -1030,27 +1203,33 @@ let BenchRemoteService = (() => {
1030
1203
 
1031
1204
  async remoteExportReorder(sessionKey, doc, signal) {
1032
1205
  signal.throwIfAborted();
1033
- const drafts = await this.open(signal);
1034
- const row = await reorderBench(drafts, sessionKey, doc);
1035
- notifyBenchChange(sessionKey);
1036
- return row;
1206
+ return withBenchWrite(benchDraftIdOf(sessionKey), async () => {
1207
+ const drafts = await this.open(signal);
1208
+ const row = await reorderBench(drafts, sessionKey, doc);
1209
+ notifyBenchChange(sessionKey);
1210
+ return row;
1211
+ });
1037
1212
  }
1038
1213
 
1039
1214
 
1040
1215
  async remoteExportGroup(sessionKey, doc, signal) {
1041
1216
  signal.throwIfAborted();
1042
- const drafts = await this.open(signal);
1043
- const row = await groupBench(drafts, sessionKey, doc, benchAdmissionOf(this.ctx));
1044
- notifyBenchChange(sessionKey);
1045
- return row;
1217
+ return withBenchWrite(benchDraftIdOf(sessionKey), async () => {
1218
+ const drafts = await this.open(signal);
1219
+ const row = await groupBench(drafts, sessionKey, doc, benchAdmissionOf(this.ctx));
1220
+ notifyBenchChange(sessionKey);
1221
+ return row;
1222
+ });
1046
1223
  }
1047
1224
  /** Endpoint `bench/ungroup` — spill a compound's children back. */
1048
1225
  async remoteExportUngroup(sessionKey, doc, signal) {
1049
1226
  signal.throwIfAborted();
1050
- const drafts = await this.open(signal);
1051
- const row = await ungroupBench(drafts, sessionKey, doc, benchAdmissionOf(this.ctx));
1052
- notifyBenchChange(sessionKey);
1053
- return row;
1227
+ return withBenchWrite(benchDraftIdOf(sessionKey), async () => {
1228
+ const drafts = await this.open(signal);
1229
+ const row = await ungroupBench(drafts, sessionKey, doc, benchAdmissionOf(this.ctx));
1230
+ notifyBenchChange(sessionKey);
1231
+ return row;
1232
+ });
1054
1233
  }
1055
1234
  /** Endpoint `bench/undo` — walk the log tail, apply inverses. */
1056
1235
  async remoteExportUndo(sessionKey, doc, signal) {
@@ -1070,45 +1249,57 @@ let BenchRemoteService = (() => {
1070
1249
  }
1071
1250
  async patch(session, doc, signal) {
1072
1251
  signal.throwIfAborted();
1073
- const drafts = await this.open(signal);
1074
- const row = await patchBench(drafts, session, doc, benchAdmissionOf(this.ctx));
1075
- notifyBenchChange(session);
1076
- return row;
1252
+ return withBenchWrite(benchDraftIdOf(session), async () => {
1253
+ const drafts = await this.open(signal);
1254
+ const row = await patchBench(drafts, session, doc, benchAdmissionOf(this.ctx), laneMigrationOf(this.ctx));
1255
+ notifyBenchChange(session);
1256
+ return row;
1257
+ });
1077
1258
  }
1078
1259
  async addStage(session, doc, signal) {
1079
1260
  signal.throwIfAborted();
1080
- const drafts = await this.open(signal);
1081
- const row = await addStageBench(drafts, session, doc, benchAdmissionOf(this.ctx));
1082
- notifyBenchChange(session);
1083
- return row;
1261
+ return withBenchWrite(benchDraftIdOf(session), async () => {
1262
+ const drafts = await this.open(signal);
1263
+ const row = await addStageBench(drafts, session, doc, benchAdmissionOf(this.ctx));
1264
+ notifyBenchChange(session);
1265
+ return row;
1266
+ });
1084
1267
  }
1085
1268
  async removeStage(session, doc, signal) {
1086
1269
  signal.throwIfAborted();
1087
- const drafts = await this.open(signal);
1088
- const row = await removeStageBench(drafts, session, doc);
1089
- notifyBenchChange(session);
1090
- return row;
1270
+ return withBenchWrite(benchDraftIdOf(session), async () => {
1271
+ const drafts = await this.open(signal);
1272
+ const row = await removeStageBench(drafts, session, doc);
1273
+ notifyBenchChange(session);
1274
+ return row;
1275
+ });
1091
1276
  }
1092
1277
  async clear(session, doc, signal) {
1093
1278
  signal.throwIfAborted();
1094
- const drafts = await this.open(signal);
1095
- const row = await clearBench(drafts, session, doc);
1096
- notifyBenchChange(session);
1097
- return row;
1279
+ return withBenchWrite(benchDraftIdOf(session), async () => {
1280
+ const drafts = await this.open(signal);
1281
+ const row = await clearBench(drafts, session, doc);
1282
+ notifyBenchChange(session);
1283
+ return row;
1284
+ });
1098
1285
  }
1099
1286
  async rewire(session, doc, signal) {
1100
1287
  signal.throwIfAborted();
1101
- const drafts = await this.open(signal);
1102
- const row = await rewireBench(drafts, session, doc);
1103
- notifyBenchChange(session);
1104
- return row;
1288
+ return withBenchWrite(benchDraftIdOf(session), async () => {
1289
+ const drafts = await this.open(signal);
1290
+ const row = await rewireBench(drafts, session, doc);
1291
+ notifyBenchChange(session);
1292
+ return row;
1293
+ });
1105
1294
  }
1106
1295
  async undo(session, doc, signal) {
1107
1296
  signal.throwIfAborted();
1108
- const drafts = await this.open(signal);
1109
- const row = await undoBench(drafts, session, doc, benchAdmissionOf(this.ctx));
1110
- notifyBenchChange(session);
1111
- return row;
1297
+ return withBenchWrite(benchDraftIdOf(session), async () => {
1298
+ const drafts = await this.open(signal);
1299
+ const row = await undoBench(drafts, session, doc, benchAdmissionOf(this.ctx));
1300
+ notifyBenchChange(session);
1301
+ return row;
1302
+ });
1112
1303
  }
1113
1304
  };
1114
1305
  })();