@panphora/clayjs 0.5.0 → 0.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT",
package/src/core/save.js CHANGED
@@ -117,6 +117,37 @@ let lastSavedContents = '';
117
117
  // A save was requested while one was on the wire; run one more when it settles.
118
118
  let pendingSave = false;
119
119
 
120
+ // ============================================
121
+ // AUTOSAVE SUSPENSION
122
+ // ============================================
123
+ //
124
+ // clay.wire holds this for the length of an agent request. The save is
125
+ // last-writer-wins with a backup, so an autosave landing while a local process is
126
+ // writing the same file posts the pre-agent document: the server backs the agent's
127
+ // bytes up and writes the browser's, the watcher's revalidation then fails, and
128
+ // the agent's work exists only in Backups while the page reports success.
129
+ //
130
+ // It suspends AUTOsave only. An explicit savePage — Cmd+S, a [trigger-save]
131
+ // button, or the wire's own pre-send flush — is a deliberate act and still runs.
132
+ //
133
+ // Reference counted, because two overlapping requests each hold it. A save
134
+ // skipped while suspended is replayed on release, or the user's edits would sit
135
+ // unsaved until something else happened to mutate the page.
136
+ let autosaveSuspended = 0;
137
+ let autosaveMissed = false;
138
+
139
+ export function suspendAutosave() {
140
+ autosaveSuspended++;
141
+ }
142
+
143
+ export function resumeAutosave() {
144
+ if (autosaveSuspended === 0) return;
145
+ autosaveSuspended--;
146
+ if (autosaveSuspended > 0 || !autosaveMissed) return;
147
+ autosaveMissed = false;
148
+ savePageThrottled();
149
+ }
150
+
120
151
  function skipped_(msg) {
121
152
  return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
122
153
  }
@@ -451,6 +482,16 @@ export function savePageThrottled(callback = () => {}) {
451
482
  return Promise.resolve(skipped);
452
483
  }
453
484
 
485
+ // Every autosave path lands here — the mutation-driven one, the [persist] input
486
+ // timer, and live-sync's convergence save after a protected apply — which is
487
+ // why the suspension lives at this one entry rather than at each caller.
488
+ if (autosaveSuspended > 0) {
489
+ autosaveMissed = true;
490
+ const skipped = skipped_('Autosave suspended');
491
+ callback(skipped);
492
+ return Promise.resolve(skipped);
493
+ }
494
+
454
495
  // For autosave: while the page is still settling, content must differ from BOTH
455
496
  // the load-time baseline and the last save, so module setup churn cannot trigger
456
497
  // a save. Once settled, the baseline veto is disarmed and only the last save
@@ -27,6 +27,7 @@
27
27
 
28
28
  import Mutation from './mutation.js';
29
29
  import { isEditMode } from '../core/is-edit-mode.js';
30
+ import { STRIP_FROM_COMPARISON, SNAPSHOT_REMOVE_SELECTOR } from './region-policy.js';
30
31
 
31
32
  let changes = 0;
32
33
  let clearedAt = 0;
@@ -34,6 +35,22 @@ let paused = false;
34
35
  let started = false;
35
36
 
36
37
  const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
38
+
39
+ // Regions the comparison never sees. The hub feed already skips them, through
40
+ // `require: 'autosave'`, and the input feed has to skip them for the same
41
+ // reason: their content is stripped from the comparison clone, so an edit inside
42
+ // one can never produce a dirty root, and counting it marks the page dirty with
43
+ // nothing for the oracle to find — permanently, since only a save clears the
44
+ // counter and churn in these regions triggers none.
45
+ //
46
+ // This is not a relaxation of "never under-report". A control here is absent
47
+ // from the clone by definition, so there is nothing about it to under-report.
48
+ // It matters because a mounted tool (redpen's answer field, any panel that
49
+ // marks itself no-save) is a real <textarea> in the document: one keystroke in
50
+ // it used to freeze the live-sync save baseline for the rest of the session,
51
+ // after which every incoming disk change was diffed against a stale base, and
52
+ // the previous change was spliced back over the newer one and written to disk.
53
+ const GATE_IGNORE = `${STRIP_FROM_COMPARISON}, ${SNAPSHOT_REMOVE_SELECTOR}`;
37
54
  const probeCache = new WeakMap();
38
55
 
