@melite/estate-drafts 0.0.0-stage → 0.1.4-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/LICENSE +557 -0
- package/lib/bench.d.ts +329 -0
- package/lib/bench.js +1866 -0
- package/lib/index.d.ts +12 -0
- package/lib/index.js +13 -0
- package/lib/remote.d.ts +306 -0
- package/lib/remote.js +1116 -0
- package/lib/spec.d.ts +61 -0
- package/lib/spec.js +57 -0
- package/lib/store.d.ts +12 -0
- package/lib/store.js +106 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1013 -0
- package/lib/typert.remote-client.d.ts +54 -0
- package/lib/typert.remote-client.js +732 -0
- package/lib/types.d.ts +3 -0
- package/lib/types.js +2 -0
- package/package.json +61 -4
- package/README.md +0 -3
package/lib/bench.js
ADDED
|
@@ -0,0 +1,1866 @@
|
|
|
1
|
+
|
|
2
|
+
//
|
|
3
|
+
// The bench doc IS a draft doc (same domain, same table, id
|
|
4
|
+
// "bench-<session>") plus a `bench` block: { rev, log } — the
|
|
5
|
+
// append-only revision log both authors walk. HTTP vocabulary is
|
|
6
|
+
// dead with the gateway: birth/load refuse as RemoteError codes,
|
|
7
|
+
// never status payloads.
|
|
8
|
+
//
|
|
9
|
+
// Laws carried over (the bench rework's, unchanged):
|
|
10
|
+
// CAS — ensure-with-stages takes expect_rev; a moved bench refuses
|
|
11
|
+
// loud (bench/cas-conflict carries current_rev), never merges.
|
|
12
|
+
// APPEND-ONLY LOG — load appends a revert-carrying entry; history
|
|
13
|
+
// is never rewritten (patch/undo arms land in later strokes).
|
|
14
|
+
// AUTHORSHIP — every entry carries author ("agent"|"user").
|
|
15
|
+
import { unwrapItemArrays } from '@melite/comfyui-card-kit';
|
|
16
|
+
import { benchDraftIdOf, BIRTH_STRIP_KEYS, LOAD_CARRY_KEYS, normalizeBenchStage, SELF_NAMED_FAMILIES, stripKeys } from '@melite/contracts/benchStage';
|
|
17
|
+
import { RemoteError } from "@deepseek-ai/dsh-typert-protocol";
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
import { BENCH_DRAFT_RE, RESTORED_PROVENANCE_SPELLING, SESSION_RE } from "@melite/contracts/benchStage";
|
|
29
|
+
import { RESTORED_PROVENANCE } from "./remote.js";
|
|
30
|
+
export { SESSION_RE };
|
|
31
|
+
function benchIdOf(session) {
|
|
32
|
+
return benchDraftIdOf(session);
|
|
33
|
+
}
|
|
34
|
+
const AUTHORS = ["agent", "user"];
|
|
35
|
+
function authorOk(v) {
|
|
36
|
+
return v === "agent" || v === "user";
|
|
37
|
+
}
|
|
38
|
+
function isRecord(v) {
|
|
39
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
40
|
+
}
|
|
41
|
+
export function benchBlockOf(doc) {
|
|
42
|
+
const raw = doc["bench"];
|
|
43
|
+
if (isRecord(raw) &&
|
|
44
|
+
typeof raw["rev"] === "number" &&
|
|
45
|
+
Array.isArray(raw["log"])) {
|
|
46
|
+
return { rev: raw["rev"], log: raw["log"] };
|
|
47
|
+
}
|
|
48
|
+
return { rev: 0, log: [] };
|
|
49
|
+
}
|
|
50
|
+
function stagesOf(doc) {
|
|
51
|
+
// The domain schema promises stages is an array of id-carrying
|
|
52
|
+
// objects (DraftDocSchema), but nothing enforces the promise on
|
|
53
|
+
// this path — a doc written behind the domain's back (an older
|
|
54
|
+
// door, a hand edit) reads back as it was written, and a reader
|
|
55
|
+
// that trusted the promise crashed on a null row. The read
|
|
56
|
+
// projection owns the guarantee: only record rows ride, and a
|
|
57
|
+
// row's id is read where it is used, never assumed.
|
|
58
|
+
const stages = doc["stages"];
|
|
59
|
+
if (!Array.isArray(stages))
|
|
60
|
+
return [];
|
|
61
|
+
return stages.filter((s) => isRecord(s));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
function seedStateOf(row) {
|
|
71
|
+
// Post-normalize rows ALWAYS carry a record params (normalizeStages
|
|
72
|
+
// replaces junk/missing params with {}) — no re-defense here.
|
|
73
|
+
const seed = row["params"]["seed"];
|
|
74
|
+
if (typeof seed === "number" && seed !== -1)
|
|
75
|
+
return `${row["id"]} seed ${seed} (pinned)`;
|
|
76
|
+
if (seed === -1)
|
|
77
|
+
return `${row["id"]} seed random`;
|
|
78
|
+
return `${row["id"]} seed absent`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
export function mintStageId(rows, body) {
|
|
95
|
+
if (typeof body["id"] === "string" && body["id"] !== "")
|
|
96
|
+
return body["id"];
|
|
97
|
+
return nextStageId(highestStageSuffix(rows), takenStageIds(rows));
|
|
98
|
+
}
|
|
99
|
+
/** THE HIGHEST `s_<n>` ON THE BENCH, or -1 when there is none (the
|
|
100
|
+
* ONE arithmetic; both id producers take it).
|
|
101
|
+
*
|
|
102
|
+
* It was written twice — once in the write door's mint and once in
|
|
103
|
+
* the load path's dedupe — and the identity wall caught the second
|
|
104
|
+
* copy on its first run, which is the wall doing precisely its job.
|
|
105
|
+
* A formula that decides what a stage is called belongs in one
|
|
106
|
+
* place; the two callers differ only in what they do with the
|
|
107
|
+
* answer. */
|
|
108
|
+
function highestStageSuffix(rows) {
|
|
109
|
+
let max = -1;
|
|
110
|
+
for (const row of rows) {
|
|
111
|
+
const m = /^s_(\d+)$/.exec(String(row["id"] ?? ""));
|
|
112
|
+
if (m !== null && Number(m[1]) > max)
|
|
113
|
+
max = Number(m[1]);
|
|
114
|
+
}
|
|
115
|
+
return max;
|
|
116
|
+
}
|
|
117
|
+
/** The next free `s_<n>` at or above `max + 1`, skipping any id the
|
|
118
|
+
* caller already holds. The writer's mint and the loader's repair
|
|
119
|
+
* both ask this, so "the next id" means one thing in this file. */
|
|
120
|
+
function nextStageId(max, taken) {
|
|
121
|
+
let n = max;
|
|
122
|
+
let fresh;
|
|
123
|
+
do {
|
|
124
|
+
n += 1;
|
|
125
|
+
fresh = "s_" + String(n);
|
|
126
|
+
} while (taken.has(fresh));
|
|
127
|
+
return fresh;
|
|
128
|
+
}
|
|
129
|
+
/** The ids already spoken for on the bench — every stage's own. A
|
|
130
|
+
* mint that skips these cannot collide with a row the caller has
|
|
131
|
+
* not seen yet. */
|
|
132
|
+
function takenStageIds(rows) {
|
|
133
|
+
const taken = new Set();
|
|
134
|
+
for (const row of rows) {
|
|
135
|
+
const id = row["id"];
|
|
136
|
+
if (typeof id === "string")
|
|
137
|
+
taken.add(id);
|
|
138
|
+
}
|
|
139
|
+
return taken;
|
|
140
|
+
}
|
|
141
|
+
/** Birth defaults per the composer's law (id, inputs, provenance)
|
|
142
|
+
* with the runner's object inputs. */
|
|
143
|
+
export function normalizeStages(stagesIn, author) {
|
|
144
|
+
if (!Array.isArray(stagesIn))
|
|
145
|
+
return [];
|
|
146
|
+
const kept = stagesIn.filter((s) => isRecord(s));
|
|
147
|
+
// which rows arrived with their OWN id (the dedupe's pass-1 set)
|
|
148
|
+
const arrivedWithId = kept.map((s) => typeof s["id"] === "string");
|
|
149
|
+
const normalized = kept
|
|
150
|
+
.map((s, idx) => {
|
|
151
|
+
// THE PARAMS-SHAPE REFUSAL (commission 015, §6 finding 5):
|
|
152
|
+
// normalizeBenchStage heals a non-record params to {} — the
|
|
153
|
+
// right law for a doc already on disk, but at the WRITE door
|
|
154
|
+
// that heal minted a stage from the card's knobs WRAPPED IN AN
|
|
155
|
+
// ARRAY with empty params instead of refusing, the bench door
|
|
156
|
+
// looser than the card it stages. The judgment runs on the
|
|
157
|
+
// UNWRAPPED value (the wire's `{item:[…]}` spelling resolves to
|
|
158
|
+
// the same bare array); the add and patch doors carry the same
|
|
159
|
+
// law.
|
|
160
|
+
const rawParams = wireParams(s["params"]);
|
|
161
|
+
if (rawParams !== undefined && !isRecord(rawParams)) {
|
|
162
|
+
const named = s["id"];
|
|
163
|
+
throw new RemoteError("bench/bad-body", "stage " + (typeof named === "string" && named !== "" ? named : "#" + idx) +
|
|
164
|
+
": params must be an object (got " + (Array.isArray(rawParams) ? "an array — the card's knobs, not a list of them" : typeof rawParams) +
|
|
165
|
+
"); a wire that cannot be read is refused here, not healed", {});
|
|
166
|
+
}
|
|
167
|
+
// THE ONE NORMALIZATION (benchStage.ts — the contract): id
|
|
168
|
+
// fill, DERIVED engine, run-memory strip, run-media path heal,
|
|
169
|
+
// the self-naming model strip, inputs/params/outputs defaults.
|
|
170
|
+
|
|
171
|
+
// GUI's row, the agent's row and a restored row became three
|
|
172
|
+
// different documents.
|
|
173
|
+
const row = normalizeBenchStage(s, idx);
|
|
174
|
+
// the load arm's rows cross the same remote encoding — the
|
|
175
|
+
// store-seat unwrap applies here too (wireParams law)
|
|
176
|
+
row["params"] = wireParams(row["params"]);
|
|
177
|
+
|
|
178
|
+
// audit's CRITICAL-1): provenance 'restored' is how the model
|
|
179
|
+
// REPAIR law knows a row came off an archive. Unconditionally
|
|
180
|
+
// stamping the author here erased it on every load, so a
|
|
181
|
+
// design-era row (params.model naming the card, an unboarded
|
|
182
|
+
// family) refused at its next admission with no repair — the
|
|
183
|
+
// one job the coordinate exists for. The author's own rows are
|
|
184
|
+
// stamped as before; a restored row keeps its mark.
|
|
185
|
+
// THE COORDINATE HAS TWO SPELLINGS, AND BOTH ARE KEPT (the
|
|
186
|
+
|
|
187
|
+
// to judge its stored rows: the stamp respected the WIRE string
|
|
188
|
+
// and silently overwrote the in-process SYMBOL, so a
|
|
189
|
+
// server-armed restore lost its mark on the very path that
|
|
190
|
+
// exists to carry it). A coordinate with one spelling on the
|
|
191
|
+
// wire and another in memory is two facts wearing one name —
|
|
192
|
+
// the stamp respects both, and the door that mints the symbol
|
|
193
|
+
// is the only producer of it.
|
|
194
|
+
if (row["provenance"] !== RESTORED_PROVENANCE_SPELLING
|
|
195
|
+
&& row["provenance"] !== RESTORED_PROVENANCE) {
|
|
196
|
+
row["provenance"] = author;
|
|
197
|
+
}
|
|
198
|
+
return row;
|
|
199
|
+
});
|
|
200
|
+
// THE SECOND PASS (the identity audit's A3). The map above is
|
|
201
|
+
// left exactly as it was; the uniqueness law is a separate step so
|
|
202
|
+
// the contract's positional fill stays the contract's.
|
|
203
|
+
return dedupeStageIds(normalized, arrivedWithId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
function dedupeStageIds(rows, arrivedWithId) {
|
|
225
|
+
// PASS 1 — the author's ids, reserved before anything is moved. An
|
|
226
|
+
// explicit id is never the one that gives way.
|
|
227
|
+
const named = [];
|
|
228
|
+
for (const [i, row] of rows.entries()) {
|
|
229
|
+
if (arrivedWithId[i] === true && typeof row["id"] === "string")
|
|
230
|
+
named.push(row);
|
|
231
|
+
}
|
|
232
|
+
const taken = takenStageIds(named);
|
|
233
|
+
// PASS 2 — only a FILLED id may be moved, and only when it is
|
|
234
|
+
// already spoken for. The next free id comes from the same
|
|
235
|
+
// arithmetic the write door mints with (highestStageSuffix), so a
|
|
236
|
+
// load and an add cannot disagree about what "next" means.
|
|
237
|
+
let max = highestStageSuffix(named);
|
|
238
|
+
for (const [i, row] of rows.entries()) {
|
|
239
|
+
if (arrivedWithId[i] === true)
|
|
240
|
+
continue;
|
|
241
|
+
// the id here is normalizeBenchStage's fill — always a string
|
|
242
|
+
// (a doc's non-string id heals to the minted form before this
|
|
243
|
+
// pass, so only a filled id rides)
|
|
244
|
+
const id = row["id"];
|
|
245
|
+
if (!taken.has(id)) {
|
|
246
|
+
taken.add(id);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
let fresh;
|
|
250
|
+
do {
|
|
251
|
+
max += 1;
|
|
252
|
+
fresh = "s_" + String(max);
|
|
253
|
+
} while (taken.has(fresh));
|
|
254
|
+
row["id"] = fresh;
|
|
255
|
+
taken.add(fresh);
|
|
256
|
+
}
|
|
257
|
+
return rows;
|
|
258
|
+
}
|
|
259
|
+
const ENSURE_KEYS = ["session", "stages", "intent", "author", "expect_rev", "overwrite"];
|
|
260
|
+
function refuseUnknown(body, allowed) {
|
|
261
|
+
const unknown = Object.keys(body).filter((k) => !allowed.includes(k));
|
|
262
|
+
if (unknown.length > 0) {
|
|
263
|
+
throw new RemoteError("bench/bad-body", "unknown bench key(s): " +
|
|
264
|
+
unknown.sort().join(", ") +
|
|
265
|
+
" (want " +
|
|
266
|
+
allowed.join(", ") +
|
|
267
|
+
")", {});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** ensure — create the bench (birth: rev 0, empty log) or LOAD onto
|
|
271
|
+
* it (replace stages, snapshot inverse; a non-empty bench demands
|
|
272
|
+
* overwrite: true — the overwrite law). The only arm that may
|
|
273
|
+
* create; everything else refuses a missing bench. */
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
function syncIntent(doc, before, after) {
|
|
292
|
+
const cur = typeof doc["intent"] === "string" ? doc["intent"] : "";
|
|
293
|
+
if (cur === "" || cur === "bench")
|
|
294
|
+
return;
|
|
295
|
+
const prevCards = new Set();
|
|
296
|
+
for (const row of before) {
|
|
297
|
+
const c = row["card"];
|
|
298
|
+
if (typeof c === "string" && c !== "")
|
|
299
|
+
prevCards.add(c);
|
|
300
|
+
}
|
|
301
|
+
if (!prevCards.has(cur))
|
|
302
|
+
return;
|
|
303
|
+
const nextCards = new Set();
|
|
304
|
+
for (const row of after) {
|
|
305
|
+
const c = row["card"];
|
|
306
|
+
if (typeof c === "string" && c !== "")
|
|
307
|
+
nextCards.add(c);
|
|
308
|
+
}
|
|
309
|
+
if (nextCards.has(cur))
|
|
310
|
+
return;
|
|
311
|
+
for (let i = after.length - 1; i >= 0; i--) {
|
|
312
|
+
const c = after[i]["card"];
|
|
313
|
+
if (typeof c === "string" && c !== "") {
|
|
314
|
+
doc["intent"] = c;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
export async function ensureBench(drafts, body, admit) {
|
|
320
|
+
refuseUnknown(body, ENSURE_KEYS);
|
|
321
|
+
const session = body["session"];
|
|
322
|
+
if (typeof session !== "string" || !SESSION_RE.test(session)) {
|
|
323
|
+
throw new RemoteError("bench/bad-session", "bad session key (want [A-Za-z0-9_-]{1,64})", {});
|
|
324
|
+
}
|
|
325
|
+
const draftId = benchIdOf(session);
|
|
326
|
+
const author = authorOk(body["author"])
|
|
327
|
+
? body["author"]
|
|
328
|
+
: "agent";
|
|
329
|
+
const rec = drafts.table("docs").get(draftId);
|
|
330
|
+
if (rec === undefined) {
|
|
331
|
+
const stagesIn = Array.isArray(body["stages"]) ? body["stages"] : [];
|
|
332
|
+
// THE JUDGE SEES THE STORED ROW — BIRTH TOO (the walls audit,
|
|
333
|
+
|
|
334
|
+
// the RAW body and then stored the NORMALIZED rows, so the
|
|
335
|
+
// "shaped first, then judged" comment above was false for the
|
|
336
|
+
// birth path. Two different objects were in play: a raw body row
|
|
337
|
+
// was judged, and a normalized one was stored — the exact law the
|
|
338
|
+
// load arm had already been fixed for. The birth arm now judges
|
|
339
|
+
// `born`, the very rows it is about to put on disk.
|
|
340
|
+
const born = normalizeStages(stagesIn, author);
|
|
341
|
+
for (const row of born)
|
|
342
|
+
stripKeys(row, BIRTH_STRIP_KEYS);
|
|
343
|
+
admitStages(admit, born);
|
|
344
|
+
// A BENCH IS BORN CLEAN OF ARCHIVES (the cross-bench history
|
|
345
|
+
|
|
346
|
+
// wand restores onto fresh sessions carrying stage rows from a
|
|
347
|
+
// FOREIGN bench's run snapshot; archives (history,
|
|
348
|
+
// resultParams, runStatus, resolved — the observe state THIS
|
|
349
|
+
// bench's own runs earn) never survive a birth. The one lawful
|
|
350
|
+
// birth memory is `result` — the wand's preview stamp for the
|
|
351
|
+
// ran card. The LOAD arm keeps the stage memory law whole
|
|
352
|
+
// (same-id carry + fill-never-clobber).
|
|
353
|
+
admitWiringList(born, session);
|
|
354
|
+
const doc = {
|
|
355
|
+
draft_id: draftId,
|
|
356
|
+
intent: typeof body["intent"] === "string" ? body["intent"] : "bench",
|
|
357
|
+
stages: born,
|
|
358
|
+
status: "ready",
|
|
359
|
+
createdAt: Date.now(),
|
|
360
|
+
completedAt: null,
|
|
361
|
+
bench: { rev: 0, log: [] },
|
|
362
|
+
};
|
|
363
|
+
await drafts.table("docs").put(draftId, {
|
|
364
|
+
updated_at: Math.floor(Date.now() / 1000),
|
|
365
|
+
doc: doc,
|
|
366
|
+
});
|
|
367
|
+
return {
|
|
368
|
+
draft_id: draftId,
|
|
369
|
+
session,
|
|
370
|
+
rev: 0,
|
|
371
|
+
intent: doc["intent"],
|
|
372
|
+
stages: doc["stages"],
|
|
373
|
+
created: true,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const doc = rec.doc;
|
|
377
|
+
const block = benchBlockOf(doc);
|
|
378
|
+
if (body["stages"] === undefined) {
|
|
379
|
+
return {
|
|
380
|
+
draft_id: draftId,
|
|
381
|
+
session,
|
|
382
|
+
rev: block.rev,
|
|
383
|
+
intent: doc["intent"],
|
|
384
|
+
stages: doc["stages"],
|
|
385
|
+
created: false,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const expect = body["expect_rev"];
|
|
389
|
+
if (typeof expect !== "number") {
|
|
390
|
+
throw new RemoteError("bench/cas-conflict", "expect_rev is required (the CAS law)", { current_rev: block.rev });
|
|
391
|
+
}
|
|
392
|
+
if (expect !== block.rev) {
|
|
393
|
+
throw new RemoteError("bench/cas-conflict", "bench moved: rev is " + block.rev + ", caller held " + expect, { current_rev: block.rev, expect_rev: expect });
|
|
394
|
+
}
|
|
395
|
+
const before = stagesOf(doc);
|
|
396
|
+
|
|
397
|
+
// image card with a wholesale load): replacing a NON-EMPTY bench
|
|
398
|
+
// is a destructive act and must say so. expect_rev proves the
|
|
399
|
+
// caller read the bench, not that the user agreed to lose it —
|
|
400
|
+
// pass overwrite: true to confirm (flow landings and the
|
|
401
|
+
// gallery's draft-click already do; the undo log still records
|
|
402
|
+
// stages_before either way).
|
|
403
|
+
if (before.length > 0 && body["overwrite"] !== true) {
|
|
404
|
+
throw new RemoteError("bench/overwrite-required", "bench holds " + before.length + " stage(s) at rev " + block.rev +
|
|
405
|
+
" — a wholesale load would discard them; confirm with overwrite: true, " +
|
|
406
|
+
"or grow the bench with add_stage / patch, or undo if this was a mistake", { current_rev: block.rev, stages: before.length });
|
|
407
|
+
}
|
|
408
|
+
// (the no-leak law: admit on the LOCAL normalized list, assign to
|
|
409
|
+
// the cached doc only after the gates pass.)
|
|
410
|
+
const staged = normalizeStages(body["stages"], author);
|
|
411
|
+
// THE JUDGED VIEW IS THE STORED VIEW (the bench audit's gap 11,
|
|
412
|
+
|
|
413
|
+
// normalized rows — the unwrap, the media heal and the self-naming
|
|
414
|
+
// strip all landed AFTER the judgment, so a body the normalizer
|
|
415
|
+
// would have healed could still be refused. The rows are shaped
|
|
416
|
+
// first, then judged — the order add uses.
|
|
417
|
+
admitStages(admit, staged);
|
|
418
|
+
|
|
419
|
+
// does it never track history?"): a stage's OUTPUT MEMORY — its
|
|
420
|
+
// landed result and archived history — belongs to the STAGE'S
|
|
421
|
+
// IDENTITY, not to the doc revision it happened to land in. A
|
|
422
|
+
// wholesale load rebuilding the bench with the SAME stage ids
|
|
423
|
+
// (a chat agent re-loading the card the operator is iterating
|
|
424
|
+
// on) CARRIES the memory forward: result, runStatus, history,
|
|
425
|
+
// resultParams ride from the outgoing row onto the incoming row
|
|
426
|
+
// of the same id. A genuinely new stage id starts clean. The
|
|
427
|
+
// bench is composed state; the gallery is the stage's own.
|
|
428
|
+
// THE LOAD ARM'S CARRY LIST (the contract's, benchStage.ts): a
|
|
429
|
+
// wholesale load onto the SAME stage ids carries the memory the
|
|
430
|
+
// bench itself earned — the stage's own result/status/history/
|
|
431
|
+
// resultParams. Named in the contract beside the birth strip (the
|
|
432
|
+
// two are complements: birth STRIPS, load CARRIES) so the two arms
|
|
433
|
+
// can never be edited apart.
|
|
434
|
+
const MEMORY_KEYS = LOAD_CARRY_KEYS;
|
|
435
|
+
for (const row of staged) {
|
|
436
|
+
const prev = before.find((b) => b["id"] === row["id"]);
|
|
437
|
+
if (prev === undefined)
|
|
438
|
+
continue;
|
|
439
|
+
for (const key of MEMORY_KEYS) {
|
|
440
|
+
if (prev[key] !== undefined && row[key] === undefined) {
|
|
441
|
+
row[key] = prev[key];
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
admitWiringList(staged, session);
|
|
446
|
+
doc["stages"] = staged;
|
|
447
|
+
syncIntent(doc, before, staged);
|
|
448
|
+
if (typeof body["intent"] === "string")
|
|
449
|
+
doc["intent"] = body["intent"];
|
|
450
|
+
const entry = {
|
|
451
|
+
author,
|
|
452
|
+
kind: "load",
|
|
453
|
+
summary: "load " + stagesOf(doc).length + " stages | " + staged.map(seedStateOf).join(" | "),
|
|
454
|
+
inverse: { stages_before: before },
|
|
455
|
+
rev: block.rev + 1,
|
|
456
|
+
at: Math.floor(Date.now() / 1000),
|
|
457
|
+
};
|
|
458
|
+
block.rev = entry.rev;
|
|
459
|
+
block.log.push(entry);
|
|
460
|
+
doc["bench"] = block;
|
|
461
|
+
await drafts.table("docs").put(draftId, {
|
|
462
|
+
updated_at: Math.floor(Date.now() / 1000),
|
|
463
|
+
doc: doc,
|
|
464
|
+
});
|
|
465
|
+
return {
|
|
466
|
+
draft_id: draftId,
|
|
467
|
+
session,
|
|
468
|
+
rev: block.rev,
|
|
469
|
+
intent: doc["intent"],
|
|
470
|
+
stages: doc["stages"],
|
|
471
|
+
created: false,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/** get — one session's bench block as the served row. */
|
|
475
|
+
export function getBench(drafts, session) {
|
|
476
|
+
if (!SESSION_RE.test(session)) {
|
|
477
|
+
throw new RemoteError("bench/bad-session", "bad session key (want [A-Za-z0-9_-]{1,64})", {});
|
|
478
|
+
}
|
|
479
|
+
const draftId = benchIdOf(session);
|
|
480
|
+
const rec = drafts.table("docs").get(draftId);
|
|
481
|
+
if (rec === undefined) {
|
|
482
|
+
throw new RemoteError("bench/not-found", "no such bench: " + session, {
|
|
483
|
+
session,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
const doc = rec.doc;
|
|
487
|
+
|
|
488
|
+
// report: a bench doc born BEFORE the bench/self-wire law
|
|
489
|
+
// persisted its crime and served silently until a run tripped a
|
|
490
|
+
// generic wall). Scoped NARROW, by design: the read path is
|
|
491
|
+
// deliberately tolerant of loose-but-harmless shapes (junk cells
|
|
492
|
+
// and dead wires are refused with precise voices at the run door
|
|
493
|
+
// and at the next write door; the patch-tolerance laws pin that
|
|
494
|
+
// they serve). ONE crime is refused HERE, and it is the one that
|
|
495
|
+
// composes degenerately with no other voice: a SELF-WIRE, which is
|
|
496
|
+
// a cycle, and which the run's generic no-FromStage message hid.
|
|
497
|
+
//
|
|
498
|
+
|
|
499
|
+
// this gate also refused a wire to a DEAD STAGE. It never did, and
|
|
500
|
+
// the code below says why it must not — crafted-undo docs may hold
|
|
501
|
+
// one legitimately between mutations, and serve must not brick the
|
|
502
|
+
// bench a fix needs to open. The law was correct and the comment
|
|
503
|
+
// was the thing that drifted; a reader auditing against the prose
|
|
504
|
+
// would have gone looking for a check that does not exist.)
|
|
505
|
+
//
|
|
506
|
+
// Whatever wrote the doc — an older door, a hand edit — the store
|
|
507
|
+
// is held at read for that one crime.
|
|
508
|
+
if (Array.isArray(doc["stages"])) {
|
|
509
|
+
for (const st of doc["stages"]) {
|
|
510
|
+
if (typeof st !== "object" || st === null)
|
|
511
|
+
continue;
|
|
512
|
+
const row = st;
|
|
513
|
+
const id = typeof row["id"] === "string" ? row["id"] : undefined;
|
|
514
|
+
const ins = row["inputs"];
|
|
515
|
+
if (typeof ins !== "object" || ins === null || Array.isArray(ins))
|
|
516
|
+
continue;
|
|
517
|
+
for (const [chan, ref] of Object.entries(ins)) {
|
|
518
|
+
const rr = ref;
|
|
519
|
+
if (typeof rr !== "object" || rr === null || typeof rr["stage"] !== "string")
|
|
520
|
+
continue;
|
|
521
|
+
const up = rr["stage"];
|
|
522
|
+
if (id !== undefined && up === id) {
|
|
523
|
+
throw new RemoteError("bench/self-wire", "input '" + chan + "' wires its own stage '" + id + "' — a cycle composes nothing. " +
|
|
524
|
+
"This wire predates the bench door's self-wire law and persisted; clear it (rewire the input with no source, or wire a real prior stage).", { session, stage: id, input: chan });
|
|
525
|
+
}
|
|
526
|
+
// A wire to a stage NOT on the bench (its source removed
|
|
527
|
+
// later, or a crafted-log doc) stays the write doors' and
|
|
528
|
+
// the run door's voice — crafted-undo docs may hold one
|
|
529
|
+
// legitimately between mutations (bench-undo's inverse
|
|
530
|
+
// table); serve must not brick the bench a fix needs to
|
|
531
|
+
// open.
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
const block = benchBlockOf(doc);
|
|
536
|
+
return {
|
|
537
|
+
draft_id: draftId,
|
|
538
|
+
session,
|
|
539
|
+
rev: block.rev,
|
|
540
|
+
intent: doc["intent"],
|
|
541
|
+
stages: doc["stages"],
|
|
542
|
+
log: block.log.map((e) => ({
|
|
543
|
+
rev: e.rev,
|
|
544
|
+
at: e.at,
|
|
545
|
+
author: e.author,
|
|
546
|
+
kind: e.kind,
|
|
547
|
+
...(e.stage !== undefined ? { stage: e.stage } : {}),
|
|
548
|
+
...(e.summary !== undefined ? { summary: e.summary } : {}),
|
|
549
|
+
...(e.reverted !== undefined ? { reverted: e.reverted } : {}),
|
|
550
|
+
})),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
async function loadBench(drafts, session) {
|
|
554
|
+
if (!SESSION_RE.test(session)) {
|
|
555
|
+
throw new RemoteError("bench/bad-session", "bad session key (want [A-Za-z0-9_-]{1,64})", {});
|
|
556
|
+
}
|
|
557
|
+
const draftId = benchIdOf(session);
|
|
558
|
+
let rec = drafts.table("docs").get(draftId);
|
|
559
|
+
if (rec === undefined) {
|
|
560
|
+
// THE BIRTH (stroke 46g, the operator's "is this a forever fix?"):
|
|
561
|
+
// a conversation's bench is born at its FIRST write — never a
|
|
562
|
+
// refusal. rev 0, empty stages, the session's own id.
|
|
563
|
+
// THE BIRTH CARRIES EVERY REQUIRED KEY (the typed-seam audit,
|
|
564
|
+
|
|
565
|
+
// completedAt, and this literal left all three to a cast — so a
|
|
566
|
+
// reader that validated the record would have refused the estate's
|
|
567
|
+
// own first bench. The birth is a DRAFT: status "draft", created
|
|
568
|
+
// now, completed null.
|
|
569
|
+
const born = Math.floor(Date.now() / 1000);
|
|
570
|
+
const doc0 = {
|
|
571
|
+
draft_id: draftId,
|
|
572
|
+
status: "draft",
|
|
573
|
+
createdAt: born,
|
|
574
|
+
completedAt: null,
|
|
575
|
+
intent: "",
|
|
576
|
+
stages: [],
|
|
577
|
+
bench: { rev: 0, log: [] },
|
|
578
|
+
};
|
|
579
|
+
await drafts.table("docs").put(draftId, {
|
|
580
|
+
updated_at: born,
|
|
581
|
+
doc: doc0,
|
|
582
|
+
});
|
|
583
|
+
rec = drafts.table("docs").get(draftId);
|
|
584
|
+
if (rec === undefined) {
|
|
585
|
+
throw new RemoteError("bench/not-found", "could not birth bench: " + session, { session });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
const doc = rec.doc;
|
|
589
|
+
return { draftId, doc, block: benchBlockOf(doc) };
|
|
590
|
+
}
|
|
591
|
+
/** The CAS law, one voice (missing expect_rev refuses too). */
|
|
592
|
+
function casCheck(block, expect, session) {
|
|
593
|
+
if (typeof expect !== "number") {
|
|
594
|
+
throw new RemoteError("bench/cas-conflict", "expect_rev is required (the CAS law)", { current_rev: block.rev });
|
|
595
|
+
}
|
|
596
|
+
if (expect !== block.rev) {
|
|
597
|
+
throw new RemoteError("bench/cas-conflict", "bench " +
|
|
598
|
+
session +
|
|
599
|
+
" moved: rev is " +
|
|
600
|
+
block.rev +
|
|
601
|
+
", caller held " +
|
|
602
|
+
expect, { current_rev: block.rev, expect_rev: expect });
|
|
603
|
+
}
|
|
604
|
+
return block.rev;
|
|
605
|
+
}
|
|
606
|
+
/** Append one entry + persist; returns the new rev. */
|
|
607
|
+
async function appendAndSave(drafts, loaded, entry) {
|
|
608
|
+
const full = {
|
|
609
|
+
...entry,
|
|
610
|
+
rev: loaded.block.rev + 1,
|
|
611
|
+
at: Math.floor(Date.now() / 1000),
|
|
612
|
+
};
|
|
613
|
+
loaded.block.rev = full.rev;
|
|
614
|
+
loaded.block.log.push(full);
|
|
615
|
+
loaded.doc["bench"] = loaded.block;
|
|
616
|
+
await drafts.table("docs").put(loaded.draftId, {
|
|
617
|
+
updated_at: Math.floor(Date.now() / 1000),
|
|
618
|
+
doc: loaded.doc,
|
|
619
|
+
});
|
|
620
|
+
return full.rev;
|
|
621
|
+
}
|
|
622
|
+
function stageRow(doc, stageId) {
|
|
623
|
+
// Schema law (see stagesOf): the projection keeps only record
|
|
624
|
+
// rows — a junk row in the stored doc never crashes the find.
|
|
625
|
+
const stages = stagesOf(doc);
|
|
626
|
+
return stages.find((st) => st["id"] === stageId) ?? null;
|
|
627
|
+
}
|
|
628
|
+
const PATCH_KEYS = ["stage", "params", "result", "runStatus", "expect_rev", "author"];
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
export async function patchBench(drafts, session, body, admit) {
|
|
639
|
+
refuseUnknown(body, PATCH_KEYS);
|
|
640
|
+
if ("result" in body || "runStatus" in body) {
|
|
641
|
+
throw new RemoteError("bench/bad-body", "run outcomes ride the drafts/patch door (result/runStatus refused on the bench verb — it authors params only)", {});
|
|
642
|
+
}
|
|
643
|
+
const loaded = await loadBench(drafts, session);
|
|
644
|
+
casCheck(loaded.block, body["expect_rev"], session);
|
|
645
|
+
const stageId = body["stage"];
|
|
646
|
+
if (typeof stageId !== "string") {
|
|
647
|
+
throw new RemoteError("bench/bad-body", "stage is required", {});
|
|
648
|
+
}
|
|
649
|
+
const st = stageRow(loaded.doc, stageId);
|
|
650
|
+
if (st === null) {
|
|
651
|
+
throw new RemoteError("bench/stage-not-found", "stage not found: " + stageId, { session, stage: stageId });
|
|
652
|
+
}
|
|
653
|
+
const author = authorOk(body["author"])
|
|
654
|
+
? body["author"]
|
|
655
|
+
: "agent";
|
|
656
|
+
const before = {};
|
|
657
|
+
const added = [];
|
|
658
|
+
const paramsIn = wireParams(body["params"]);
|
|
659
|
+
if (paramsIn !== undefined) {
|
|
660
|
+
if (!isRecord(paramsIn)) {
|
|
661
|
+
throw new RemoteError("bench/bad-body", "params must be an object", {});
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// view is a NEW object — a refused admission must not leave the
|
|
665
|
+
// patch's writes on the domain's cached doc (the live record is
|
|
666
|
+
// assigned only after every gate passes).
|
|
667
|
+
const cur = isRecord(st["params"])
|
|
668
|
+
? { ...st["params"] }
|
|
669
|
+
: {};
|
|
670
|
+
for (const k of Object.keys(paramsIn)) {
|
|
671
|
+
if (Object.prototype.hasOwnProperty.call(cur, k))
|
|
672
|
+
before[k] = cur[k];
|
|
673
|
+
else
|
|
674
|
+
added.push(k);
|
|
675
|
+
|
|
676
|
+
// can't unset a param — a lane switch forced remove_stage +
|
|
677
|
+
// add_stage where one clear-semantics patch would do"). The
|
|
678
|
+
// drafts door has ALWAYS deleted null keys (JSON Merge Patch
|
|
679
|
+
// law, draft.ts's own doc: "null params values ride VERBATIM —
|
|
680
|
+
// the door's JSON Merge Patch arm deletes those keys"); this
|
|
681
|
+
// door landed them as literal nulls instead — a key that could
|
|
682
|
+
// never be cleared, only overwritten. Null now deletes here
|
|
683
|
+
// too: one law, two doors. The inverse restores from
|
|
684
|
+
// params_before, so undo resurrects a deleted key.
|
|
685
|
+
const v = paramsIn[k];
|
|
686
|
+
if (v === null)
|
|
687
|
+
delete cur[k];
|
|
688
|
+
else
|
|
689
|
+
cur[k] = v;
|
|
690
|
+
}
|
|
691
|
+
// admission on the MERGED view (the row's card + the params the
|
|
692
|
+
// NEXT run would compose — a patch that lands poison is as bad
|
|
693
|
+
// as an add that lands it). NO PROVENANCE RIDES (the audit's
|
|
694
|
+
// GAP 3): the restored carve is LOAD-TIME grace — "the archive
|
|
695
|
+
// loads; the run teaches" — and forwarding it here made the
|
|
696
|
+
// grace sticky: a restored row patched under restore law
|
|
697
|
+
// FOREVER, so a human actively editing a pre-law row never met
|
|
698
|
+
// today's law. A patch is a live compose act; it judges with
|
|
699
|
+
// the DECL the next run will compose from.
|
|
700
|
+
// THE STORED ROW IS THE JUDGED ROW (the bench audit's gap 3,
|
|
701
|
+
|
|
702
|
+
// raw one, so a doc written by patch held a self-naming model
|
|
703
|
+
// key that add/load never store — and the next door to read that
|
|
704
|
+
// row (a group, an undo) judged a different object than the one
|
|
705
|
+
// on disk. The merge strips before judging AND before storing:
|
|
706
|
+
// one row, one view.
|
|
707
|
+
const stored = strippedForAdmission(typeof st["card"] === "string" ? st["card"] : "", cur);
|
|
708
|
+
// THE COMPOUND IS JUDGED BY ITS MEMBERS HERE TOO (the walls
|
|
709
|
+
|
|
710
|
+
// belongs to a compound judged the COMPOUND ROW as a whole, and
|
|
711
|
+
// the literal "group" — a card no model board knows — reached the
|
|
712
|
+
// board and refused the operator's own patch. A group's patch
|
|
713
|
+
// is a patch of a MEMBER, so the members cross the door and the
|
|
714
|
+
// compound never does.)
|
|
715
|
+
if (st["card"] === GROUP_CARD && Array.isArray(st["children"])) {
|
|
716
|
+
admitRestored(admit, st, isRecord(st["params"]) ? st["params"] : {});
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
admitStage(admit, st["card"], stored, undefined);
|
|
720
|
+
}
|
|
721
|
+
// THE WIRING ADMISSION (phantom-wire cure): a params patch can
|
|
722
|
+
// orphan a spell (drop the $image binding while the wire stays)
|
|
723
|
+
// or strand a wire (introduce $image with no channel) — the
|
|
724
|
+
// merged pair admits together, exactly as the run consumes them.
|
|
725
|
+
admitWiring(st, stored, isRecord(st["inputs"]) ? st["inputs"] : {},
|
|
726
|
+
// (the domain schema pins stages[].id: string with
|
|
727
|
+
// backup-and-skip at read — a nullish id never reaches here)
|
|
728
|
+
stagesOf(loaded.doc).map((r) => String(r["id"])), stageId, session,
|
|
729
|
+
// THE UN-WEDGE LAW at the patch seat: patch touches params,
|
|
730
|
+
// never inputs — a pre-broken input ref (already dead before
|
|
731
|
+
// this patch) tolerates so the row's OTHER knobs stay
|
|
732
|
+
// editable; the merged params×inputs law still judges what
|
|
733
|
+
// the patch itself can create (an orphaned spell refuses).
|
|
734
|
+
toleratedByRow([st], liveIdsOf(stagesOf(loaded.doc))).get(stageId));
|
|
735
|
+
st["params"] = stored;
|
|
736
|
+
}
|
|
737
|
+
if (paramsIn === undefined) {
|
|
738
|
+
throw new RemoteError("bench/bad-body", "nothing to patch (params)", {});
|
|
739
|
+
}
|
|
740
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
741
|
+
author,
|
|
742
|
+
kind: "patch",
|
|
743
|
+
stage: stageId,
|
|
744
|
+
summary: "patch " + Object.keys(paramsIn).sort().join(","),
|
|
745
|
+
inverse: { stage: stageId, params_before: before, params_added: added },
|
|
746
|
+
});
|
|
747
|
+
return {
|
|
748
|
+
draft_id: loaded.draftId,
|
|
749
|
+
session,
|
|
750
|
+
rev: newRev,
|
|
751
|
+
stage: stageId,
|
|
752
|
+
patched: Object.keys(paramsIn).sort(),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
const ADD_KEYS = ["id", "card", "engine", "params", "inputs", "outputs", "expected", "expect_rev", "author"];
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
const wireParams = (v) => (isRecord(v) ? unwrapItemArrays(v) : v);
|
|
765
|
+
/** Admit one RESTORED stage state (the undo arms): the same seat,
|
|
766
|
+
* NO restored carve — the audit's GAP 6 caught the lie: the comment
|
|
767
|
+
* claimed no exemption while the call forwarded the very
|
|
768
|
+
* provenance string the load seat's carve matches, so an undo on a
|
|
769
|
+
* restored row judged nothing at all. Undo rewrites live-authored
|
|
770
|
+
* state; it is judged like any other write, with no provenance. A
|
|
771
|
+
* stage with no card row (an mcp or flow id) is not this seat's
|
|
772
|
+
* business. */
|
|
773
|
+
function admitRestored(admit, stage, params) {
|
|
774
|
+
if (admit === undefined)
|
|
775
|
+
return;
|
|
776
|
+
const card = stage["card"];
|
|
777
|
+
if (typeof card !== 'string' || card === '')
|
|
778
|
+
return;
|
|
779
|
+
// A RESTORED COMPOUND IS JUDGED BY ITS MEMBERS (the bench audit's
|
|
780
|
+
// gap 9): the literal "group" card names nothing the model board
|
|
781
|
+
// knows, so an undo that reinserted a group row (or a snapshot
|
|
782
|
+
// restore) refused the bench's own state — an undo bricked by
|
|
783
|
+
// what the group door had just built. The members cross the same
|
|
784
|
+
// door they crossed when they were picked.
|
|
785
|
+
if (card === GROUP_CARD && Array.isArray(stage["children"])) {
|
|
786
|
+
for (const child of stage["children"]) {
|
|
787
|
+
if (!isRecord(child))
|
|
788
|
+
continue;
|
|
789
|
+
admitRestored(admit, child, isRecord(child["params"]) ? child["params"] : {});
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
// THE STRIPPED VIEW, LIKE EVERY OTHER SEAT (the bench audit's gap
|
|
794
|
+
|
|
795
|
+
// {model: <own card>} — which normalizeBenchStage strips at store
|
|
796
|
+
// and which add/patch admit through the stripped view — was
|
|
797
|
+
// REFUSED here as an unknown model. One admission means one view:
|
|
798
|
+
// the restored row is judged exactly as it would be written.
|
|
799
|
+
admit(card, strippedForAdmission(card, params), undefined);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
export function strippedForAdmission(card, params) {
|
|
807
|
+
const judged = { ...params };
|
|
808
|
+
if (judged["model"] === card && !SELF_NAMED_FAMILIES.has(card)) {
|
|
809
|
+
delete judged["model"];
|
|
810
|
+
}
|
|
811
|
+
return judged;
|
|
812
|
+
}
|
|
813
|
+
function admitStage(admit, card, params, provenance = undefined) {
|
|
814
|
+
if (admit === undefined)
|
|
815
|
+
return;
|
|
816
|
+
if (typeof card !== "string" || card === "")
|
|
817
|
+
return;
|
|
818
|
+
|
|
819
|
+
// add sailed past admission entirely) — admit the card against
|
|
820
|
+
// empty params; the model check passes vacuous, the card check
|
|
821
|
+
// still bites. Every caller hands this seat a record: the load and
|
|
822
|
+
// birth arms pass normalizeStages' rows, whose params normalizeBenchStage
|
|
823
|
+
// has already healed to {} when the doc carried anything else, and the
|
|
824
|
+
// patch arm passes strippedForAdmission's own copy.
|
|
825
|
+
admit(card, strippedForAdmission(card, params), provenance);
|
|
826
|
+
}
|
|
827
|
+
/** The compound row's card literal — the bench's own shape, never a
|
|
828
|
+
* declared card. Named once so every seat that must exempt a
|
|
829
|
+
* compound says the same word (the load arm below). */
|
|
830
|
+
const GROUP_CARD = "group";
|
|
831
|
+
/** Admission over a wholesale stage list (the load arm — every row
|
|
832
|
+
* passes the same door an add passes). Both callers hand it
|
|
833
|
+
* normalizeStages' own output, which keeps only records, so a row
|
|
834
|
+
* here is a record and the members of a compound are its own. */
|
|
835
|
+
function admitStages(admit, stages) {
|
|
836
|
+
if (admit === undefined || !Array.isArray(stages))
|
|
837
|
+
return;
|
|
838
|
+
for (const s of stages) {
|
|
839
|
+
// THE COMPOUND IS NOT A CARD (the bench audit's gap 9,
|
|
840
|
+
|
|
841
|
+
// "group" and whose members ride in `children` — so loading the
|
|
842
|
+
// bench's own read-back asked the model board about a card that
|
|
843
|
+
// does not exist ("no registered card definition"), and an undo
|
|
844
|
+
// of such a load bricked on the bench's own prior state. The
|
|
845
|
+
// compound is judged by its MEMBERS: the same door each member
|
|
846
|
+
// crossed when it was picked (the compound-no-blind-spot law,
|
|
847
|
+
// stated on the way back in).
|
|
848
|
+
if (s["card"] === GROUP_CARD && Array.isArray(s["children"])) {
|
|
849
|
+
admitStages(admit, s["children"]);
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
852
|
+
admitStage(admit, s["card"], s["params"], s["provenance"]);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// The bench's wiring vocabulary is the RUN's: `inputs` name channels,
|
|
857
|
+
// params bind them via `$channel` spells (rewriteSpells is the
|
|
858
|
+
// consumer — a string param starting with "$" IS a spell). Three
|
|
859
|
+
|
|
860
|
+
// run time, far from the write that caused them:
|
|
861
|
+
// a) a channel no param spells (dead wire — the agent's
|
|
862
|
+
// inputs:{image:{stage,...}} with no $image anywhere);
|
|
863
|
+
// b) a spell no channel binds (runs refuse at rewriteSpells);
|
|
864
|
+
// c) an upstream stage id that is not on the bench (the mint
|
|
865
|
+
// mints s_N; "stage-1" never existed).
|
|
866
|
+
// All three now refuse AT WRITE TIME, named.
|
|
867
|
+
/** Collect the `$channel` spell names a params body carries
|
|
868
|
+
* (rewriteSpells semantics verbatim: a string STARTING with "$" is
|
|
869
|
+
* one spell named slice(1) — "costs $5" is text, "$image" is a
|
|
870
|
+
* spell). */
|
|
871
|
+
function spellsOf(params, out = new Set()) {
|
|
872
|
+
if (typeof params === "string") {
|
|
873
|
+
if (params.startsWith("$"))
|
|
874
|
+
out.add(params.slice(1));
|
|
875
|
+
}
|
|
876
|
+
else if (Array.isArray(params)) {
|
|
877
|
+
for (const v of params)
|
|
878
|
+
spellsOf(v, out);
|
|
879
|
+
}
|
|
880
|
+
else if (typeof params === "object" && params !== null) {
|
|
881
|
+
for (const v of Object.values(params))
|
|
882
|
+
spellsOf(v, out);
|
|
883
|
+
}
|
|
884
|
+
return out;
|
|
885
|
+
}
|
|
886
|
+
/** One wiring binding's declared shape: {stage, output} (FromStage),
|
|
887
|
+
* {initial} (the request's initial), or anything else (garbage —
|
|
888
|
+
* the run maps only these two). */
|
|
889
|
+
function wireUpstreamOf(ref) {
|
|
890
|
+
if (typeof ref !== "object" || ref === null || Array.isArray(ref))
|
|
891
|
+
return {};
|
|
892
|
+
const r = ref;
|
|
893
|
+
if (typeof r["stage"] === "string")
|
|
894
|
+
return { stage: r["stage"] };
|
|
895
|
+
if (typeof r["initial"] === "string")
|
|
896
|
+
return { initial: true };
|
|
897
|
+
return {};
|
|
898
|
+
}
|
|
899
|
+
/** The wiring admission over ONE row (params = the MERGED view the
|
|
900
|
+
* next run composes; inputs = the MERGED wiring; liveIds = the
|
|
901
|
+
* bench's stage ids after the write; selfId = the row's own id —
|
|
902
|
+
* self-wiring is a cycle, refused). Throws bench/* named. */
|
|
903
|
+
export function admitWiring(row, params, inputs, liveIds, selfId, session, tolerate) {
|
|
904
|
+
const spells = spellsOf(params);
|
|
905
|
+
for (const [chan, ref] of Object.entries(inputs)) {
|
|
906
|
+
const up = wireUpstreamOf(ref);
|
|
907
|
+
if (up.stage === undefined && up.initial !== true) {
|
|
908
|
+
throw new RemoteError("bench/bad-wire", "input '" + chan + "' is neither {stage, output} nor {initial} — the run maps only those two shapes", { session, stage: selfId, input: chan });
|
|
909
|
+
}
|
|
910
|
+
if (up.stage !== undefined) {
|
|
911
|
+
if (up.stage === selfId) {
|
|
912
|
+
throw new RemoteError("bench/self-wire", "input '" + chan + "' wires its own stage '" + selfId + "' — a cycle composes nothing", { session, stage: selfId, input: chan });
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// refusal fires only for a dangle the VERB CREATES. A ref the
|
|
916
|
+
// row already carried toward an ALREADY-DEAD stage (an
|
|
917
|
+
// interleaved write birthed it; no door admits creating it)
|
|
918
|
+
// passes through `tolerate` — the run door names it, and the
|
|
919
|
+
// cure doors (rewire-clear, remove, clear) must never refuse
|
|
920
|
+
// on the very state they cure. Creation still refuses LOUD.
|
|
921
|
+
if (!liveIds.includes(up.stage) && !(tolerate !== undefined && tolerate.has(up.stage))) {
|
|
922
|
+
throw new RemoteError("bench/wire-stage-not-found", "input '" + chan + "' wires stage '" + up.stage + "' — no such stage on the bench (live: " +
|
|
923
|
+
(liveIds.join(", ") || "none") + ")", { session, stage: selfId, input: chan, live: liveIds });
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
if (!spells.has(chan)) {
|
|
927
|
+
throw new RemoteError("bench/dead-wire", "input '" + chan + "' is bound by no '$" + chan + "' spell in params — the wiring would feed nothing " +
|
|
928
|
+
"(bind it, e.g. params: {image: \"$" + chan + "\"}, or drop the wire)", { session, stage: selfId, input: chan });
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// NOTE: the reverse (a $spell with no channel) is NOT a write-time
|
|
932
|
+
// refusal — it is a legal INTERMEDIATE state (spell first, wire
|
|
933
|
+
// second; the two-step construction). The run refuses a stray
|
|
934
|
+
// spell LOUD at merge (rewriteSpells' own voice), so nothing is
|
|
935
|
+
// silent either way.
|
|
936
|
+
}
|
|
937
|
+
/** Wiring admission over a wholesale stage list (the load arm —
|
|
938
|
+
* every row checks against the list it lands in). Both call sites
|
|
939
|
+
* pass normalizeStages() output — non-arrays collapse to [] there,
|
|
940
|
+
* non-record rows filter out, and every surviving row carries a
|
|
941
|
+
* string id plus record params/inputs (the coerce arms at
|
|
942
|
+
* normalizeStages) — so no re-defense here. */
|
|
943
|
+
/** The live ids of a stage list (group children included — they
|
|
944
|
+
* keep their stage ids under the compound). */
|
|
945
|
+
function liveIdsOf(stages) {
|
|
946
|
+
const out = new Set();
|
|
947
|
+
for (const r of stages) {
|
|
948
|
+
out.add(String(r["id"] ?? ""));
|
|
949
|
+
if (r["card"] === GROUP_CARD && Array.isArray(r["children"])) {
|
|
950
|
+
for (const child of r["children"]) {
|
|
951
|
+
if (isRecord(child))
|
|
952
|
+
out.add(String(child["id"] ?? ""));
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
return out;
|
|
957
|
+
}
|
|
958
|
+
/** The un-wedge law's tolerance table: for every row in the list
|
|
959
|
+
* being JUDGED, the upstreams it wires that are absent from the
|
|
960
|
+
* PRE-verb live set — refs the verb did not create (they were
|
|
961
|
+
* already dead, or ride a restored snapshot the log itself
|
|
962
|
+
* recorded) and must not be refused for. Keyed by row/child id.
|
|
963
|
+
*
|
|
964
|
+
* The walk is over the JUDGED rows (not the pre rows): a verb
|
|
965
|
+
* like clear leaves no pre rows at all, and undo restores rows
|
|
966
|
+
* the pre state doesn't hold — the ref a row CARRIES is the
|
|
967
|
+
* thing to classify, against what was live before the verb. A
|
|
968
|
+
* ref newly introduced by a verb (rewire's `from`, a patch's
|
|
969
|
+
* spell changes) never rides this table: those seats build their
|
|
970
|
+
* tolerate sets from the PRE ROW's own inputs, so a fresh dead
|
|
971
|
+
* target stays strict. */
|
|
972
|
+
function toleratedByRow(stages, preLive) {
|
|
973
|
+
const out = new Map();
|
|
974
|
+
const rowOf = (r, id) => {
|
|
975
|
+
const inputs = r["inputs"];
|
|
976
|
+
if (!isRecord(inputs))
|
|
977
|
+
return;
|
|
978
|
+
for (const ref of Object.values(inputs)) {
|
|
979
|
+
const up = wireUpstreamOf(ref);
|
|
980
|
+
if (up.stage !== undefined && !preLive.has(up.stage)) {
|
|
981
|
+
const set = out.get(id);
|
|
982
|
+
if (set !== undefined)
|
|
983
|
+
set.add(up.stage);
|
|
984
|
+
else
|
|
985
|
+
out.set(id, new Set([up.stage]));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
for (const r of stages) {
|
|
990
|
+
const id = String(r["id"] ?? "");
|
|
991
|
+
rowOf(r, id);
|
|
992
|
+
if (r["card"] === GROUP_CARD && Array.isArray(r["children"])) {
|
|
993
|
+
for (const child of r["children"]) {
|
|
994
|
+
if (isRecord(child))
|
|
995
|
+
rowOf(child, String(child["id"] ?? ""));
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
return out;
|
|
1000
|
+
}
|
|
1001
|
+
function admitWiringList(stages, session, tolerate) {
|
|
1002
|
+
|
|
1003
|
+
// survivor walk — feeds the LIVE doc list, whose rows predate the
|
|
1004
|
+
// normalize contract's guarantees whenever the doc predates it. A
|
|
1005
|
+
// row without record params/inputs judges over {} (vacuous pass),
|
|
1006
|
+
// never crashes the door; the normalized callers are unaffected.
|
|
1007
|
+
// (records themselves are the projection's guarantee — stagesOf
|
|
1008
|
+
// keeps only record rows and normalizeStages filters its own — so
|
|
1009
|
+
// no re-defense of record-ness here)
|
|
1010
|
+
const liveIds = stages
|
|
1011
|
+
.map((r) => String(r["id"] ?? ""));
|
|
1012
|
+
// THE UN-WEDGE TOLERANCE: the map (per row id) carries the refs
|
|
1013
|
+
// that pre-date the verb — the walk refuses only the danglings
|
|
1014
|
+
// THIS verb creates; refs already dead before it pass through
|
|
1015
|
+
// (the run door's voice — see admitWiring's tolerate law). Each
|
|
1016
|
+
// seat states its own base for toleratedByRow.
|
|
1017
|
+
const tolerateAll = tolerate;
|
|
1018
|
+
for (const r of stages) {
|
|
1019
|
+
// THE COMPOUND'S OWN WIRES ARE JUDGED (the bench audit's gap 7,
|
|
1020
|
+
|
|
1021
|
+
// walk passed over it vacuously — a wire hidden INSIDE a group
|
|
1022
|
+
// survived a remove of the stage it pointed at, and ungroup then
|
|
1023
|
+
// spilled the phantom onto the flat bench with nothing left to
|
|
1024
|
+
// catch it. The walk descends: the compound is judged, and so is
|
|
1025
|
+
// every child, against the same live set.
|
|
1026
|
+
admitWiring(r, isRecord(r["params"]) ? r["params"] : {}, isRecord(r["inputs"]) ? r["inputs"] : {}, liveIds, String(r["id"] ?? ""), session, tolerateAll?.get(String(r["id"] ?? "")));
|
|
1027
|
+
if (r["card"] === GROUP_CARD && Array.isArray(r["children"])) {
|
|
1028
|
+
for (const child of r["children"]) {
|
|
1029
|
+
if (!isRecord(child))
|
|
1030
|
+
continue;
|
|
1031
|
+
admitWiring(child, isRecord(child["params"]) ? child["params"] : {}, isRecord(child["inputs"]) ? child["inputs"] : {}, liveIds, String(child["id"] ?? ""), session, tolerateAll?.get(String(child["id"] ?? "")));
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
/** add_stage — append a card stage with optional wiring (the
|
|
1037
|
+
* runner's input convention rides the row). */
|
|
1038
|
+
export async function addStageBench(drafts, session, body, admit) {
|
|
1039
|
+
refuseUnknown(body, ADD_KEYS);
|
|
1040
|
+
const loadedForAdmit = await loadBench(drafts, session);
|
|
1041
|
+
casCheck(loadedForAdmit.block, body["expect_rev"], session);
|
|
1042
|
+
// THE JUDGED VIEW IS THE STORED VIEW (the bench audit's gap 11,
|
|
1043
|
+
|
|
1044
|
+
// normalized row — so the unwrap, the legacy-media heal and the
|
|
1045
|
+
// default overlay were applied AFTER the judgment, and the door
|
|
1046
|
+
// could refuse a row the normalizer would have healed (or admit one
|
|
1047
|
+
// it would have reshaped). The row is shaped FIRST, then judged —
|
|
1048
|
+
// the same order every other door uses.
|
|
1049
|
+
const cardForRow = body["card"];
|
|
1050
|
+
if (typeof cardForRow !== "string" || cardForRow === "") {
|
|
1051
|
+
throw new RemoteError("bench/bad-body", "card is required", {});
|
|
1052
|
+
}
|
|
1053
|
+
// THE ONE MINT, AND NOW IT IS ONE CALL (the identity audit's
|
|
1054
|
+
|
|
1055
|
+
// producer of the value" directly above a function that called the
|
|
1056
|
+
// producer TWICE — once for the row handed to admission, once for
|
|
1057
|
+
// the row stored below. They agree today only because both read the
|
|
1058
|
+
// same unmutated doc; any future edit between them, or a change to
|
|
1059
|
+
// one formula, reopens the judged-vs-stored split this comment
|
|
1060
|
+
// claimed to have closed. The value is bound ONCE and both doors
|
|
1061
|
+
// read it, so the sentence is now true rather than aspirational.
|
|
1062
|
+
//
|
|
1063
|
+
// (See mintStageId for why the formula is max+1 and not the count.)
|
|
1064
|
+
const stageIdForAdmit = mintStageId(stagesOf(loadedForAdmit.doc), body);
|
|
1065
|
+
// A MALFORMED `inputs` CONTAINER REFUSES (the audit's fail-open).
|
|
1066
|
+
// `isRecord(...) ? ... : {}` treats a non-record `inputs` exactly
|
|
1067
|
+
// like an absent one: the caller is told the stage was added, no
|
|
1068
|
+
// wire is stored, and the loss surfaces much later as a missing
|
|
1069
|
+
// required param at run time. A bad BINDING already refuses
|
|
1070
|
+
// (bench/bad-wire) and rewire's non-record `from` already refuses
|
|
1071
|
+
// (bench/bad-body) — the container was the one shape that failed
|
|
1072
|
+
// open. The READ path stays tolerant on purpose (a doc already on
|
|
1073
|
+
// disk is not the caller's mistake, and the serve/run doors judge
|
|
1074
|
+
// it there); this is the door where the caller can still be told.
|
|
1075
|
+
const rawInputs = body["inputs"];
|
|
1076
|
+
if (rawInputs !== undefined && !isRecord(rawInputs)) {
|
|
1077
|
+
throw new RemoteError("bench/bad-body", "stage " + JSON.stringify(stageIdForAdmit) +
|
|
1078
|
+
": inputs must be an object of channel → {stage, output} | {initial} (got " +
|
|
1079
|
+
typeof rawInputs +
|
|
1080
|
+
"); a wire that cannot be read is refused here, not dropped", {});
|
|
1081
|
+
}
|
|
1082
|
+
// A MALFORMED `params` CONTAINER REFUSES (the same fail-open the
|
|
1083
|
+
// `inputs` container closed above — commission 015, §6 finding 5):
|
|
1084
|
+
// `isRecord(...) ? ... : {}` treated a non-record `params` exactly
|
|
1085
|
+
// like an absent one, so the card's knobs wrapped in an array
|
|
1086
|
+
// minted a stage with EMPTY params instead of refusing. The patch
|
|
1087
|
+
|
|
1088
|
+
// refuses at its own row admission — one law, every write door.
|
|
1089
|
+
const rawParams = wireParams(body["params"]);
|
|
1090
|
+
if (rawParams !== undefined && !isRecord(rawParams)) {
|
|
1091
|
+
throw new RemoteError("bench/bad-body", "stage " + JSON.stringify(stageIdForAdmit) + ": params must be an object (got " +
|
|
1092
|
+
(Array.isArray(rawParams) ? "an array — the card's knobs, not a list of them" : typeof rawParams) +
|
|
1093
|
+
"); a wire that cannot be read is refused here, not dropped", {});
|
|
1094
|
+
}
|
|
1095
|
+
const admitted = normalizeBenchStage({
|
|
1096
|
+
id: stageIdForAdmit,
|
|
1097
|
+
card: cardForRow,
|
|
1098
|
+
...(typeof body["engine"] === "string" && body["engine"] !== "" ? { engine: body["engine"] } : {}),
|
|
1099
|
+
...(rawParams !== undefined ? { params: rawParams } : {}),
|
|
1100
|
+
...(isRecord(body["inputs"]) ? { inputs: body["inputs"] } : {}),
|
|
1101
|
+
...(isRecord(body["outputs"]) ? { outputs: body["outputs"] } : {}),
|
|
1102
|
+
}, stageIdForAdmit);
|
|
1103
|
+
admitStage(admit, admitted["card"], admitted["params"]);
|
|
1104
|
+
const loaded = loadedForAdmit;
|
|
1105
|
+
// (the card was already refused at the top of this door when it is
|
|
1106
|
+
// not a non-empty string — the bound value here is one)
|
|
1107
|
+
const card = body["card"];
|
|
1108
|
+
const author = authorOk(body["author"])
|
|
1109
|
+
? body["author"]
|
|
1110
|
+
: "agent";
|
|
1111
|
+
const stages = stagesOf(loaded.doc);
|
|
1112
|
+
|
|
1113
|
+
// ("s_" + stages.length) collides the moment any stage was ever
|
|
1114
|
+
// removed — [s_0..s_9] minus nine leaves s_1, and the count says
|
|
1115
|
+
// s_1 again. The mint takes MAX numeric suffix + 1 over the live
|
|
1116
|
+
// rows, so a fresh id is fresh against the bench AS IT STANDS.
|
|
1117
|
+
// THE STORED ROW'S ID IS THE ADMITTED ROW'S ID (the one mint,
|
|
1118
|
+
// above): read the bound value, never call the producer again.
|
|
1119
|
+
const stageId = stageIdForAdmit;
|
|
1120
|
+
if (stageRow(loaded.doc, stageId) !== null) {
|
|
1121
|
+
throw new RemoteError("bench/duplicate-stage", "stage id already on the bench: " + stageId, { session, stage: stageId });
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// arm hand-rolled the whole row, so a stage added here kept a dead
|
|
1125
|
+
// `/v1/assets/runs` path unhealed, skipped the self-naming strip,
|
|
1126
|
+
// and defaulted `outputs` by a SECOND law). One writer shapes a
|
|
1127
|
+
// stage row: normalizeBenchStage. `expected` rides on top, exactly
|
|
1128
|
+
// as it does on every other arm.
|
|
1129
|
+
const row = normalizeBenchStage({
|
|
1130
|
+
id: stageId,
|
|
1131
|
+
card,
|
|
1132
|
+
...(typeof body["engine"] === "string" && body["engine"] !== "" ? { engine: body["engine"] } : {}),
|
|
1133
|
+
...(rawParams !== undefined ? { params: rawParams } : {}),
|
|
1134
|
+
...(isRecord(body["inputs"]) ? { inputs: body["inputs"] } : {}),
|
|
1135
|
+
...(isRecord(body["outputs"]) ? { outputs: body["outputs"] } : {}),
|
|
1136
|
+
}, stageId);
|
|
1137
|
+
row["provenance"] = author;
|
|
1138
|
+
if (isRecord(body["expected"]))
|
|
1139
|
+
row["expected"] = body["expected"];
|
|
1140
|
+
// THE WIRING ADMISSION (phantom-wire cure) — BEFORE the push: the
|
|
1141
|
+
// row's wiring admits against a PROJECTED live set (current ids +
|
|
1142
|
+
// this row's). Never push-then-check: the loaded doc is the
|
|
1143
|
+
// domain's cached object, and a refusal after the push LEAKS the
|
|
1144
|
+
|
|
1145
|
+
// adds persisted as s_1/s_2 on the next good write — a refusal
|
|
1146
|
+
// that lies). Mutate only after every gate passes.
|
|
1147
|
+
admitWiring(row, row["params"], row["inputs"], [...stages.map((r) => String(r["id"] ?? "")), stageId], stageId, session);
|
|
1148
|
+
stages.push(row);
|
|
1149
|
+
loaded.doc["stages"] = stages;
|
|
1150
|
+
// add_stage never syncs the intent (the orphaning law): a pure
|
|
1151
|
+
// add grows the card set — a label true before stays true after.
|
|
1152
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
1153
|
+
author,
|
|
1154
|
+
kind: "add_stage",
|
|
1155
|
+
stage: stageId,
|
|
1156
|
+
summary: "add " + card,
|
|
1157
|
+
inverse: { stage: stageId },
|
|
1158
|
+
});
|
|
1159
|
+
return { draft_id: loaded.draftId, session, rev: newRev, stage: stageId };
|
|
1160
|
+
}
|
|
1161
|
+
const REMOVE_KEYS = ["stage", "expect_rev", "author"];
|
|
1162
|
+
const REORDER_KEYS = ["stage", "to", "expect_rev", "author"];
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
export async function reorderBench(drafts, session, body) {
|
|
1171
|
+
refuseUnknown(body, REORDER_KEYS);
|
|
1172
|
+
const loaded = await loadBench(drafts, session);
|
|
1173
|
+
casCheck(loaded.block, body["expect_rev"], session);
|
|
1174
|
+
const stageId = body["stage"];
|
|
1175
|
+
if (typeof stageId !== "string") {
|
|
1176
|
+
throw new RemoteError("bench/bad-body", "stage is required", {});
|
|
1177
|
+
}
|
|
1178
|
+
const toRaw = body["to"];
|
|
1179
|
+
if (typeof toRaw !== "number" || !Number.isInteger(toRaw) || toRaw < 0) {
|
|
1180
|
+
throw new RemoteError("bench/bad-body", "to must be a non-negative integer index", {});
|
|
1181
|
+
}
|
|
1182
|
+
const author = authorOk(body["author"])
|
|
1183
|
+
? body["author"]
|
|
1184
|
+
: "agent";
|
|
1185
|
+
const stages = stagesOf(loaded.doc);
|
|
1186
|
+
const from = stages.findIndex((s) => s["id"] === stageId);
|
|
1187
|
+
if (from < 0) {
|
|
1188
|
+
throw new RemoteError("bench/stage-not-found", "stage not found: " + stageId, { session, stage: stageId });
|
|
1189
|
+
}
|
|
1190
|
+
const [row] = stages.splice(from, 1);
|
|
1191
|
+
const to = Math.min(toRaw, stages.length);
|
|
1192
|
+
stages.splice(to, 0, row);
|
|
1193
|
+
loaded.doc["stages"] = stages;
|
|
1194
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
1195
|
+
author,
|
|
1196
|
+
kind: "reorder",
|
|
1197
|
+
stage: stageId,
|
|
1198
|
+
summary: "reorder " + String(row["card"] ?? stageId),
|
|
1199
|
+
inverse: { stage: stageId, from, to },
|
|
1200
|
+
});
|
|
1201
|
+
return { draft_id: loaded.draftId, session, rev: newRev, stage: stageId, from, to };
|
|
1202
|
+
}
|
|
1203
|
+
const GROUP_KEYS = ["stages", "expect_rev", "author"];
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
export async function groupBench(drafts, session, body, admit = undefined) {
|
|
1211
|
+
refuseUnknown(body, GROUP_KEYS);
|
|
1212
|
+
const loaded = await loadBench(drafts, session);
|
|
1213
|
+
casCheck(loaded.block, body["expect_rev"], session);
|
|
1214
|
+
const idsRaw = body["stages"];
|
|
1215
|
+
if (!Array.isArray(idsRaw) || idsRaw.length < 2
|
|
1216
|
+
|| !idsRaw.every((x) => typeof x === "string")) {
|
|
1217
|
+
throw new RemoteError("bench/bad-body", "stages must be an array of 2+ stage ids", {});
|
|
1218
|
+
}
|
|
1219
|
+
const author = authorOk(body["author"])
|
|
1220
|
+
? body["author"]
|
|
1221
|
+
: "agent";
|
|
1222
|
+
const stages = stagesOf(loaded.doc);
|
|
1223
|
+
// bench order (the ids arrive selection-order; the compound is the
|
|
1224
|
+
// bench's own order — the old law: "the compound parent lands at the
|
|
1225
|
+
// FIRST member's position, childIds in array order")
|
|
1226
|
+
const ids = idsRaw;
|
|
1227
|
+
const picked = [];
|
|
1228
|
+
for (const st of stages) {
|
|
1229
|
+
if (ids.includes(String(st["id"])))
|
|
1230
|
+
picked.push(st);
|
|
1231
|
+
}
|
|
1232
|
+
if (picked.length !== ids.length) {
|
|
1233
|
+
throw new RemoteError("bench/stage-not-found", "group: unknown stage id(s) — have " + String(picked.length) + " of " + String(ids.length), { session, stage: ids.join(",") });
|
|
1234
|
+
}
|
|
1235
|
+
const firstIndex = stages.indexOf(picked[0]);
|
|
1236
|
+
|
|
1237
|
+
// audit's rows 1+4): a compound row renders as one tile and its
|
|
1238
|
+
// children leave the flat bench — patch cannot reach inside it, so
|
|
1239
|
+
// a row parked before a law tightened would sit inside the group
|
|
1240
|
+
// judged by NOTHING until ungroup spilled it back. The picked rows
|
|
1241
|
+
// walk the same admission an add walks BEFORE they leave the flat
|
|
1242
|
+
// bench — the group is a write like every other write, and a
|
|
1243
|
+
// refusal here names the child the way add_stage would.
|
|
1244
|
+
for (const row of picked) {
|
|
1245
|
+
admitRestored(admit, row, isRecord(row["params"])
|
|
1246
|
+
? row["params"]
|
|
1247
|
+
: {});
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// — the direction the first cut of this gate still missed): the
|
|
1251
|
+
// run's wiring walk (benchWiring in the runs registry) reads the
|
|
1252
|
+
// FLAT stage list only, so a member's wire dies the moment the
|
|
1253
|
+
// member leaves it — whether it pointed at another member or at a
|
|
1254
|
+
// bystander. Parking it is a silent run-time starvation, so the
|
|
1255
|
+
// door refuses and names the cure (ungroup first, or drop the wire).
|
|
1256
|
+
for (const row of picked) {
|
|
1257
|
+
const ins = row["inputs"];
|
|
1258
|
+
if (isRecord(ins) && Object.keys(ins).length > 0) {
|
|
1259
|
+
throw new RemoteError("bench/grouped-row-wired", "stage " + String(row["id"]) + " carries a wire (" + Object.keys(ins).join(", ") +
|
|
1260
|
+
") — grouping hides it from the run, which wires only the flat bench; ungroup it first, or drop the wire", { session, stage: String(row["id"]) });
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
// THE SURVIVORS RE-JUDGED, AND THE WIRES TOO (the bench audit's
|
|
1264
|
+
|
|
1265
|
+
// wire another member carried now points at a stage that is no
|
|
1266
|
+
// longer addressable — a state remove_stage refuses and rewire
|
|
1267
|
+
// refuses to create. Grouping admitted it silently, because this
|
|
1268
|
+
// arm only judged the picked rows' models. The post-move bench is
|
|
1269
|
+
// judged as a whole, on a copy, before the splice lands (the
|
|
1270
|
+
// survivor law remove established, now applied to the other door
|
|
1271
|
+
// that moves rows).
|
|
1272
|
+
const afterGroup = stages.filter((r) => !picked.includes(r));
|
|
1273
|
+
const compoundProbe = {
|
|
1274
|
+
id: "g_probe",
|
|
1275
|
+
card: GROUP_CARD,
|
|
1276
|
+
children: picked,
|
|
1277
|
+
};
|
|
1278
|
+
afterGroup.splice(Math.min(firstIndex, afterGroup.length), 0, compoundProbe);
|
|
1279
|
+
// THE UN-WEDGE LAW at the group seat: rows keep the refs they
|
|
1280
|
+
// carried; only the grouping changes. Judged against the pre live set.
|
|
1281
|
+
admitWiringList(afterGroup, session, toleratedByRow(afterGroup, liveIdsOf(stages)));
|
|
1282
|
+
for (const row of picked) {
|
|
1283
|
+
// picked rows ARE stages members (built from the walk above) —
|
|
1284
|
+
// splice by identity, no guard.
|
|
1285
|
+
stages.splice(stages.indexOf(row), 1);
|
|
1286
|
+
}
|
|
1287
|
+
// id from the doc's own coordinates (rev + count) — no global RNG
|
|
1288
|
+
// dependency; unique per append by construction (rev advances).
|
|
1289
|
+
const gid = "g_r" + String(loaded.block.rev) + "c" + String(picked.length) + "_" + String(stages.length);
|
|
1290
|
+
const compound = {
|
|
1291
|
+
id: gid,
|
|
1292
|
+
card: GROUP_CARD,
|
|
1293
|
+
children: picked,
|
|
1294
|
+
};
|
|
1295
|
+
stages.splice(Math.min(firstIndex, stages.length), 0, compound);
|
|
1296
|
+
loaded.doc["stages"] = stages;
|
|
1297
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
1298
|
+
author,
|
|
1299
|
+
kind: "group",
|
|
1300
|
+
stage: gid,
|
|
1301
|
+
summary: "group " + String(ids.length) + " stages",
|
|
1302
|
+
inverse: { stage: gid, index: firstIndex, children: picked },
|
|
1303
|
+
});
|
|
1304
|
+
return { draft_id: loaded.draftId, session, rev: newRev, stage: gid };
|
|
1305
|
+
}
|
|
1306
|
+
const UNGROUP_KEYS = ["stage", "expect_rev", "author"];
|
|
1307
|
+
/** ungroup — the compound's door: spill the children back at the
|
|
1308
|
+
* compound's index, in their stored order. */
|
|
1309
|
+
export async function ungroupBench(drafts, session, body, admit = undefined) {
|
|
1310
|
+
refuseUnknown(body, UNGROUP_KEYS);
|
|
1311
|
+
const loaded = await loadBench(drafts, session);
|
|
1312
|
+
casCheck(loaded.block, body["expect_rev"], session);
|
|
1313
|
+
const stageId = body["stage"];
|
|
1314
|
+
if (typeof stageId !== "string") {
|
|
1315
|
+
throw new RemoteError("bench/bad-body", "stage is required", {});
|
|
1316
|
+
}
|
|
1317
|
+
const author = authorOk(body["author"])
|
|
1318
|
+
? body["author"]
|
|
1319
|
+
: "agent";
|
|
1320
|
+
const stages = stagesOf(loaded.doc);
|
|
1321
|
+
const idx = stages.findIndex((s) => s["id"] === stageId);
|
|
1322
|
+
if (idx < 0 || stages[idx]["card"] !== GROUP_CARD) {
|
|
1323
|
+
throw new RemoteError("bench/stage-not-found", "ungroup: no group stage " + stageId, { session, stage: stageId });
|
|
1324
|
+
}
|
|
1325
|
+
const compound = stages[idx];
|
|
1326
|
+
const children = compound["children"];
|
|
1327
|
+
const rows = Array.isArray(children)
|
|
1328
|
+
? children
|
|
1329
|
+
: [];
|
|
1330
|
+
|
|
1331
|
+
// the children re-enter the flat bench as patchable rows — every
|
|
1332
|
+
// other re-entry (undo-patch, undo-remove, load) walks admission,
|
|
1333
|
+
// and this door was the one place a pre-law row could come back
|
|
1334
|
+
// judged by nothing. Judged BEFORE the splice; a refusal keeps the
|
|
1335
|
+
// compound intact and names the child.
|
|
1336
|
+
for (const row of rows) {
|
|
1337
|
+
admitRestored(admit, row, isRecord(row["params"])
|
|
1338
|
+
? row["params"]
|
|
1339
|
+
: {});
|
|
1340
|
+
}
|
|
1341
|
+
// THE SPILLED BENCH IS JUDGED AS A WHOLE (the bench audit's gap 6,
|
|
1342
|
+
|
|
1343
|
+
// compound hid — or a wire to a member that the group removed from
|
|
1344
|
+
// the flat bench — comes back as a PHANTOM, a state add, rewire and
|
|
1345
|
+
// remove all refuse. Judged on a copy, before the splice.
|
|
1346
|
+
const afterSpill = stages.filter((r) => r !== compound);
|
|
1347
|
+
afterSpill.splice(Math.min(idx, afterSpill.length), 0, ...rows);
|
|
1348
|
+
// THE UN-WEDGE LAW at the ungroup seat (same as group's).
|
|
1349
|
+
admitWiringList(afterSpill, session, toleratedByRow(afterSpill, liveIdsOf(stages)));
|
|
1350
|
+
stages.splice(idx, 1);
|
|
1351
|
+
stages.splice(Math.min(idx, stages.length), 0, ...rows);
|
|
1352
|
+
loaded.doc["stages"] = stages;
|
|
1353
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
1354
|
+
author,
|
|
1355
|
+
kind: "ungroup",
|
|
1356
|
+
stage: stageId,
|
|
1357
|
+
summary: "ungroup " + String(rows.length) + " stages",
|
|
1358
|
+
inverse: { row: compound, index: idx },
|
|
1359
|
+
});
|
|
1360
|
+
return { draft_id: loaded.draftId, session, rev: newRev, stage: stageId };
|
|
1361
|
+
}
|
|
1362
|
+
/** remove_stage — splice one stage out (the inverse carries row +
|
|
1363
|
+
* index for undo). */
|
|
1364
|
+
export async function removeStageBench(drafts, session, body) {
|
|
1365
|
+
refuseUnknown(body, REMOVE_KEYS);
|
|
1366
|
+
const loaded = await loadBench(drafts, session);
|
|
1367
|
+
casCheck(loaded.block, body["expect_rev"], session);
|
|
1368
|
+
const stageId = body["stage"];
|
|
1369
|
+
if (typeof stageId !== "string") {
|
|
1370
|
+
throw new RemoteError("bench/bad-body", "stage is required", {});
|
|
1371
|
+
}
|
|
1372
|
+
const author = authorOk(body["author"])
|
|
1373
|
+
? body["author"]
|
|
1374
|
+
: "agent";
|
|
1375
|
+
const stages = stagesOf(loaded.doc);
|
|
1376
|
+
const idx = stages.findIndex((s) => s["id"] === stageId);
|
|
1377
|
+
if (idx < 0) {
|
|
1378
|
+
throw new RemoteError("bench/stage-not-found", "stage not found: " + stageId, { session, stage: stageId });
|
|
1379
|
+
}
|
|
1380
|
+
const row = stages[idx];
|
|
1381
|
+
const before = [...stages];
|
|
1382
|
+
|
|
1383
|
+
// 5): removing a stage rewrites every row that wired it — the
|
|
1384
|
+
// survivor now holds bench/wire-stage-not-found, a state the
|
|
1385
|
+
// rewire door REFUSES to create. Door parity demands the removal
|
|
1386
|
+
// refuse it too (drop the wire first, or keep the stage). The
|
|
1387
|
+
// survivors' wiring walks the same law on a COPY, BEFORE the
|
|
1388
|
+
// splice — a refused removal leaks nothing (stagesOf returns the
|
|
1389
|
+
// doc's own array; the splice is the write, and it lands last).
|
|
1390
|
+
admitWiringList(stages.filter((_r, i) => i !== idx), session,
|
|
1391
|
+
// THE UN-WEDGE LAW at the remove seat: the survivors are judged
|
|
1392
|
+
// against the PRE-removal live set — this removal refuses only
|
|
1393
|
+
// the danglings IT creates (a survivor wiring the removed
|
|
1394
|
+
// stage); a survivor's PRE-EXISTING dead ref (born by an
|
|
1395
|
+
// interleaved write, refused by no door that admits creating
|
|
1396
|
+
// it) passes through, else NO remove could ever land and the
|
|
1397
|
+
|
|
1398
|
+
// clear or delete from the bench").
|
|
1399
|
+
toleratedByRow(stages.filter((_r, i) => i !== idx), liveIdsOf(stages)));
|
|
1400
|
+
stages.splice(idx, 1);
|
|
1401
|
+
loaded.doc["stages"] = stages;
|
|
1402
|
+
syncIntent(loaded.doc, before, stages);
|
|
1403
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
1404
|
+
author,
|
|
1405
|
+
kind: "remove_stage",
|
|
1406
|
+
stage: stageId,
|
|
1407
|
+
summary: "remove " + String(row["card"] ?? stageId),
|
|
1408
|
+
inverse: { stage: stageId, row, index: idx },
|
|
1409
|
+
});
|
|
1410
|
+
return { draft_id: loaded.draftId, session, rev: newRev, stage: stageId };
|
|
1411
|
+
}
|
|
1412
|
+
const CLEAR_KEYS = ["expect_rev", "author"];
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
export async function clearBench(drafts, session, body) {
|
|
1421
|
+
refuseUnknown(body, CLEAR_KEYS);
|
|
1422
|
+
const loaded = await loadBench(drafts, session);
|
|
1423
|
+
casCheck(loaded.block, body["expect_rev"], session);
|
|
1424
|
+
const author = authorOk(body["author"])
|
|
1425
|
+
? body["author"]
|
|
1426
|
+
: "agent";
|
|
1427
|
+
const stages = stagesOf(loaded.doc);
|
|
1428
|
+
const before = [...stages];
|
|
1429
|
+
stages.length = 0;
|
|
1430
|
+
loaded.doc["stages"] = stages;
|
|
1431
|
+
syncIntent(loaded.doc, before, stages);
|
|
1432
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
1433
|
+
author,
|
|
1434
|
+
kind: "clear",
|
|
1435
|
+
summary: "clear " + String(before.length) + " stages",
|
|
1436
|
+
inverse: { stages_before: before },
|
|
1437
|
+
});
|
|
1438
|
+
return {
|
|
1439
|
+
draft_id: loaded.draftId,
|
|
1440
|
+
session,
|
|
1441
|
+
rev: newRev,
|
|
1442
|
+
cleared: before.length,
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
const REWIRE_KEYS = ["stage", "input", "from", "expect_rev", "author"];
|
|
1446
|
+
/** rewire — set or clear one input binding. from:
|
|
1447
|
+
* {stage, output} | {initial: "image"} | absent → clear. */
|
|
1448
|
+
export async function rewireBench(drafts, session, body) {
|
|
1449
|
+
refuseUnknown(body, REWIRE_KEYS);
|
|
1450
|
+
const loaded = await loadBench(drafts, session);
|
|
1451
|
+
casCheck(loaded.block, body["expect_rev"], session);
|
|
1452
|
+
const stageId = body["stage"];
|
|
1453
|
+
const input = body["input"];
|
|
1454
|
+
if (typeof stageId !== "string" || typeof input !== "string") {
|
|
1455
|
+
throw new RemoteError("bench/bad-body", "stage and input are required", {});
|
|
1456
|
+
}
|
|
1457
|
+
const st = stageRow(loaded.doc, stageId);
|
|
1458
|
+
if (st === null) {
|
|
1459
|
+
throw new RemoteError("bench/stage-not-found", "stage not found: " + stageId, { session, stage: stageId });
|
|
1460
|
+
}
|
|
1461
|
+
const author = authorOk(body["author"])
|
|
1462
|
+
? body["author"]
|
|
1463
|
+
: "agent";
|
|
1464
|
+
const from = body["from"];
|
|
1465
|
+
if (from !== undefined && !isRecord(from)) {
|
|
1466
|
+
throw new RemoteError("bench/bad-body", "from must be {stage,output} | {initial} | absent", {});
|
|
1467
|
+
}
|
|
1468
|
+
const inputs = isRecord(st["inputs"])
|
|
1469
|
+
? { ...st["inputs"] }
|
|
1470
|
+
: {};
|
|
1471
|
+
const before = Object.prototype.hasOwnProperty.call(st["inputs"] === undefined ? {} : st["inputs"], input)
|
|
1472
|
+
? st["inputs"][input]
|
|
1473
|
+
: undefined;
|
|
1474
|
+
if (from === undefined)
|
|
1475
|
+
delete inputs[input];
|
|
1476
|
+
else
|
|
1477
|
+
inputs[input] = from;
|
|
1478
|
+
|
|
1479
|
+
// the CANDIDATE wiring (a copy, never the live record: a refusal
|
|
1480
|
+
// must not leak the write into the domain's cached doc) admits
|
|
1481
|
+
// against the row's params and the live bench BEFORE anything
|
|
1482
|
+
// moves (a channel nothing spells, an upstream that doesn't
|
|
1483
|
+
// exist — all refuse here, named, instead of starving compose at
|
|
1484
|
+
// run time).
|
|
1485
|
+
admitWiring(st, isRecord(st["params"]) ? st["params"] : {}, inputs,
|
|
1486
|
+
// (the domain schema pins stages[].id: string with
|
|
1487
|
+
// backup-and-skip at read — a nullish id never reaches here)
|
|
1488
|
+
stagesOf(loaded.doc).map((r) => String(r["id"])), stageId, session,
|
|
1489
|
+
// THE UN-WEDGE LAW at the rewire seat: the tolerate table is
|
|
1490
|
+
// built from the PRE ROW's own inputs — a ref the rewire
|
|
1491
|
+
// NEWLY sets (its `from`) never rides it, so wiring a dead
|
|
1492
|
+
// stage still refuses (the phantom-wire cure stands), while
|
|
1493
|
+
// the row's other pre-broken inputs tolerate so rewiring one
|
|
1494
|
+
// wire on a wounded row never wedges.
|
|
1495
|
+
toleratedByRow([st], liveIdsOf(stagesOf(loaded.doc))).get(stageId));
|
|
1496
|
+
st["inputs"] = inputs;
|
|
1497
|
+
const inverse = { stage: stageId, input };
|
|
1498
|
+
if (before !== undefined)
|
|
1499
|
+
inverse["before"] = before;
|
|
1500
|
+
else
|
|
1501
|
+
inverse["added"] = true;
|
|
1502
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
1503
|
+
author,
|
|
1504
|
+
kind: "rewire",
|
|
1505
|
+
stage: stageId,
|
|
1506
|
+
summary: "wire " + input,
|
|
1507
|
+
inverse,
|
|
1508
|
+
});
|
|
1509
|
+
return {
|
|
1510
|
+
draft_id: loaded.draftId,
|
|
1511
|
+
session,
|
|
1512
|
+
rev: newRev,
|
|
1513
|
+
stage: stageId,
|
|
1514
|
+
input,
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
const UNDO_KEYS = ["n", "author", "expect_rev"];
|
|
1518
|
+
/** undo — walk the log tail, skip already-reverted entries and
|
|
1519
|
+
* entries the author filter excludes, apply inverses
|
|
1520
|
+
* newest-first, append ONE undo entry carrying the pre-undo
|
|
1521
|
+
* snapshot. Undo entries are NEVER candidates — a plain undo
|
|
1522
|
+
* walks ACTIONS (targeting them would ping-pong). */
|
|
1523
|
+
export async function undoBench(drafts, session, body, admit) {
|
|
1524
|
+
refuseUnknown(body, UNDO_KEYS);
|
|
1525
|
+
const loaded = await loadBench(drafts, session);
|
|
1526
|
+
if (body["expect_rev"] !== undefined) {
|
|
1527
|
+
casCheck(loaded.block, body["expect_rev"], session);
|
|
1528
|
+
}
|
|
1529
|
+
const n = typeof body["n"] === "number" &&
|
|
1530
|
+
Number.isInteger(body["n"]) &&
|
|
1531
|
+
body["n"] > 0
|
|
1532
|
+
? body["n"]
|
|
1533
|
+
: 1;
|
|
1534
|
+
const filter = authorOk(body["author"])
|
|
1535
|
+
? body["author"]
|
|
1536
|
+
: null;
|
|
1537
|
+
// The already-reverted set (union of every undo entry's reverted).
|
|
1538
|
+
const revertedSet = new Set();
|
|
1539
|
+
for (const e of loaded.block.log) {
|
|
1540
|
+
if (e.kind === "undo" && Array.isArray(e.reverted)) {
|
|
1541
|
+
for (const r of e.reverted)
|
|
1542
|
+
revertedSet.add(r);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
const targets = [];
|
|
1546
|
+
for (let i = loaded.block.log.length - 1; i >= 0 && targets.length < n; i--) {
|
|
1547
|
+
const e = loaded.block.log[i];
|
|
1548
|
+
if (e.kind === "undo")
|
|
1549
|
+
continue;
|
|
1550
|
+
if (revertedSet.has(e.rev))
|
|
1551
|
+
continue;
|
|
1552
|
+
if (filter !== null && e.author !== filter)
|
|
1553
|
+
continue;
|
|
1554
|
+
targets.push(e);
|
|
1555
|
+
}
|
|
1556
|
+
if (targets.length === 0) {
|
|
1557
|
+
throw new RemoteError("bench/nothing-to-undo", "nothing to undo" + (filter !== null ? " (author " + filter + ")" : ""), {});
|
|
1558
|
+
}
|
|
1559
|
+
const stagesBefore = stagesOf(loaded.doc);
|
|
1560
|
+
// THE MULTI-TARGET UNDO IS ATOMIC (the bench audit's gap 2,
|
|
1561
|
+
|
|
1562
|
+
// so `undo {n: 2}` where the first target reverts cleanly and the
|
|
1563
|
+
// second refuses left the first one's change in the live document
|
|
1564
|
+
// with no rev and no log — a refused write that mutated, through
|
|
1565
|
+
// the door whose whole job is repair. The inverses now run against
|
|
1566
|
+
// a DEEP COPY and the swap happens only when every target applied
|
|
1567
|
+
// (copy-on-judge, at the shape's own scale: an undo either lands
|
|
1568
|
+
// whole or not at all).
|
|
1569
|
+
const undoDoc = structuredClone(loaded.doc);
|
|
1570
|
+
for (const e of targets)
|
|
1571
|
+
applyInverse(undoDoc, e, admit, session);
|
|
1572
|
+
// THE UNDOED BENCH IS WIRE-JUDGED WHOLE (the bench audit's gaps 4
|
|
1573
|
+
|
|
1574
|
+
// the wiring half of a write was undo's blind spot: an undo-patch
|
|
1575
|
+
// restored params that stranded a spell the wire still fed (a dead
|
|
1576
|
+
// wire the forward patch refuses), and undo-remove/group/ungroup/
|
|
1577
|
+
|
|
1578
|
+
// bench. One walk over the RESULT covers every arm at once — the
|
|
1579
|
+
// survivor law remove established, applied to the repair door.
|
|
1580
|
+
|
|
1581
|
+
// wedge), with the resurrection line drawn by ROW, not by ref: a
|
|
1582
|
+
// row that was already on the bench before the undo (or rides an
|
|
1583
|
+
// undone CLEAR's recorded snapshot — clear's promise is that undo
|
|
1584
|
+
// restores EVERYTHING, wounds included; the run door names them,
|
|
1585
|
+
// the cure doors exist) keeps its pre-existing dead refs. A row
|
|
1586
|
+
// the undo RESURRECTS through any other arm (remove's inverse,
|
|
1587
|
+
// group's spill) judges STRICT — the phantom the forward doors
|
|
1588
|
+
// refuse must not launder back through undo (gap 5 stands). Refs
|
|
1589
|
+
// the undo itself orphans (a stage live before, gone after) still
|
|
1590
|
+
// refuse LOUD.
|
|
1591
|
+
(() => {
|
|
1592
|
+
const baseRows = [...stagesBefore];
|
|
1593
|
+
for (const e of targets) {
|
|
1594
|
+
const inv = e.inverse ?? {};
|
|
1595
|
+
if (e.kind === "clear" && Array.isArray(inv["stages_before"])) {
|
|
1596
|
+
for (const row of inv["stages_before"]) {
|
|
1597
|
+
if (isRecord(row))
|
|
1598
|
+
baseRows.push(row);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
const baseIds = liveIdsOf(baseRows);
|
|
1603
|
+
const restored = stagesOf(undoDoc);
|
|
1604
|
+
admitWiringList(restored, session, toleratedByRow(restored.filter((r) => baseIds.has(String(r["id"] ?? ""))), baseIds));
|
|
1605
|
+
})();
|
|
1606
|
+
loaded.doc = undoDoc;
|
|
1607
|
+
const undone = targets
|
|
1608
|
+
.map((e) => e.rev)
|
|
1609
|
+
.sort((a, b) => a - b);
|
|
1610
|
+
const newRev = await appendAndSave(drafts, loaded, {
|
|
1611
|
+
author: filter === "user" ? "user" : "agent",
|
|
1612
|
+
kind: "undo",
|
|
1613
|
+
summary: "undo r" + undone.join(",r"),
|
|
1614
|
+
inverse: { stages_before: stagesBefore },
|
|
1615
|
+
reverted: undone,
|
|
1616
|
+
});
|
|
1617
|
+
return {
|
|
1618
|
+
draft_id: loaded.draftId,
|
|
1619
|
+
session,
|
|
1620
|
+
rev: newRev,
|
|
1621
|
+
undone,
|
|
1622
|
+
stages: loaded.doc["stages"],
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
/** Apply one entry's inverse payload to the doc (best-effort per
|
|
1626
|
+
* entry; a stage an undo already removed is skipped loudly-free —
|
|
1627
|
+
* the snapshot entries carry the coarse fallback). */
|
|
1628
|
+
function applyInverse(doc, e, admit, session = "") {
|
|
1629
|
+
const inv = isRecord(e.inverse)
|
|
1630
|
+
? e.inverse
|
|
1631
|
+
: {};
|
|
1632
|
+
const stages = stagesOf(doc);
|
|
1633
|
+
if (e.kind === "patch") {
|
|
1634
|
+
const st = stageRow(doc, String(inv["stage"] ?? e.stage ?? ""));
|
|
1635
|
+
if (st === null)
|
|
1636
|
+
return;
|
|
1637
|
+
|
|
1638
|
+
// live record, so a refused undo left the poisoned merge in the
|
|
1639
|
+
// doc's cached record before the throw. Judge the copy, THEN
|
|
1640
|
+
// assign; a refusal leaves the bench exactly as it was.
|
|
1641
|
+
const cur = isRecord(st["params"])
|
|
1642
|
+
? { ...st["params"] }
|
|
1643
|
+
: {};
|
|
1644
|
+
const before = isRecord(inv["params_before"])
|
|
1645
|
+
? inv["params_before"]
|
|
1646
|
+
: {};
|
|
1647
|
+
for (const k of Object.keys(before))
|
|
1648
|
+
cur[k] = before[k];
|
|
1649
|
+
const added = inv["params_added"];
|
|
1650
|
+
if (Array.isArray(added)) {
|
|
1651
|
+
for (const k of added)
|
|
1652
|
+
delete cur[k];
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// inverse restores params wholesale, so without this an undo was
|
|
1656
|
+
// the laundering path — plant a poison through a door that is not
|
|
1657
|
+
// judged, patch it clean, then undo back onto the poison. The
|
|
1658
|
+
// restored state walks the same composed admission BEFORE it
|
|
1659
|
+
// lands, and NO restored exemption applies: these were
|
|
1660
|
+
// live-authored states, so re-judging is safe and a refusal is
|
|
1661
|
+
// loud.
|
|
1662
|
+
admitRestored(admit, st, cur);
|
|
1663
|
+
st["params"] = cur;
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (e.kind === "add_stage") {
|
|
1667
|
+
const idx = stages.findIndex((s) => s["id"] === inv["stage"]);
|
|
1668
|
+
if (idx >= 0)
|
|
1669
|
+
stages.splice(idx, 1);
|
|
1670
|
+
doc["stages"] = stages;
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (e.kind === "remove_stage") {
|
|
1674
|
+
const row = inv["row"];
|
|
1675
|
+
const idx = typeof inv["index"] === "number" ? inv["index"] : stages.length;
|
|
1676
|
+
// THE REINSERTED ROW IS JUDGED (the audit's GAP 4): an inverse
|
|
1677
|
+
// row that left the doc before a law tightened re-enters here
|
|
1678
|
+
// with NO seat looking at it — the laundering path the patch
|
|
1679
|
+
// arm closed, reopened one verb over. Admission BEFORE the
|
|
1680
|
+
// splice: a refusal names the row and the doc keeps its stages.
|
|
1681
|
+
if (isRecord(row)) {
|
|
1682
|
+
admitRestored(admit, row, isRecord(row["params"])
|
|
1683
|
+
? row["params"]
|
|
1684
|
+
: {});
|
|
1685
|
+
stages.splice(Math.min(idx, stages.length), 0, row);
|
|
1686
|
+
}
|
|
1687
|
+
doc["stages"] = stages;
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
if (e.kind === "group") {
|
|
1691
|
+
// undo(group) = ungroup: drop the compound row, spill children at
|
|
1692
|
+
// its index (bench order preserved inside the row).
|
|
1693
|
+
const idx = stages.findIndex((s) => s["id"] === inv["stage"]);
|
|
1694
|
+
const children = inv["children"];
|
|
1695
|
+
// (the inverse is a payload the log recorded, never a promise —
|
|
1696
|
+
// junk members degrade loudly-free: only record rows re-enter)
|
|
1697
|
+
const rows = Array.isArray(children)
|
|
1698
|
+
? children.filter((r) => isRecord(r))
|
|
1699
|
+
: [];
|
|
1700
|
+
|
|
1701
|
+
// audit's row 1): the children re-enter the flat bench from the
|
|
1702
|
+
// INVERSE — a payload the log recorded, never a row the doors
|
|
1703
|
+
// just judged — so an inverse carrying pre-law poison laundered
|
|
1704
|
+
// straight back onto the bench. Every child walks admission
|
|
1705
|
+
// BEFORE the splice; a refusal leaves the compound standing.
|
|
1706
|
+
for (const row of rows) {
|
|
1707
|
+
admitRestored(admit, row, isRecord(row["params"])
|
|
1708
|
+
? row["params"]
|
|
1709
|
+
: {});
|
|
1710
|
+
}
|
|
1711
|
+
if (idx >= 0) {
|
|
1712
|
+
stages.splice(idx, 1);
|
|
1713
|
+
stages.splice(Math.min(idx, stages.length), 0, ...rows);
|
|
1714
|
+
doc["stages"] = stages;
|
|
1715
|
+
}
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
if (e.kind === "ungroup") {
|
|
1719
|
+
// undo(ungroup) = re-group: splice the children back out, insert
|
|
1720
|
+
// the compound row at the recorded index.
|
|
1721
|
+
const row = inv["row"];
|
|
1722
|
+
if (!isRecord(row))
|
|
1723
|
+
return;
|
|
1724
|
+
const children = row["children"];
|
|
1725
|
+
// (the inverse's members ride as the log recorded them — only
|
|
1726
|
+
// record rows re-enter; junk degrades loudly-free)
|
|
1727
|
+
const members = Array.isArray(children)
|
|
1728
|
+
? children.filter((c) => isRecord(c))
|
|
1729
|
+
: [];
|
|
1730
|
+
if (members.length > 0) {
|
|
1731
|
+
|
|
1732
|
+
// row 2): the compound hides its children from every patch
|
|
1733
|
+
// door — re-parking an inverse row re-opens the blind spot the
|
|
1734
|
+
// group door closed. Children walk admission BEFORE the
|
|
1735
|
+
// compound forms; a refusal aborts the undo whole.
|
|
1736
|
+
for (const child of members) {
|
|
1737
|
+
admitRestored(admit, child, isRecord(child["params"])
|
|
1738
|
+
? child["params"]
|
|
1739
|
+
: {});
|
|
1740
|
+
}
|
|
1741
|
+
for (const child of members) {
|
|
1742
|
+
const idx = stages.findIndex((s) => s["id"] === child["id"]);
|
|
1743
|
+
if (idx >= 0)
|
|
1744
|
+
stages.splice(idx, 1);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
const back = typeof inv["index"] === "number"
|
|
1748
|
+
? Math.min(inv["index"], stages.length)
|
|
1749
|
+
: stages.length;
|
|
1750
|
+
stages.splice(back, 0, row);
|
|
1751
|
+
doc["stages"] = stages;
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
if (e.kind === "reorder") {
|
|
1755
|
+
// the inverse of a move is the move back: splice out at the CURRENT
|
|
1756
|
+
// (post-move) position, reinsert at the ORIGINAL index (clamped).
|
|
1757
|
+
const idx = stages.findIndex((s) => s["id"] === inv["stage"]);
|
|
1758
|
+
if (idx >= 0) {
|
|
1759
|
+
const [row] = stages.splice(idx, 1);
|
|
1760
|
+
const back = typeof inv["from"] === "number"
|
|
1761
|
+
? Math.min(inv["from"], stages.length)
|
|
1762
|
+
: stages.length;
|
|
1763
|
+
stages.splice(back, 0, row);
|
|
1764
|
+
doc["stages"] = stages;
|
|
1765
|
+
}
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
if (e.kind === "rewire") {
|
|
1769
|
+
// (stageRow matches by identity of the string — the row it
|
|
1770
|
+
// returns carries exactly this id, never a nullish one)
|
|
1771
|
+
const stageId = String(inv["stage"] ?? "");
|
|
1772
|
+
const st = stageRow(doc, stageId);
|
|
1773
|
+
if (st === null)
|
|
1774
|
+
return;
|
|
1775
|
+
// COPY-ON-JUDGE (the write-seat audit's row 6, second half): the
|
|
1776
|
+
// restored inputs are judged on a COPY and assigned only after
|
|
1777
|
+
// the verdict — the first cut mutated `st["inputs"]` in place
|
|
1778
|
+
// before the throw, so a REFUSED undo still leaked the dead wire
|
|
1779
|
+
// into the doc. No judgment path mutates what it has not yet
|
|
1780
|
+
// accepted.
|
|
1781
|
+
const inputs = {
|
|
1782
|
+
...(isRecord(st["inputs"])
|
|
1783
|
+
? st["inputs"]
|
|
1784
|
+
: {}),
|
|
1785
|
+
};
|
|
1786
|
+
const input = String(inv["input"] ?? "");
|
|
1787
|
+
if (inv["added"] === true)
|
|
1788
|
+
delete inputs[input];
|
|
1789
|
+
else if ("before" in inv)
|
|
1790
|
+
inputs[input] = inv["before"];
|
|
1791
|
+
|
|
1792
|
+
// audit's row 6): the inverse restores a wire the rewire door
|
|
1793
|
+
// judged when it was CUT — but the bench has moved since (the
|
|
1794
|
+
// upstream stage may be gone, the $spell may have left params),
|
|
1795
|
+
// and rewireBench refuses to create exactly this state. Undo
|
|
1796
|
+
// walks the same wiring law over the RESTORED view BEFORE the
|
|
1797
|
+
// assignment; a refusal aborts the undo.
|
|
1798
|
+
admitWiring(st, isRecord(st["params"]) ? st["params"] : {}, inputs,
|
|
1799
|
+
// (records are stagesOf's guarantee; an id-less row rides as "")
|
|
1800
|
+
stages
|
|
1801
|
+
.map((s) => String(s["id"] ?? "")), stageId, session);
|
|
1802
|
+
st["inputs"] = inputs;
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
// load/clear: the coarse fallbacks — restore the snapshot. (Undo
|
|
1806
|
+
// entries are never walk candidates — the skip at the top
|
|
1807
|
+
// guarantees it — so no "undo" disjunct here.)
|
|
1808
|
+
const snapshot = e.kind === "load" || e.kind === "clear" ? inv["stages_before"] : undefined;
|
|
1809
|
+
if (Array.isArray(snapshot)) {
|
|
1810
|
+
// THE SNAPSHOT IS JUDGED (the audit's GAP 4, the load arm): a
|
|
1811
|
+
// coarse fallback that assigns stages wholesale re-enters every
|
|
1812
|
+
// row with no seat looking — same laundering path as the
|
|
1813
|
+
// remove_stage arm, coarser. Judge every row BEFORE the
|
|
1814
|
+
// assignment; a refusal leaves the live bench untouched.
|
|
1815
|
+
for (const row of snapshot) {
|
|
1816
|
+
if (!isRecord(row))
|
|
1817
|
+
continue;
|
|
1818
|
+
admitRestored(admit, row, isRecord(row["params"])
|
|
1819
|
+
? row["params"]
|
|
1820
|
+
: {});
|
|
1821
|
+
}
|
|
1822
|
+
doc["stages"] = snapshot;
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
let benchChangeSeam = null;
|
|
1826
|
+
/** Install the seam (the plugin does this with its Context; tests do
|
|
1827
|
+
* it with their own). Pass null to detach. */
|
|
1828
|
+
export function setBenchChangeSeam(seam) {
|
|
1829
|
+
benchChangeSeam = seam;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
export function onBenchChange(fn) {
|
|
1840
|
+
if (benchChangeSeam === null) {
|
|
1841
|
+
throw new Error("estate-drafts: no bench change seam installed — the plugin installs it in apply(), tests call setBenchChangeSeam()");
|
|
1842
|
+
}
|
|
1843
|
+
return benchChangeSeam.on((session) => {
|
|
1844
|
+
try {
|
|
1845
|
+
fn(session);
|
|
1846
|
+
}
|
|
1847
|
+
catch {
|
|
1848
|
+
/* a listener's fault never breaks the write */
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
/** Fire the write event — the remote layer calls this after every
|
|
1853
|
+
* successful bench write (the door seam: every in-process and
|
|
1854
|
+
* host-routed write crosses the remote arms). */
|
|
1855
|
+
export function notifyBenchChange(session) {
|
|
1856
|
+
benchChangeSeam?.emit(session);
|
|
1857
|
+
}
|
|
1858
|
+
/** The public notify (the drafts-patch door writes bench docs from
|
|
1859
|
+
* remote.ts — outside this module; it fires this with the session
|
|
1860
|
+
* parsed from the bench-shaped draft id). */
|
|
1861
|
+
export function notifyBenchDocWrite(draftId) {
|
|
1862
|
+
const m = BENCH_DRAFT_RE.exec(draftId);
|
|
1863
|
+
if (m !== null)
|
|
1864
|
+
notifyBenchChange(m[1]);
|
|
1865
|
+
}
|
|
1866
|
+
//# sourceMappingURL=bench.js.map
|