@panphora/clayjs 0.5.0 → 0.6.1

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.
@@ -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;