@1agh/maude 0.55.0 → 0.57.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/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -407,6 +407,45 @@ export function isOverlayTarget(t: EventTarget | null): boolean {
|
|
|
407
407
|
);
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Artboard "chrome" — inside an artboard (`[data-dc-screen]`) but OUTSIDE its
|
|
412
|
+
* `.dc-artboard-body` content box, i.e. the label strip + border that
|
|
413
|
+
* `use-artboard-drag.tsx` binds its own bubble-phase `onPointerDown` to
|
|
414
|
+
* (`bindHandle()`, spread directly on the `.dc-artboard` article). The router
|
|
415
|
+
* runs in the CAPTURE phase ahead of that handler; claiming a bare press here
|
|
416
|
+
* — same as any other chrome hit, the router still selects the artboard —
|
|
417
|
+
* used to `stopImmediatePropagation` the native event before it ever reached
|
|
418
|
+
* the drag hook's listener, so no artboard could be repositioned by its
|
|
419
|
+
* header at all (issue-71). A modifier-held press is not exempted: the drag
|
|
420
|
+
* hook itself ignores Cmd/Ctrl (reserved for deep-select), so nothing
|
|
421
|
+
* downstream needs the raw event in that case.
|
|
422
|
+
*/
|
|
423
|
+
export function isArtboardDragChrome(t: EventTarget | null): boolean {
|
|
424
|
+
if (!t || typeof (t as Element).closest !== 'function') return false;
|
|
425
|
+
const el = t as Element;
|
|
426
|
+
return !!el.closest('[data-dc-screen]') && !el.closest('.dc-artboard-body');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Should the router's pointerdown/mousedown handler skip
|
|
431
|
+
* `stopImmediatePropagation` so the event still reaches use-artboard-drag.tsx?
|
|
432
|
+
* Pulled out of the two near-identical handlers below so the decision itself
|
|
433
|
+
* — not just `isArtboardDragChrome`'s target test — is covered by a DOM-free
|
|
434
|
+
* unit test (issue-71).
|
|
435
|
+
*/
|
|
436
|
+
export function yieldsToArtboardDrag(
|
|
437
|
+
action: RouterAction,
|
|
438
|
+
target: EventTarget | null,
|
|
439
|
+
modifiers: { metaKey: boolean; ctrlKey: boolean }
|
|
440
|
+
): boolean {
|
|
441
|
+
return (
|
|
442
|
+
action.kind === 'select' &&
|
|
443
|
+
!modifiers.metaKey &&
|
|
444
|
+
!modifiers.ctrlKey &&
|
|
445
|
+
isArtboardDragChrome(target)
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
410
449
|
export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
411
450
|
const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
|
|
412
451
|
|
|
@@ -496,13 +535,22 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
496
535
|
activeTool: getActiveTool(),
|
|
497
536
|
})
|
|
498
537
|
);
|
|
538
|
+
// issue-71 — a bare press on an artboard's own drag chrome must still
|
|
539
|
+
// reach that artboard's `onPointerDown` (use-artboard-drag.tsx) so its
|
|
540
|
+
// pending→dragging state machine can arm, or no artboard can ever be
|
|
541
|
+
// repositioned by its header. The router still SELECTS (dispatch below
|
|
542
|
+
// is unconditional) and still preventDefaults the browser's native
|
|
543
|
+
// click-drag text-selection — it only skips stopImmediatePropagation,
|
|
544
|
+
// which is the one call that would keep the event from ever reaching
|
|
545
|
+
// the bubble-phase listener bound to the very element it targeted.
|
|
546
|
+
const yields = yieldsToArtboardDrag(action, e.target, e);
|
|
499
547
|
if (action.kind !== 'no-op') {
|
|
500
548
|
// Suppress native behavior on every event the router claims —
|
|
501
549
|
// button presses don't fire, inputs don't focus, the canvas
|
|
502
550
|
// content's own click handlers don't run. The router lives in
|
|
503
551
|
// capture phase so this fires before descendants.
|
|
504
552
|
e.preventDefault();
|
|
505
|
-
e.stopImmediatePropagation();
|
|
553
|
+
if (!yields) e.stopImmediatePropagation();
|
|
506
554
|
}
|
|
507
555
|
dispatch(action);
|
|
508
556
|
};
|
|
@@ -531,9 +579,14 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
531
579
|
activeTool: getActiveTool(),
|
|
532
580
|
})
|
|
533
581
|
);
|
|
582
|
+
// issue-71 — same exemption as onPointerDown above: preventDefault still
|
|
583
|
+
// runs (blocks the native click-drag text-selection an un-prevented
|
|
584
|
+
// mousedown would start), but stopImmediatePropagation is skipped so the
|
|
585
|
+
// paired pointerdown reaching use-artboard-drag.tsx isn't undone here.
|
|
586
|
+
const yields = yieldsToArtboardDrag(action, e.target, e);
|
|
534
587
|
if (action.kind !== 'no-op') {
|
|
535
588
|
e.preventDefault();
|
|
536
|
-
e.stopImmediatePropagation();
|
|
589
|
+
if (!yields) e.stopImmediatePropagation();
|
|
537
590
|
// preventDefault above stops mousedown's default FOCUS, so a Cmd-click
|
|
538
591
|
// select would leave the iframe unfocused and every in-canvas keyboard
|
|
539
592
|
// shortcut (arrow-nudge, Cmd+D, copy/paste-style, Delete) dead until a
|
package/apps/studio/server.ts
CHANGED
|
@@ -34,7 +34,7 @@ import { createHttp } from './http.ts';
|
|
|
34
34
|
import { createInspectRegistry } from './inspect.ts';
|
|
35
35
|
import { startHeapWatch } from './mem.ts';
|
|
36
36
|
import { normalizeSessionKey, runInSession, SESSION_HEADER } from './session-scope.ts';
|
|
37
|
-
import {
|
|
37
|
+
import { createSyncSupervisor } from './sync/supervisor.ts';
|
|
38
38
|
import {
|
|
39
39
|
assertContainment,
|
|
40
40
|
isForbiddenRoute,
|
|
@@ -680,13 +680,15 @@ ctx.bus.on('fs:json', (rel: string) => {
|
|
|
680
680
|
// Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
|
|
681
681
|
// linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
|
|
682
682
|
// off after fsWatch so the agent's bus subscription receives every fs event.
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
683
|
+
// Owned by a supervisor rather than started inline: linking a project from the
|
|
684
|
+
// cloud panel cycles the runtime in place (ctx.syncControl), so pressing
|
|
685
|
+
// Connect starts syncing instead of printing "restart the studio server".
|
|
686
|
+
const syncRuntime = createSyncSupervisor(ctx, collab ? { registry: collab.registry } : {});
|
|
687
|
+
ctx.syncControl = syncRuntime;
|
|
688
|
+
try {
|
|
689
|
+
await syncRuntime.start();
|
|
690
|
+
} catch (err) {
|
|
691
|
+
console.error('[sync] startup failed — continuing in solo mode:', err);
|
|
690
692
|
}
|
|
691
693
|
|
|
692
694
|
const url = `http://localhost:${server.port}`;
|
|
@@ -733,6 +735,17 @@ async function shutdown() {
|
|
|
733
735
|
// previously-unanswered "how does app-quit reach the grandchild" question.
|
|
734
736
|
cancelSignin();
|
|
735
737
|
cancelInstall();
|
|
738
|
+
// Addendum Task 8 — bridges now outlive their WebSocket, so socket-close is
|
|
739
|
+
// no longer the reaper it used to be. App quit / dev-server shutdown is the
|
|
740
|
+
// ONE lifetime boundary the detached model deliberately does NOT survive
|
|
741
|
+
// (DDR-166's SIGTERM-first path): extending a session across a project or
|
|
742
|
+
// branch *switch* is the point; extending it across a quit is not, and a
|
|
743
|
+
// surviving `claude` subprocess after quit would be an orphan.
|
|
744
|
+
try {
|
|
745
|
+
acp.stopAll();
|
|
746
|
+
} catch {
|
|
747
|
+
/* best-effort — the process is exiting either way */
|
|
748
|
+
}
|
|
736
749
|
fsWatch.stop();
|
|
737
750
|
try {
|
|
738
751
|
gitWatch.stop();
|
|
@@ -750,7 +763,7 @@ async function shutdown() {
|
|
|
750
763
|
/* timer cleanup is best-effort */
|
|
751
764
|
}
|
|
752
765
|
try {
|
|
753
|
-
|
|
766
|
+
await syncRuntime.stop();
|
|
754
767
|
} catch {
|
|
755
768
|
/* best-effort — provider sockets will be closed by process exit anyway */
|
|
756
769
|
}
|
|
@@ -32,6 +32,16 @@ import { withRepoLock } from '../git/repo-lock.ts';
|
|
|
32
32
|
/** How long the tree must be quiet before a commit fires. */
|
|
33
33
|
const DEFAULT_DEBOUNCE_MS = 3000;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The ceiling on that quiescence — how long a PENDING batch may be deferred by
|
|
37
|
+
* a continuing stream of notes before it commits anyway. Without it, quiescence
|
|
38
|
+
* batching has no upper bound and a steady note cadence defers history forever
|
|
39
|
+
* (see `note`). Generous enough that ordinary typing still coalesces into one
|
|
40
|
+
* commit, small enough that a cell's ephemeral disk is never far ahead of its
|
|
41
|
+
* history.
|
|
42
|
+
*/
|
|
43
|
+
const DEFAULT_MAX_DEBOUNCE_MS = 15_000;
|
|
44
|
+
|
|
35
45
|
export interface GitRunResult {
|
|
36
46
|
code: number;
|
|
37
47
|
stdout: string;
|
|
@@ -53,6 +63,12 @@ export interface AutoCommitOptions {
|
|
|
53
63
|
run: GitRunner;
|
|
54
64
|
/** Quiescence window. */
|
|
55
65
|
debounceMs?: number;
|
|
66
|
+
/**
|
|
67
|
+
* Ceiling on that quiescence: a pending batch commits after at most this long
|
|
68
|
+
* from its first note, however many notes keep arriving. Bounds the
|
|
69
|
+
* "steady stream defers history forever" case — see `note`.
|
|
70
|
+
*/
|
|
71
|
+
maxDebounceMs?: number;
|
|
56
72
|
/** Committer identity — the machine, never the human. */
|
|
57
73
|
bot?: EditAttribution;
|
|
58
74
|
log?: Pick<Console, 'warn' | 'error' | 'log'>;
|
|
@@ -154,6 +170,7 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
154
170
|
repoRoot,
|
|
155
171
|
run,
|
|
156
172
|
debounceMs = DEFAULT_DEBOUNCE_MS,
|
|
173
|
+
maxDebounceMs = DEFAULT_MAX_DEBOUNCE_MS,
|
|
157
174
|
bot = DEFAULT_BOT,
|
|
158
175
|
log = console,
|
|
159
176
|
} = opts;
|
|
@@ -163,9 +180,13 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
163
180
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
164
181
|
let inFlight: Promise<CommitOutcome | null> | null = null;
|
|
165
182
|
let stopped = false;
|
|
183
|
+
/** When the currently-pending batch received its first note (see `note`). */
|
|
184
|
+
let batchStartedAt = 0;
|
|
166
185
|
|
|
167
186
|
function note(relPath: string, who?: EditAttribution | null): void {
|
|
168
187
|
if (stopped) return;
|
|
188
|
+
// Start of a batch — the clock the ceiling below is measured against.
|
|
189
|
+
if (touched.size === 0) batchStartedAt = Date.now();
|
|
169
190
|
touched.add(relPath);
|
|
170
191
|
// Last writer wins for attribution. A commit that coalesced two people's
|
|
171
192
|
// edits can only name one author; the message body names them, and the
|
|
@@ -173,6 +194,23 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
173
194
|
// that keeps this history readable.
|
|
174
195
|
if (who) author = who;
|
|
175
196
|
if (timer) clearTimeout(timer);
|
|
197
|
+
// QUIESCENCE, BUT WITH A CEILING. Resetting the timer on every note is what
|
|
198
|
+
// makes a typing session one commit instead of four hundred — and, alone,
|
|
199
|
+
// it means a steady stream of notes defers the commit forever. That is not
|
|
200
|
+
// hypothetical here: the hub notes on every stored document, Hocuspocus
|
|
201
|
+
// stores on its own ~2 s debounce, and anyone who can drive doc updates at
|
|
202
|
+
// a cadence under `debounceMs` — including a VIEWER, who deliberately holds
|
|
203
|
+
// the `comment` capability — can hold a project's history open indefinitely
|
|
204
|
+
// while `/health` stays green. Before the commit set stopped being "files
|
|
205
|
+
// we just wrote", a no-write store event returned before reaching here, so
|
|
206
|
+
// the reachable cadence was bounded by real edits.
|
|
207
|
+
//
|
|
208
|
+
// So: still coalesce, but never defer a pending batch past `maxDebounceMs`
|
|
209
|
+
// from its first note. Worst-case commit latency becomes bounded and small,
|
|
210
|
+
// and the batching behaviour for ordinary typing is unchanged (a human
|
|
211
|
+
// pauses long before the ceiling).
|
|
212
|
+
const sinceBatchStart = Date.now() - batchStartedAt;
|
|
213
|
+
const delay = Math.max(0, Math.min(debounceMs, maxDebounceMs - sinceBatchStart));
|
|
176
214
|
timer = setTimeout(() => {
|
|
177
215
|
timer = null;
|
|
178
216
|
// `.catch` and not just `void`: a discarded rejection here is an
|
|
@@ -180,7 +218,7 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
180
218
|
flush().catch((err) => {
|
|
181
219
|
log.warn?.(`[autocommit] flush failed: ${err instanceof Error ? err.message : err}`);
|
|
182
220
|
});
|
|
183
|
-
},
|
|
221
|
+
}, delay);
|
|
184
222
|
}
|
|
185
223
|
|
|
186
224
|
async function flush(): Promise<CommitOutcome | null> {
|
|
@@ -242,12 +280,33 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
242
280
|
const add = await run(['add', '--', ...files], { cwd: repoRoot });
|
|
243
281
|
if (add.code !== 0) {
|
|
244
282
|
log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
|
|
283
|
+
// RE-QUEUE, exactly as the `commit` branch below does. `touched` was
|
|
284
|
+
// cleared by `flush()` before we got here, so returning without this
|
|
285
|
+
// drops the whole coalesced batch — including other people's edits in
|
|
286
|
+
// the same window — from history, permanently and silently.
|
|
287
|
+
// One bad pathspec is enough: `git add -- <path that vanished>` exits
|
|
288
|
+
// 128, and paths are now noted on the strength of a read taken up to
|
|
289
|
+
// `debounceMs` earlier, for files whose lifetime this process does not
|
|
290
|
+
// own (a canvas deleted inside the quiescence window).
|
|
291
|
+
for (const f of files) touched.add(f);
|
|
245
292
|
return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
|
|
246
293
|
}
|
|
247
294
|
|
|
248
295
|
// Nothing staged ⇒ the write was a no-op (an echo, or identical bytes).
|
|
249
296
|
// Not an error, and committing an empty change would be noise.
|
|
250
|
-
|
|
297
|
+
//
|
|
298
|
+
// SCOPED TO `files`, and that pathspec is load-bearing. The question is
|
|
299
|
+
// "did OUR paths stage anything", and an unrelated entry sitting in the
|
|
300
|
+
// index answers it wrongly — the probe says yes, then `commit --only --
|
|
301
|
+
// <our unchanged paths>` exits non-zero with "no changes added to commit".
|
|
302
|
+
// `touched` re-queues but nothing re-arms the timer, so the agent wedges
|
|
303
|
+
// and fails identically forever while `/health` keeps answering 200.
|
|
304
|
+
// Reachable since the commit set stopped being "files we just wrote": a
|
|
305
|
+
// batch of exclusively-unchanged paths is now routine under cell pairing,
|
|
306
|
+
// where the studio child's projector writes the bytes first.
|
|
307
|
+
const staged = await run(['diff', '--cached', '--name-only', '--', ...files], {
|
|
308
|
+
cwd: repoRoot,
|
|
309
|
+
});
|
|
251
310
|
if (staged.code === 0 && staged.stdout.trim() === '') {
|
|
252
311
|
return { ok: false, reason: 'nothing-to-commit', files };
|
|
253
312
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Desktop ↔ cloud live pairing, variant C2 — the ONE exception to "a cell does
|
|
2
|
+
// not sync" (DDR-209 / Cloud Phase 27 D2).
|
|
3
|
+
//
|
|
4
|
+
// THE PROBLEM THIS OPENS A DOOR FOR. A cell holds two disjoint Y.Doc worlds: the
|
|
5
|
+
// hub's Hocuspocus documents (which the DESKTOP syncs to, SQLite-backed) and the
|
|
6
|
+
// studio child's own collab rooms (which BROWSERS join, in-memory). Nothing
|
|
7
|
+
// crosses. Two people on the same project — one in the app, one in a tab — never
|
|
8
|
+
// see each other's cursors and never see each other's edits, which is the
|
|
9
|
+
// headline of the cloud product.
|
|
10
|
+
//
|
|
11
|
+
// WHY THE DOOR IS SHAPED LIKE THIS. DDR-209 forbids the studio child from
|
|
12
|
+
// syncing out, for two named reasons, and BOTH are preserved literally here
|
|
13
|
+
// rather than argued away:
|
|
14
|
+
//
|
|
15
|
+
// 1. "dial OUT from the cell to a third-party hub carrying the project's
|
|
16
|
+
// canvases" — so the URL must be LOOPBACK. Not "usually loopback", not
|
|
17
|
+
// "loopback in production": the resolver refuses anything else, and the
|
|
18
|
+
// tenant's own `.design/config.json linkedHub.url` is never read for it.
|
|
19
|
+
// A cell talking to 127.0.0.1 is a process talking to itself.
|
|
20
|
+
// 2. "start a SECOND autocommit over the working tree the hub is already
|
|
21
|
+
// committing" — so autocommit must be explicitly DISABLED, by a flag the
|
|
22
|
+
// caller has to set on purpose. Default-absent means refuse.
|
|
23
|
+
//
|
|
24
|
+
// A third condition is ours, not DDR-209's, and it narrows further: shared-doc
|
|
25
|
+
// must be ON. Without it the loopback provider would open a SECOND Y.Doc per
|
|
26
|
+
// canvas beside the room's — the two-doc world this pairing exists to collapse —
|
|
27
|
+
// and its agent would become a second doc→file writer for no benefit at all.
|
|
28
|
+
//
|
|
29
|
+
// WHAT IS TAKEN FROM THE TENANT'S CONFIG, AND WHY IT MUST BE. The URL and the
|
|
30
|
+
// credential come from the environment (the hub minted them for its own child).
|
|
31
|
+
// The WORKSPACE ID does not: it comes from `linkedHub.workspaceId`, the tenant's
|
|
32
|
+
// own file, because it decides the DOCUMENT NAME on the wire (DDR-192 §5). The
|
|
33
|
+
// desktop resolves it from that same field. If the cell derived a different one,
|
|
34
|
+
// the browser and the desktop would sync to two differently-named documents and
|
|
35
|
+
// converge on nothing — this whole feature, silently doing exactly what it did
|
|
36
|
+
// before. Honouring the tenant's `url` is a dial-out; honouring their
|
|
37
|
+
// `workspaceId` is the only way the two ends can meet.
|
|
38
|
+
|
|
39
|
+
import { isLoopbackHost } from './loopback.ts';
|
|
40
|
+
|
|
41
|
+
/** Everything the runtime needs to open the loopback provider. */
|
|
42
|
+
export interface CellPairing {
|
|
43
|
+
/** Always a loopback http(s) URL — asserted, never assumed. */
|
|
44
|
+
url: string;
|
|
45
|
+
/** The hub-minted, cell-derived credential. Never a user's own token. */
|
|
46
|
+
token: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Why pairing is off, when the operator asked for it and it did not engage. */
|
|
50
|
+
export type CellPairingRefusal =
|
|
51
|
+
| 'not-workspace-mode'
|
|
52
|
+
| 'not-requested'
|
|
53
|
+
| 'no-url'
|
|
54
|
+
| 'not-loopback'
|
|
55
|
+
| 'no-token'
|
|
56
|
+
| 'autocommit-enabled'
|
|
57
|
+
| 'shared-doc-off';
|
|
58
|
+
|
|
59
|
+
export interface CellPairingVerdict {
|
|
60
|
+
pairing: CellPairing | null;
|
|
61
|
+
/** Present whenever `pairing` is null. */
|
|
62
|
+
refusal?: CellPairingRefusal;
|
|
63
|
+
/** One sentence for the log — populated only when the operator ASKED for
|
|
64
|
+
* pairing and we refused, so a silent misconfiguration is impossible. */
|
|
65
|
+
detail?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Env truthiness, matching `server.ts`'s MAUDE_SHARED_DOC parsing exactly. */
|
|
69
|
+
function on(value: string | undefined): boolean {
|
|
70
|
+
return /^(1|true|on|yes)$/i.test(value ?? '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Strip control characters and cap length before a value reaches a log line.
|
|
75
|
+
*
|
|
76
|
+
* Every refusal `detail` this module builds interpolates `MAUDE_LOOPBACK_SYNC_URL`
|
|
77
|
+
* verbatim, and today that env var is hub-set only — no tenant-reachable input
|
|
78
|
+
* writes it. But this feature's entire threat model rests on "the URL is
|
|
79
|
+
* provably loopback," so the log line proving that should not be the one place
|
|
80
|
+
* that still trusts the string unsanitized. Mirrors the codebase's own
|
|
81
|
+
* precedent (`sanitizeForLog` in `apps/hub/src/server.mjs`) rather than
|
|
82
|
+
* inventing a second scrub shape.
|
|
83
|
+
*/
|
|
84
|
+
export function sanitizeForLog(value: string): string {
|
|
85
|
+
let out = '';
|
|
86
|
+
const s = value.slice(0, 256);
|
|
87
|
+
for (let i = 0; i < s.length; i++) {
|
|
88
|
+
const code = s.charCodeAt(i);
|
|
89
|
+
out += code < 0x20 || code === 0x7f ? '·' : s[i];
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the cell pairing from the environment, or explain the refusal.
|
|
96
|
+
*
|
|
97
|
+
* Every condition is a hard gate. There is no "warn and continue" branch on
|
|
98
|
+
* purpose: the failure mode of a partial pairing (a provider that dials out, or
|
|
99
|
+
* a second committer) is worse than no pairing at all, and no pairing is exactly
|
|
100
|
+
* what shipped before this feature.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveCellPairing(
|
|
103
|
+
env: Record<string, string | undefined> = process.env
|
|
104
|
+
): CellPairingVerdict {
|
|
105
|
+
if (env.MAUDE_WORKSPACE_MODE !== '1') {
|
|
106
|
+
return { pairing: null, refusal: 'not-workspace-mode' };
|
|
107
|
+
}
|
|
108
|
+
if (!on(env.MAUDE_CELL_PAIRING)) {
|
|
109
|
+
return { pairing: null, refusal: 'not-requested' };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// From here on the operator ASKED for pairing, so every refusal gets a
|
|
113
|
+
// sentence — a cell that quietly declines is the bug this feature is fixing.
|
|
114
|
+
const url = (env.MAUDE_LOOPBACK_SYNC_URL ?? '').trim();
|
|
115
|
+
if (!url) {
|
|
116
|
+
return {
|
|
117
|
+
pairing: null,
|
|
118
|
+
refusal: 'no-url',
|
|
119
|
+
detail: 'MAUDE_CELL_PAIRING is set but MAUDE_LOOPBACK_SYNC_URL is empty.',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
let parsed: URL;
|
|
123
|
+
try {
|
|
124
|
+
parsed = new URL(url);
|
|
125
|
+
} catch {
|
|
126
|
+
return {
|
|
127
|
+
pairing: null,
|
|
128
|
+
refusal: 'not-loopback',
|
|
129
|
+
detail: `MAUDE_LOOPBACK_SYNC_URL is not a URL: ${sanitizeForLog(url)}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (!isLoopbackHost(parsed.hostname)) {
|
|
133
|
+
return {
|
|
134
|
+
pairing: null,
|
|
135
|
+
refusal: 'not-loopback',
|
|
136
|
+
detail:
|
|
137
|
+
`refusing MAUDE_LOOPBACK_SYNC_URL ${sanitizeForLog(url)} — a cell syncs to ITSELF or to nothing. ` +
|
|
138
|
+
'Only 127.0.0.1 / localhost / ::1 are accepted (DDR-209: no dial-out).',
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const token = (env.MAUDE_LOOPBACK_SYNC_TOKEN ?? '').trim();
|
|
143
|
+
if (!token) {
|
|
144
|
+
return {
|
|
145
|
+
pairing: null,
|
|
146
|
+
refusal: 'no-token',
|
|
147
|
+
detail: 'MAUDE_CELL_PAIRING is set but MAUDE_LOOPBACK_SYNC_TOKEN is empty.',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!on(env.MAUDE_SYNC_NO_AUTOCOMMIT)) {
|
|
152
|
+
return {
|
|
153
|
+
pairing: null,
|
|
154
|
+
refusal: 'autocommit-enabled',
|
|
155
|
+
detail:
|
|
156
|
+
'refusing to pair without MAUDE_SYNC_NO_AUTOCOMMIT=1 — the hub is the sole committer ' +
|
|
157
|
+
'in a cell, and a second autocommit over the same working tree is the exact ' +
|
|
158
|
+
'duplication DDR-209 exists to prevent.',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!on(env.MAUDE_SHARED_DOC)) {
|
|
163
|
+
return {
|
|
164
|
+
pairing: null,
|
|
165
|
+
refusal: 'shared-doc-off',
|
|
166
|
+
detail:
|
|
167
|
+
'refusing to pair without MAUDE_SHARED_DOC=1 — pairing IS the single-shared-doc ' +
|
|
168
|
+
'model (DDR-064). Without it the loopback provider would open a second Y.Doc per ' +
|
|
169
|
+
'canvas beside the browser room, which is the two-doc world pairing exists to end.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { pairing: { url, token } };
|
|
174
|
+
}
|
|
@@ -26,6 +26,13 @@ import { hostname } from 'node:os';
|
|
|
26
26
|
import type * as Y from 'yjs';
|
|
27
27
|
|
|
28
28
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
29
|
+
import {
|
|
30
|
+
MAX_ANNOTATIONS_BYTES,
|
|
31
|
+
MAX_COMMENTS_BYTES,
|
|
32
|
+
MAX_CSS_BYTES,
|
|
33
|
+
MAX_HTML_BYTES,
|
|
34
|
+
MAX_META_BYTES,
|
|
35
|
+
} from './limits.ts';
|
|
29
36
|
|
|
30
37
|
/**
|
|
31
38
|
* Y.Doc shared-type names introduced by Task 4. Distinct namespace from
|
|
@@ -59,12 +66,11 @@ export const Y_SYNC_TYPES = {
|
|
|
59
66
|
* has no upstream-enforced size cap; the codec is the consumer's guard.
|
|
60
67
|
* Mirrors the existing /_api/annotations 1 MB cap (api.ts) so the sync path
|
|
61
68
|
* doesn't bypass the HTTP-layer guard.
|
|
69
|
+
*
|
|
70
|
+
* Defined in `limits.ts` (a leaf) and re-exported here so every existing
|
|
71
|
+
* importer is unchanged — see that file for why they had to move.
|
|
62
72
|
*/
|
|
63
|
-
export
|
|
64
|
-
export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
|
|
65
|
-
export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
|
|
66
|
-
export const MAX_META_BYTES = 1 * 1024 * 1024;
|
|
67
|
-
export const MAX_CSS_BYTES = 4 * 1024 * 1024;
|
|
73
|
+
export { MAX_ANNOTATIONS_BYTES, MAX_COMMENTS_BYTES, MAX_CSS_BYTES, MAX_HTML_BYTES, MAX_META_BYTES };
|
|
68
74
|
|
|
69
75
|
function byteLengthUtf8(s: string): number {
|
|
70
76
|
return Buffer.byteLength(s, 'utf8');
|
|
@@ -47,6 +47,22 @@ export interface SyncStatusSnapshot {
|
|
|
47
47
|
/** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
|
|
48
48
|
* for the true count). Treat as text, never HTML. */
|
|
49
49
|
rejectedSlugs?: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Canvases this run brought DOWN from the project — documents that existed
|
|
52
|
+
* only on the hub and are now real local files.
|
|
53
|
+
*
|
|
54
|
+
* This field was briefly `remoteGap`, "what the project has that this machine
|
|
55
|
+
* does not". That name stopped being true the moment the pull landed: the
|
|
56
|
+
* diff is computed before providers are built and recorded after, so it
|
|
57
|
+
* enumerated exactly the documents that had just arrived. Observed live —
|
|
58
|
+
* `_sync.json` naming two canvases that were sitting on disk.
|
|
59
|
+
*
|
|
60
|
+
* "What arrived this run" is both true and the more useful fact: it is what
|
|
61
|
+
* the Synced state tells the user to go and open. Absent when the hub could
|
|
62
|
+
* not be asked (old hub, offline) or when nothing was pulled. Names are
|
|
63
|
+
* hub-controlled — treat as text, never HTML, and see the cap in `notePulled`.
|
|
64
|
+
*/
|
|
65
|
+
pulled?: { names: string[]; count: number };
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
@@ -75,6 +91,8 @@ export interface ConnectionMonitor {
|
|
|
75
91
|
/** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
|
|
76
92
|
* applied): bumps `lastSyncAt` to now. */
|
|
77
93
|
noteSyncActivity(slug: string): void;
|
|
94
|
+
/** Record the canvases this run pulled down from the project (see `pulled`). */
|
|
95
|
+
notePulled(slugs: readonly string[]): void;
|
|
78
96
|
/** Current snapshot (defensive copy). */
|
|
79
97
|
snapshot(): SyncStatusSnapshot;
|
|
80
98
|
/** Tear down timers. */
|
|
@@ -100,7 +118,14 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
100
118
|
// DDR-102 — per-doc states (pending/connected/auth-rejected).
|
|
101
119
|
const docStates = new Map<string, DocSyncState>();
|
|
102
120
|
|
|
103
|
-
|
|
121
|
+
// NOT born connected. The monitor used to start `online`, so from the instant
|
|
122
|
+
// a link was created — before a socket, before a token was accepted, before a
|
|
123
|
+
// byte moved — every surface reading `state` reported success. On a healthy
|
|
124
|
+
// fast connect that was harmless (the truth arrived milliseconds later); on a
|
|
125
|
+
// refused or unreachable one it was the whole bug: the user was shown the
|
|
126
|
+
// intention and read it as the result. `connecting` is the honest seed, and
|
|
127
|
+
// it is the state a link genuinely occupies until a provider says otherwise.
|
|
128
|
+
let state: SyncState = 'connecting';
|
|
104
129
|
let queuedOps = 0;
|
|
105
130
|
let lastSyncAt: number | null = null;
|
|
106
131
|
let offlineSince: number | null = null;
|
|
@@ -111,6 +136,25 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
111
136
|
let flashTimer: TimerHandle | null = null;
|
|
112
137
|
let stopped = false;
|
|
113
138
|
|
|
139
|
+
/** Canvases pulled down from the project this run. */
|
|
140
|
+
let pulled: SyncStatusSnapshot['pulled'];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Start the clock at construction, not at the first provider event.
|
|
144
|
+
*
|
|
145
|
+
* `enterGrace` is reachable ONLY from `noteProviderStatus`, so a link where
|
|
146
|
+
* no provider ever reports — every provider rejected during boot, a hub that
|
|
147
|
+
* never completes an upgrade — would have sat in `connecting` forever and
|
|
148
|
+
* never reached the offline banner. Seeding `state` honestly is not enough on
|
|
149
|
+
* its own; something has to be counting from the moment the link exists.
|
|
150
|
+
*/
|
|
151
|
+
function armInitialGrace(): void {
|
|
152
|
+
graceTimer = setTimer(() => {
|
|
153
|
+
graceTimer = null;
|
|
154
|
+
goOffline();
|
|
155
|
+
}, graceMs);
|
|
156
|
+
}
|
|
157
|
+
|
|
114
158
|
function snapshot(): SyncStatusSnapshot {
|
|
115
159
|
const docs = { synced: 0, pending: 0, rejected: 0 };
|
|
116
160
|
const rejectedSlugs: string[] = [];
|
|
@@ -130,9 +174,31 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
130
174
|
updatedAt: now(),
|
|
131
175
|
docs,
|
|
132
176
|
rejectedSlugs,
|
|
177
|
+
...(pulled ? { pulled } : {}),
|
|
133
178
|
};
|
|
134
179
|
}
|
|
135
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Record the canvases this run pulled down from the project.
|
|
183
|
+
*
|
|
184
|
+
* `names` is capped like `rejectedSlugs` — this reaches a UI and a JSON file,
|
|
185
|
+
* and an unbounded list of hub-chosen names is a hub-controlled payload size.
|
|
186
|
+
* `count` keeps the true total, so the cap never falsifies the number the
|
|
187
|
+
* user is shown. An empty list clears the field rather than recording a zero:
|
|
188
|
+
* a run that pulled nothing has nothing to say.
|
|
189
|
+
*/
|
|
190
|
+
function notePulled(slugs: readonly string[]): void {
|
|
191
|
+
// Every other mutator carries this guard; without it a late call could
|
|
192
|
+
// write `_sync.json` and broadcast for a monitor that has been torn down.
|
|
193
|
+
// Unreachable today (the sole caller checks `stopped` too) — kept so the
|
|
194
|
+
// invariant does not depend on the caller remembering it.
|
|
195
|
+
if (stopped) return;
|
|
196
|
+
pulled = slugs.length
|
|
197
|
+
? { names: slugs.slice(0, MAX_REJECTED_SLUGS), count: slugs.length }
|
|
198
|
+
: undefined;
|
|
199
|
+
emit();
|
|
200
|
+
}
|
|
201
|
+
|
|
136
202
|
function emit(): void {
|
|
137
203
|
onChange?.(snapshot());
|
|
138
204
|
}
|
|
@@ -181,15 +247,20 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
181
247
|
}
|
|
182
248
|
|
|
183
249
|
function enterGrace(): void {
|
|
184
|
-
// Already
|
|
185
|
-
if (state
|
|
250
|
+
// Already offline — stay there until a provider reports 'connected'.
|
|
251
|
+
if (state === 'offline' || state === 'offline-long') return;
|
|
252
|
+
// Already counting down — don't restart the clock. The guard used to be
|
|
253
|
+
// `state !== 'online'`; the timer, not the state, is what says whether a
|
|
254
|
+
// countdown is running, and with the `connecting` seed those are no longer
|
|
255
|
+
// the same question.
|
|
256
|
+
if (graceTimer !== null) return;
|
|
186
257
|
state = 'connecting';
|
|
187
|
-
if (graceTimer !== null) clearTimer(graceTimer);
|
|
188
258
|
graceTimer = setTimer(() => {
|
|
189
259
|
graceTimer = null;
|
|
190
260
|
goOffline();
|
|
191
261
|
}, graceMs);
|
|
192
|
-
emit
|
|
262
|
+
// No emit here — the sole caller emits once for the whole update, so a
|
|
263
|
+
// demotion that does not change `state` still reaches the readers.
|
|
193
264
|
}
|
|
194
265
|
|
|
195
266
|
function goOffline(): void {
|
|
@@ -204,19 +275,56 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
204
275
|
emit();
|
|
205
276
|
}
|
|
206
277
|
|
|
278
|
+
armInitialGrace();
|
|
279
|
+
|
|
207
280
|
return {
|
|
208
281
|
noteProviderStatus(providerId, status) {
|
|
209
282
|
if (stopped) return;
|
|
283
|
+
|
|
284
|
+
// EMIT ONLY ON A REAL CHANGE.
|
|
285
|
+
//
|
|
286
|
+
// Every emit is a synchronous `_sync.json` write plus a WS broadcast to
|
|
287
|
+
// every open tab. Provider status is driven by the socket lifecycle,
|
|
288
|
+
// which the hub controls, and there is one provider per canvas — 75+ in
|
|
289
|
+
// the reported project. An unconditional emit here turns a flapping (or
|
|
290
|
+
// deliberately hostile) hub into a sustained disk-write and fan-out loop
|
|
291
|
+
// on the dev-server's main thread. The pre-existing code emitted only on
|
|
292
|
+
// a transition; adding the demotion below must not cost that property.
|
|
293
|
+
const prevStatus = providerStatuses.get(providerId);
|
|
210
294
|
providerStatuses.set(providerId, status);
|
|
295
|
+
let changed = prevStatus !== status;
|
|
296
|
+
|
|
297
|
+
// A document whose socket is gone is not a synced document.
|
|
298
|
+
//
|
|
299
|
+
// `docs.synced` used to only ever RISE — nothing demoted a `connected`
|
|
300
|
+
// doc, so a hub that died left the last count frozen on screen, which is
|
|
301
|
+
// the most convincing form of the lie: it was true a moment ago. The
|
|
302
|
+
// provider id IS the slug (`index.ts` passes `canvas.slug`), so the
|
|
303
|
+
// monitor already knows which document just lost its socket.
|
|
304
|
+
//
|
|
305
|
+
// `auth-rejected` is deliberately NOT demoted. The hub gave an answer;
|
|
306
|
+
// losing the socket afterwards does not turn that answer back into
|
|
307
|
+
// "still trying", and letting it would hide a rotated credential behind
|
|
308
|
+
// a spinner.
|
|
309
|
+
if (status !== 'connected' && docStates.get(providerId) === 'connected') {
|
|
310
|
+
docStates.set(providerId, 'pending');
|
|
311
|
+
changed = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
211
314
|
const agg = aggregate();
|
|
212
315
|
if (agg === 'connected') {
|
|
316
|
+
// goOnline() emits for the transition; otherwise only a real change
|
|
317
|
+
// (this provider's status moved, or a document was demoted) is news.
|
|
213
318
|
if (state !== 'online') goOnline();
|
|
319
|
+
else if (changed) emit();
|
|
214
320
|
return;
|
|
215
321
|
}
|
|
216
322
|
// Aggregate is connecting or disconnected → start (or continue) the
|
|
217
323
|
// grace countdown. Once offline/offline-long we stay there until a
|
|
218
324
|
// provider reports 'connected' again.
|
|
219
|
-
|
|
325
|
+
const before = state;
|
|
326
|
+
enterGrace();
|
|
327
|
+
if (changed || state !== before) emit();
|
|
220
328
|
},
|
|
221
329
|
|
|
222
330
|
noteLocalEdit() {
|
|
@@ -245,6 +353,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
245
353
|
emit();
|
|
246
354
|
},
|
|
247
355
|
|
|
356
|
+
notePulled,
|
|
357
|
+
|
|
248
358
|
snapshot,
|
|
249
359
|
|
|
250
360
|
stop() {
|