@1agh/maude 0.54.0 → 0.56.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/annotations-layer.tsx +11 -1
- 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/canvas-build-sandbox.ts +101 -9
- package/apps/studio/canvas-build-worker.ts +7 -1
- package/apps/studio/canvas-build.ts +9 -1
- package/apps/studio/client/app.jsx +49 -3
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/CloudBar.jsx +505 -45
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- package/apps/studio/client/styles/3-shell-maude.css +30 -0
- package/apps/studio/client/styles/4-components.css +72 -0
- package/apps/studio/cloud/endpoints.ts +104 -9
- 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 +1545 -1545
- 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 +210 -110
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +11 -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/index.ts +239 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/supervisor.ts +178 -0
- package/apps/studio/test/cloud-endpoints.test.ts +326 -4
- package/apps/studio/test/csrf-write-guard.test.ts +19 -2
- 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-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}`;
|
|
@@ -750,7 +752,7 @@ async function shutdown() {
|
|
|
750
752
|
/* timer cleanup is best-effort */
|
|
751
753
|
}
|
|
752
754
|
try {
|
|
753
|
-
|
|
755
|
+
await syncRuntime.stop();
|
|
754
756
|
} catch {
|
|
755
757
|
/* best-effort — provider sockets will be closed by process exit anyway */
|
|
756
758
|
}
|
|
@@ -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');
|