39
56
  function onUserInput(event) {
@@ -42,9 +59,9 @@ function onUserInput(event) {
42
59
  // during a morph's async resource wait, which must keep the page dirty.
43
60
  const el = event.target;
44
61
  if (!el || el.nodeType !== 1) return;
45
- if (el.matches('input, textarea, select') || el.isContentEditable) {
46
- changes++;
47
- }
62
+ if (!(el.matches('input, textarea, select') || el.isContentEditable)) return;
63
+ if (el.closest(GATE_IGNORE)) return;
64
+ changes++;
48
65
  }
49
66
 
50
67
  export function startDirtyGate() {
@@ -21,10 +21,11 @@ export const PLUGIN_PATHS = {
21
21
  sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
22
22
  undo: { path: "plugins/undo.js", editOnly: true, default: false },
23
23
  cms: { path: "vendor/hypercms.vendor.js", editOnly: false, default: false },
24
+ wire: { path: "plugins/wire.js", editOnly: false, default: false },
24
25
  demo: { path: "plugins/demo.js", editOnly: false, default: false },
25
26
  };
26
27
 
27
- const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "demo"];
28
+ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "cms", "sync", "wire", "demo"];
28
29
 
29
30
  function parseCsv(params, key, enabled, apply) {
30
31
  const raw = params.get(key);
package/src/loader.js CHANGED
@@ -102,6 +102,8 @@ function attachPluginMember(path, mod) {
102
102
  clay.morph = mod.morph;
103
103
  } else if (path === "vendor/hypercms.vendor.js") {
104
104
  clay.cms = mod.cms || mod.default;
105
+ } else if (path === "plugins/wire.js") {
106
+ clay.wire = mod.wire || mod.default;
105
107
  } else if (path === "plugins/demo.js") {
106
108
  clay.demo = mod.demo;
107
109
  } else if (path === "vendor/richclay.vendor.js") {
@@ -0,0 +1,598 @@
1
+ import { isEditMode } from "../core/is-edit-mode.js";
2
+
3
+ /**
4
+ * clay.wire — a per-file control channel between this page and a local process.
5
+ *
6
+ * The page sends a request ("rewrite the section I circled"), a process running
7
+ * in the user's own terminal answers with progress and a terminal frame, and
8
+ * that process edits the FILE. HTML never rides the wire: the agent's change
9
+ * reaches this page as an ordinary external file change, through live-sync. That
10
+ * split is why this module has no morphing, no content lane, and no opinion
11
+ * about what a payload contains.
12
+ *
13
+ * Three constraints shape everything below.
14
+ *
15
+ * It must not import the sync plugin. `sync/live-sync.js` opens an EventSource
16
+ * on evaluation, so a static import here would give every wire page a live-sync
17
+ * connection it never asked for. The one thing this module needs from live-sync,
18
+ * "a disk frame just landed", arrives as the `clay:sync-applied` DOM event, which
19
+ * couples the two through the document rather than through the module graph.
20
+ *
21
+ * It runs in view mode (`editOnly: false` in the loader's plugin table), because
22
+ * the `/htmlfile questions` consumer and every read-only review page need it. So
23
+ * nothing here may assume the save lane exists: on a view-mode page `clay.save`
24
+ * is absent, and the save-protection below is skipped entirely rather than
25
+ * guarded at each call site.
26
+ *
27
+ * The stream is lazy. A browser allows six connections per origin, the pool is
28
+ * shared across tabs, and live-sync already holds one per tab, so a second
29
+ * permanent stream means three tabs of one project saturate the origin and the
30
+ * next save queues behind them. The wire is idle between requests by definition,
31
+ * so it connects on the first in-flight request and disconnects after the last
32
+ * one ends.
33
+ */
34
+
35
+ // Every request carries a deadline, from before its POST until it ends. Nothing
36
+ // else can end one: the wire has no "the handler detached" signal, and a handler
37
+ // that acknowledged and then died would otherwise leave the request open, its
38
+ // promise unresolved, and saving paused for the rest of the session. That is the
39
+ // worst outcome available here, worse than reporting a slow agent as failed,
40
+ // because the page silently stops saving.
41
+ //
42
+ // It runs on two lengths. Before the first frame, the handler has taken the
43
+ // request (the POST already reports delivered: 0 when nobody was attached) and
44
+ // only has to say so.
45
+ const ACK_TIMEOUT_MS = 15000;
46
+
47
+ // After that, it is an inactivity deadline: every frame rearms it, and `wire
48
+ // serve` streams the child's stdout as status frames, so an agent that reports
49
+ // what it is doing keeps its request alive indefinitely. A silent one gets this
50
+ // long. Its work still reaches the page if it lands later, through live-sync,
51
+ // since the wire never carried the content anyway.
52
+ const SILENCE_TIMEOUT_MS = 120000;
53
+
54
+ // `wire/done` means the handler finished writing the file. It does not mean this
55
+ // page has rendered the change: that arrives on the live-sync lane after the
56
+ // watcher's quiet interval, and nothing orders the two. So a finished request
57
+ // waits in `landing` for the disk frame, and gives up waiting after this.
58
+ const LANDING_TIMEOUT_MS = 4000;
59
+
60
+ const SAVE_FLUSH_TIMEOUT_MS = 5000;
61
+
62
+ // A long review session sends many requests. Records outlive their request so a
63
+ // UI can still show what happened, so the map needs a ceiling; terminated ones
64
+ // are dropped oldest-first.
65
+ const MAX_RECORDS = 50;
66
+
67
+ const OPEN_STATES = new Set(["sent", "acked"]);
68
+
69
+ // Once a request has ended it stays ended. Several things can arrive after the
70
+ // end and each would otherwise rewrite it: a POST that was already on the wire
71
+ // when the user cancelled, an ack timer that fires after a fast error, a done
72
+ // for a request the handler also errored.
73
+ const TERMINAL_STATES = new Set(["done", "error", "cancelled"]);
74
+
75
+ const records = new Map();
76
+ const listeners = new Set();
77
+
78
+ let stream = null;
79
+ let streamReady = null;
80
+ let landingWatch = 0;
81
+ let landingHandler = null;
82
+
83
+ function newId() {
84
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
85
+ return crypto.randomUUID();
86
+ }
87
+ return `page-${Date.now()}-${Math.random().toString(16).slice(2)}`;
88
+ }
89
+
90
+ // Every URL is resolved against the real origin. A `<base href>` in the authored
91
+ // document would otherwise point this page's control channel at an origin the
92
+ // document chose, which is the same trap live-sync documents on its own stream.
93
+ function wireURL(path) {
94
+ return new URL(path, window.location.origin).href;
95
+ }
96
+
97
+ function view(rec) {
98
+ return {
99
+ id: rec.id,
100
+ type: rec.type,
101
+ state: rec.state,
102
+ text: rec.text,
103
+ error: rec.error,
104
+ startedAt: rec.startedAt,
105
+ };
106
+ }
107
+
108
+ function emit(rec) {
109
+ const snapshot = view(rec);
110
+ for (const fn of listeners) {
111
+ try {
112
+ fn(snapshot);
113
+ } catch (err) {
114
+ console.error("clay.wire: a listener threw", err);
115
+ }
116
+ }
117
+ document.dispatchEvent(new CustomEvent("clay:wire-state", { detail: snapshot }));
118
+ }
119
+
120
+ function setState(rec, state) {
121
+ if (rec.state === state) return;
122
+ rec.state = state;
123
+ emit(rec);
124
+ }
125
+
126
+ // The request deadline. Armed before the POST and rearmed by every frame, so a
127
+ // request is bounded from end to end rather than only up to its ack.
128
+ function arm(rec, ms, message) {
129
+ clearTimeout(rec.timer);
130
+ rec.timer = setTimeout(() => {
131
+ rec.timer = null;
132
+ finish(rec, "error", message);
133
+ }, ms);
134
+ }
135
+
136
+ function disarm(rec) {
137
+ clearTimeout(rec.timer);
138
+ rec.timer = null;
139
+ }
140
+
141
+ function prune() {
142
+ if (records.size <= MAX_RECORDS) return;
143
+ for (const [id, rec] of records) {
144
+ if (records.size <= MAX_RECORDS) break;
145
+ if (!OPEN_STATES.has(rec.state) && rec.state !== "landing") records.delete(id);
146
+ }
147
+ }
148
+
149
+ // --- the stream ------------------------------------------------------------
150
+
151
+ // A page never names a file and never asks for the handler role. Its target
152
+ // comes from its own URL, through the same funnel live-sync's save uses, and any
153
+ // file it supplied would be discarded server-side; the handler slot is refused
154
+ // to browsers outright, since a page holding it could receive every request on
155
+ // the file, including other tabs', and answer with fabricated terminal frames.
156
+ function openStream() {
157
+ if (streamReady) return streamReady;
158
+
159
+ const path = `/_/wire/subscribe?page-url=${encodeURIComponent(window.location.href)}`;
160
+ stream = new EventSource(wireURL(path));
161
+
162
+ stream.onmessage = (event) => {
163
+ let frame;
164
+ try {
165
+ frame = JSON.parse(event.data);
166
+ } catch {
167
+ return;
168
+ }
169
+ handleFrame(frame);
170
+ };
171
+
172
+ // Native EventSource reconnects on its own and carries Last-Event-ID, and wire
173
+ // frames are stamped with one, so the server replays any terminal frame this
174
+ // page missed during the gap: an in-flight request recovers without help. A
175
+ // request cancelled during the gap is dropped by handleFrame, since its record
176
+ // is no longer open. What a reconnect cannot bring back is a status frame,
177
+ // which is display only. So there is nothing to do here.
178
+ stream.onerror = () => {};
179
+
180
+ streamReady = new Promise((resolve) => {
181
+ let settled = false;
182
+ const finish = () => {
183
+ if (settled) return;
184
+ settled = true;
185
+ resolve();
186
+ };
187
+ stream.onopen = finish;
188
+ // Sending is more important than subscribing: a request that never goes out
189
+ // because the stream would not open is worse than one whose early frames
190
+ // are missed.
191
+ setTimeout(finish, 2000);
192
+ });
193
+ return streamReady;
194
+ }
195
+
196
+ function closeStreamIfIdle() {
197
+ for (const rec of records.values()) {
198
+ if (OPEN_STATES.has(rec.state)) return;
199
+ }
200
+ if (!stream) return;
201
+ stream.close();
202
+ stream = null;
203
+ streamReady = null;
204
+ }
205
+
206
+ function handleFrame(frame) {
207
+ if (!frame || typeof frame.id !== "string") return;
208
+ const rec = records.get(frame.id);
209
+ // An id this page did not issue, or one it has stopped caring about. Cancel
210
+ // means stop completely, so a cancelled request's late frames are dropped here
211
+ // rather than reopening a lifecycle the user ended.
212
+ if (!rec || !OPEN_STATES.has(rec.state)) return;
213
+
214
+ switch (frame.type) {
215
+ case "wire/ack":
216
+ // Rearmed, not cleared. The handler acknowledges within milliseconds of
217
+ // picking a request up, so clearing here would leave every working request
218
+ // with no deadline at all.
219
+ arm(rec, SILENCE_TIMEOUT_MS, "the agent stopped responding");
220
+ setState(rec, "acked");
221
+ break;
222
+ case "wire/status":
223
+ arm(rec, SILENCE_TIMEOUT_MS, "the agent stopped responding");
224
+ rec.text = typeof frame.text === "string" ? frame.text : "";
225
+ rec.state = "acked";
226
+ emit(rec);
227
+ break;
228
+ case "wire/done":
229
+ land(rec);
230
+ break;
231
+ case "wire/error":
232
+ finish(rec, "error", frame.text || "the handler reported an error");
233
+ break;
234
+ default:
235
+ break;
236
+ }
237
+ }
238
+
239
+ // --- the landing state -----------------------------------------------------
240
+
241
+ // `clay:sync-applied` fires on every successful remote morph, from live-sync's
242
+ // single apply choke point, whether or not the sync plugin was loaded by this
243
+ // page's own URL. Listening for it is what makes "done" mean "you can see it"
244
+ // instead of "the agent stopped typing", and it costs no import.
245
+ function watchLandings() {
246
+ if (landingHandler) return;
247
+ landingHandler = (event) => {
248
+ // Both apply paths dispatch this event. A peer frame is another tab's edit,
249
+ // so completing a landing on one would report done for bytes that are not
250
+ // the ones this request asked for.
251
+ if (event.detail?.source === "peer") return;
252
+ // One record per frame, oldest first (Map iteration is insertion order). A
253
+ // frame proves one landing and says nothing about any other request, and a
254
+ // request left waiting still ends on its own next frame or its timer.
255
+ for (const rec of records.values()) {
256
+ if (rec.state === "landing") {
257
+ finish(rec, "done", null);
258
+ return;
259
+ }
260
+ }
261
+ };
262
+ document.addEventListener("clay:sync-applied", landingHandler);
263
+ }
264
+
265
+ function unwatchLandingsIfIdle() {
266
+ if (landingWatch > 0 || !landingHandler) return;
267
+ document.removeEventListener("clay:sync-applied", landingHandler);
268
+ landingHandler = null;
269
+ }
270
+
271
+ function land(rec) {
272
+ if (TERMINAL_STATES.has(rec.state) || rec.state === "landing") return;
273
+ disarm(rec);
274
+ rec.state = "landing";
275
+ landingWatch++;
276
+ watchLandings();
277
+ rec.landingTimer = setTimeout(() => {
278
+ // The change may have landed in a way this page cannot observe (no sync
279
+ // plugin, a frame that morphed nothing). Reporting done late is right;
280
+ // reporting a working agent as failed is not.
281
+ finish(rec, "done", null);
282
+ }, LANDING_TIMEOUT_MS);
283
+ emit(rec);
284
+ // The wire is done with this request even though the page is still waiting for
285
+ // its bytes, so the connection can go now.
286
+ closeStreamIfIdle();
287
+ }
288
+
289
+ function finish(rec, state, error) {
290
+ if (TERMINAL_STATES.has(rec.state)) return;
291
+ const wasLanding = rec.state === "landing";
292
+ disarm(rec);
293
+ clearTimeout(rec.landingTimer);
294
+ rec.landingTimer = null;
295
+ // A POST still on the wire has nothing left to report to, and an unbounded
296
+ // fetch is the one thing the deadline cannot otherwise reach.
297
+ rec.abort?.abort();
298
+ if (wasLanding) {
299
+ landingWatch--;
300
+ unwatchLandingsIfIdle();
301
+ }
302
+ rec.error = error;
303
+ rec.state = state;
304
+ releaseSaving(rec);
305
+ emit(rec);
306
+ closeStreamIfIdle();
307
+ prune();
308
+ if (rec.settle) {
309
+ const settle = rec.settle;
310
+ rec.settle = null;
311
+ settle(view(rec));
312
+ }
313
+ }
314
+
315
+ // --- save protection -------------------------------------------------------
316
+ //
317
+ // Two outbound problems the scoped live-sync merge does not touch, because it
318
+ // fixes the return path only.
319
+ //
320
+ // Save before send: autosave is debounced, so a request sent within that window
321
+ // hands the agent a file that does not contain the paragraph the user just typed
322
+ // and is asking about.
323
+ //
324
+ // Suspend autosave while a request is in flight: the save is last-writer-wins
325
+ // with a backup, not a reject. An autosave landing inside the watcher's poll and
326
+ // quiet window posts the pre-agent document, the server backs the agent's bytes
327
+ // up and still writes the browser's, the watcher's revalidation then fails, and
328
+ // nothing is published. The handler reports done, the page shows success, and
329
+ // the agent's work exists only in Backups.
330
+ //
331
+ // The lever is the save lane's own suspension, not a mutation-hub pause. Two
332
+ // reasons. The [persist] input autosave never goes through the hub at all — it is
333
+ // a raw input listener and a timer (autosave.js) — so a hub pause misses the
334
+ // clobber it is supposed to prevent. And pausing the hub blinds the scoped-sync
335
+ // dirty gate, whose one invariant is that it may over-report but must never
336
+ // under-report: a DOM edit the user made during the request would then read as
337
+ // clean, and the agent's frame would full-morph it away.
338
+
339
+ let saveDepth = 0;
340
+ let saveLane = null;
341
+
342
+ // Imported lazily and only in edit mode, through one shared promise. The module
343
+ // is not loaded on a view-mode page, so a static import from this
344
+ // `editOnly: false` plugin would drag the save lane into view mode to do nothing.
345
+ let saveLaneReady = null;
346
+ function loadSaveLane() {
347
+ if (!saveLaneReady) saveLaneReady = import("../core/save.js");
348
+ return saveLaneReady;
349
+ }
350
+
351
+ async function holdSaving(rec) {
352
+ if (!isEditMode || rec.holdsSave) return;
353
+ // Resolve the module BEFORE claiming the hold. Claiming it first and awaiting
354
+ // afterwards leaves a window where a cancel releases a hold that was never
355
+ // taken, and the import that resumes after it suspends a lane nothing will
356
+ // ever resume.
357
+ const lane = await loadSaveLane();
358
+ if (rec.state !== "sent") return;
359
+ saveLane = lane;
360
+ rec.holdsSave = true;
361
+ if (saveDepth++ > 0) return;
362
+ lane.suspendAutosave();
363
+ }
364
+
365
+ function releaseSaving(rec) {
366
+ if (!rec.holdsSave) return;
367
+ rec.holdsSave = false;
368
+ if (--saveDepth > 0) return;
369
+ saveDepth = 0;
370
+ saveLane?.resumeAutosave();
371
+ }
372
+
373
+ function once(eventName, timeout) {
374
+ return new Promise((resolve) => {
375
+ let done = false;
376
+ const settle = () => {
377
+ if (done) return;
378
+ done = true;
379
+ document.removeEventListener(eventName, settle);
380
+ resolve();
381
+ };
382
+ document.addEventListener(eventName, settle);
383
+ setTimeout(settle, timeout);
384
+ });
385
+ }
386
+
387
+ /**
388
+ * Put what the user is looking at on disk, or fail the request.
389
+ *
390
+ * The wire's promise is that the agent reads the document the user is asking
391
+ * about. A flush that could not deliver that has to end the request: posting
392
+ * anyway means an agent editing a file without the paragraph it was asked about,
393
+ * silently, which is worse than an error the UI can show.
394
+ */
395
+ async function flushSave() {
396
+ if (!isEditMode) return;
397
+ const clay = window.clay;
398
+ if (!clay || typeof clay.save !== "function") return;
399
+
400
+ // isSaveInProgress comes from the module, not from clay.internals: that
401
+ // surface is an opt-in satellite script, absent on every page the loader
402
+ // builds, so reading it here made "is a save on the wire" permanently false.
403
+ const { isSaveInProgress } = await import("../core/save-core.js");
404
+ const deadline = Date.now() + SAVE_FLUSH_TIMEOUT_MS;
405
+
406
+ for (let attempt = 0; attempt < 3; attempt++) {
407
+ if (isSaveInProgress()) {
408
+ await once("clay:save-saved", Math.max(0, deadline - Date.now()));
409
+ }
410
+
411
+ const result = await clay.save();
412
+ // `ok` is the outcome and msgType is the server's severity, so a save that
413
+ // landed with a warning is a success. `skipped` covers two outcomes: nothing
414
+ // to save, and a save already on the wire. Only the second means these bytes
415
+ // never left, and asking whether one is still in progress tells them apart.
416
+ if (result?.ok) return;
417
+ if (result?.msgType === "skipped" && !isSaveInProgress()) return;
418
+ if (result?.msgType === "error" || result?.msgType === "unknown") {
419
+ throw new Error(`could not save this page first: ${result.msg || result.msgType}`);
420
+ }
421
+ if (Date.now() >= deadline) break;
422
+ }
423
+ throw new Error("could not save this page before sending");
424
+ }
425
+
426
+ // --- sending ---------------------------------------------------------------
427
+
428
+ async function postFrame(body, signal) {
429
+ const response = await fetch(wireURL("/_/wire/send"), {
430
+ method: "POST",
431
+ headers: {
432
+ "Content-Type": "application/json",
433
+ "Page-URL": window.location.href,
434
+ },
435
+ body: JSON.stringify(body),
436
+ signal,
437
+ });
438
+ let reply = null;
439
+ try {
440
+ reply = await response.json();
441
+ } catch {
442
+ reply = null;
443
+ }
444
+ return { ok: response.ok, status: response.status, reply };
445
+ }
446
+
447
+ /**
448
+ * Send one request and track it to a terminal state.
449
+ *
450
+ * Returns a handle immediately; `handle.done` resolves with the final snapshot
451
+ * and never rejects, because a failed request is an outcome the UI renders
452
+ * rather than an exception it catches.
453
+ */
454
+ function send(payload, opts = {}) {
455
+ const id = typeof opts.id === "string" && opts.id ? opts.id : newId();
456
+ const type = typeof opts.type === "string" && opts.type ? opts.type : "wire/request";
457
+
458
+ // The id is request identity on the wire, on both sides: a reused one would
459
+ // route the first request's frames to the second record, leaving the first
460
+ // unresolvable and its save hold never released. Refused as an outcome rather
461
+ // than thrown, so a UI renders it the way it renders any other failure.
462
+ if (records.has(id)) {
463
+ const clash = {
464
+ id,
465
+ type,
466
+ state: "error",
467
+ text: "",
468
+ error: "a request with this id is already on the wire",
469
+ startedAt: Date.now(),
470
+ };
471
+ return {
472
+ id,
473
+ get state() {
474
+ return clash.state;
475
+ },
476
+ done: Promise.resolve(view(clash)),
477
+ cancel: () => false,
478
+ };
479
+ }
480
+
481
+ const rec = {
482
+ id,
483
+ type,
484
+ state: "sent",
485
+ text: "",
486
+ error: null,
487
+ startedAt: Date.now(),
488
+ timer: null,
489
+ landingTimer: null,
490
+ abort: typeof AbortController === "function" ? new AbortController() : null,
491
+ holdsSave: false,
492
+ settle: null,
493
+ };
494
+ rec.done = new Promise((resolve) => {
495
+ rec.settle = resolve;
496
+ });
497
+ records.set(id, rec);
498
+ emit(rec);
499
+
500
+ const dispatch = async () => {
501
+ // The record is re-checked after every await. A cancel can land in any of
502
+ // these gaps, and a step that ran on regardless would open a stream nobody
503
+ // closes or post a request the user already took back.
504
+ await holdSaving(rec);
505
+ if (rec.state !== "sent") return;
506
+
507
+ await flushSave();
508
+ if (rec.state !== "sent") return;
509
+
510
+ // Subscribe before posting. A handler can acknowledge in single-digit
511
+ // milliseconds, a fresh subscription deliberately replays nothing, and only
512
+ // terminal frames are retained at all, so posting first is how a page ends
513
+ // up watching a request whose ack it already missed.
514
+ await openStream();
515
+ if (rec.state !== "sent") {
516
+ closeStreamIfIdle(); // the stream this cancelled request just opened
517
+ return;
518
+ }
519
+
520
+ // Armed BEFORE the POST, not after it. A stalled fetch would otherwise leave
521
+ // the request unbounded, and an ack that arrives while the POST is still in
522
+ // flight — the ordinary case, since the page subscribed first — would be
523
+ // followed by a fresh ack timer that fails a healthy request 15s later.
524
+ arm(rec, ACK_TIMEOUT_MS, "the agent never answered");
525
+
526
+ const { ok, status, reply } = await postFrame(
527
+ { type: rec.type, id: rec.id, text: opts.text, payload },
528
+ rec.abort?.signal
529
+ );
530
+
531
+ // A frame may have moved this request on while its own POST was in flight.
532
+ // Only a request still waiting on that POST may be failed by it.
533
+ if (rec.state !== "sent") return;
534
+
535
+ if (!ok) {
536
+ finish(rec, "error", `the wire refused this request (${status})`);
537
+ return;
538
+ }
539
+ if (!reply || reply.delivered === 0) {
540
+ // Accepted by the router and taken by nobody. Reporting this as an error
541
+ // rather than a pending request is the difference between a UI that says
542
+ // "start an agent" and one that spins forever.
543
+ finish(rec, "error", "no agent is attached to this file");
544
+ }
545
+ };
546
+
547
+ dispatch().catch((err) => {
548
+ finish(rec, "error", err && err.message ? err.message : String(err));
549
+ });
550
+
551
+ return {
552
+ id,
553
+ get state() {
554
+ return rec.state;
555
+ },
556
+ done: rec.done,
557
+ cancel: () => cancel(id),
558
+ };
559
+ }
560
+
561
+ /**
562
+ * Stop completely: the request ends here, its late frames are ignored, and the
563
+ * handler is asked to stop. A cancel that only hid the spinner would leave the
564
+ * agent writing the file the user just took back.
565
+ */
566
+ function cancel(id) {
567
+ const rec = records.get(id);
568
+ if (!rec || !OPEN_STATES.has(rec.state)) return false;
569
+ postFrame({ type: "wire/cancel", id }).catch(() => {});
570
+ finish(rec, "cancelled", null);
571
+ return true;
572
+ }
573
+
574
+ function get(id) {
575
+ const rec = records.get(id);
576
+ return rec ? view(rec) : undefined;
577
+ }
578
+
579
+ function list() {
580
+ return [...records.values()].map(view);
581
+ }
582
+
583
+ function isBusy() {
584
+ for (const rec of records.values()) {
585
+ if (OPEN_STATES.has(rec.state)) return true;
586
+ }
587
+ return false;
588
+ }
589
+
590
+ function on(fn) {
591
+ if (typeof fn !== "function") return () => {};
592
+ listeners.add(fn);
593
+ return () => listeners.delete(fn);
594
+ }
595
+
596
+ export const wire = { send, cancel, get, list, isBusy, on };
597
+
598
+ export default wire;
@@ -423,6 +423,34 @@ class LiveSync {
423
423
  if (this.onConnect) this.onConnect();
424
424
  };
425
425
 
426
+ // The cursor frame is a NAMED SSE event, so it never reaches onmessage and
427
+ // never looks like data. Its `resync` flag says the server could not retain
428
+ // everything between where this client resumed and the baseline it is
429
+ // sending: what this page holds is stale in a way no replay will fix.
430
+ //
431
+ // The repair is the token-free fetch of the served document this class
432
+ // already runs for a change too large to send, so noticing the flag is the
433
+ // whole of the work.
434
+ this.sse.addEventListener('cursor', (event) => {
435
+ let data;
436
+ try {
437
+ data = JSON.parse(event.data);
438
+ } catch {
439
+ return;
440
+ }
441
+ if (!data || data.resync !== true) return;
442
+ console.log('[LiveSync] Server could not replay everything; refetching the document');
443
+ // _fetchServedDocument, deliberately, and not _fetchExternalChange: that
444
+ // one drops a fetch whose seq is at or below the external watermark, and
445
+ // the cursor baseline routinely is, since it is the server's high-water
446
+ // mark and our own last applied change may already have reached it. A
447
+ // resync that skipped itself for being "already seen" would leave the page
448
+ // permanently stale, which is the exact failure the flag exists to report.
449
+ this._fetchServedDocument(typeof data.seq === 'number' ? data.seq : undefined, {
450
+ repair: true,
451
+ });
452
+ });
453
+
426
454
  this.sse.onmessage = (event) => {
427
455
  const data = JSON.parse(event.data);
428
456
 
@@ -654,20 +682,29 @@ class LiveSync {
654
682
  * frame the epoch check refused (the fetched body is whatever disk holds
655
683
  * NOW, which is always safe to apply).
656
684
  */
657
- _fetchServedDocument(seq, attempt = 0) {
685
+ _fetchServedDocument(seq, { attempt = 0, repair = false } = {}) {
658
686
  const epoch = this._saveEpoch;
659
687
  fetch(new URL(window.location.href), { cache: 'no-store' })
660
688
  .then((response) => (response.ok ? response.text() : null))
661
689
  .then((html) => {
662
690
  if (this.isDestroyed || html == null) return;
663
- if (typeof seq === 'number' && seq < this._lastExternalSeq) return;
691
+ if (typeof seq === 'number' && seq < this._lastExternalSeq) {
692
+ // A newer external change superseded this one, and its own fetch will
693
+ // queue a body. Except for a repair: that one exists because the server
694
+ // said replay cannot fix this page, so if the newer fetch fails there is
695
+ // nothing else coming. Refetch rather than drop the only repair.
696
+ if (repair && attempt < 3) {
697
+ this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
698
+ }
699
+ return;
700
+ }
664
701
  if (this._saveEpoch > epoch) {
665
702
  // An own save landed while the GET was in flight, so this body may
666
703
  // predate it. Save-response order proves nothing about disk-write
667
704
  // order — refetch for the newest bytes instead of dropping.
668
705
  if (attempt < 3) {
669
706
  console.log('[LiveSync] Refetching external change: own save landed mid-fetch');
670
- this._fetchServedDocument(seq, attempt + 1);
707
+ this._fetchServedDocument(seq, { attempt: attempt + 1, repair });
671
708
  }
672
709
  return;
673
710
  }
@@ -989,8 +1026,12 @@ class LiveSync {
989
1026
  // onmessage before applyUpdate is ever called. Covers every SSE morph
990
1027
  // source (peer edit, version restore, body-swap) since they all funnel
991
1028
  // through this single choke point.
1029
+ //
1030
+ // `source` is what lets a listener tell the two apply paths apart. clay.wire
1031
+ // waits for a DISK frame to call an agent's write landed, and another tab's
1032
+ // edit arriving first would otherwise report the wrong bytes as delivered.
992
1033
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
993
- detail: { seq }
1034
+ detail: { seq, source: 'peer' }
994
1035
  }));
995
1036
  } finally {
996
1037
  this._log('applyUpdate - morph complete, resuming mutations');
@@ -1042,7 +1083,14 @@ class LiveSync {
1042
1083
  const parser = new DOMParser();
1043
1084
  const newDoc = parser.parseFromString(html, 'text/html');
1044
1085
 
1045
- if (pageMaybeDirty()) {
1086
+ // Lane-guarded exactly like the peer path. A view-mode tab has no save
1087
+ // baseline — every writer of lastSavedContents is edit-gated — so
1088
+ // protectDiskDoc can only ever refuse, and the frame would hold, retry
1089
+ // every 3s, and hold again forever. The gate still reads dirty there,
1090
+ // because persistProbeDirty inspects the live DOM and a visitor can type
1091
+ // into a [persist] field. Before the resync repair this path was
1092
+ // unreachable outside the live lane; now it is the repair's own route.
1093
+ if (this.lane === 'live' && pageMaybeDirty()) {
1046
1094
  const protection = protectDiskDoc({ newDoc });
1047
1095
  if (!protection.ok) {
1048
1096
  // Hold: nothing morphs, no baseline moves, and deliberately NO
@@ -1094,7 +1142,7 @@ class LiveSync {
1094
1142
 
1095
1143
  window.scrollTo(scrollX, scrollY);
1096
1144
 
1097
- if (retainedRoots === 0 && !pageMaybeDirty()) {
1145
+ if (this.lane === 'live' && retainedRoots === 0 && !pageMaybeDirty()) {
1098
1146
  // Clean apply: the DOM now IS the disk state, so a local comparison
1099
1147
  // capture of it is the truthful baseline. The next no-op save skips,
1100
1148
  // beforeunload stays quiet. The dirty re-check matters: typing that
@@ -1115,7 +1163,7 @@ class LiveSync {
1115
1163
  }
1116
1164
 
1117
1165
  document.dispatchEvent(new CustomEvent('clay:sync-applied', {
1118
- detail: { seq }
1166
+ detail: { seq, source: 'disk' }
1119
1167
  }));
1120
1168
  } finally {
1121
1169
  this._log('applyExternal - morph complete, resuming mutations');