@1agh/maude 0.60.7 → 1.0.3
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/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +201 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -71
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +915 -308
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +71 -108
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +90 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The cell child's file-event wiring — Sync v2 Increment 2/3 (DDR-226 §4/§6).
|
|
2
|
+
//
|
|
3
|
+
// The doorbell, both buttons, assembled once at boot:
|
|
4
|
+
//
|
|
5
|
+
// resolveCellCtl → do we have a loopback hub and a token? (workspace mode)
|
|
6
|
+
// createCtlProvider → hold the read-only `maude.files` channel open
|
|
7
|
+
// createCtlHealer → hub → child: poke ⇒ read the journal ⇒ emit the
|
|
8
|
+
// `fs:any` the container's watcher owed us
|
|
9
|
+
// createCellWriteNudge → child → hub: we wrote ⇒ name the paths ⇒ the hub
|
|
10
|
+
// re-reads its own disk and journals them
|
|
11
|
+
//
|
|
12
|
+
// The two directions are deliberately symmetric and deliberately unequal in
|
|
13
|
+
// what they carry: the hub's frame carries a head and no path (DDR-054 — a path
|
|
14
|
+
// would be a path the hub chose), and the child's carries paths and no content
|
|
15
|
+
// (the hub re-stats and re-hashes for itself). Neither end can make the other
|
|
16
|
+
// believe something about bytes it did not look at.
|
|
17
|
+
//
|
|
18
|
+
// Downstream of the heal, everything is machinery that already exists:
|
|
19
|
+
// `fs:any` → `createHmrBroadcaster` → `canvas-hmr {mode:'asset'|'css'|'module'}`
|
|
20
|
+
// → the iframe repoints a broken `<img>` (DDR-224) or swaps a stylesheet. No new
|
|
21
|
+
// UI path, no new reload semantics.
|
|
22
|
+
//
|
|
23
|
+
// GATED ON WORKSPACE MODE, NOT ON CELL PAIRING — the whole point. Pairing is a
|
|
24
|
+
// one-tenant pilot allowlist; the watcher gap is on every cell. See the header
|
|
25
|
+
// of `ctl-provider.ts` for why the pairing preconditions do not apply to a
|
|
26
|
+
// read-only stateless channel.
|
|
27
|
+
//
|
|
28
|
+
// Locally this never starts: outside a container `fs.watch` fires and a second
|
|
29
|
+
// event source would double-reload every canvas on every edit, exactly the
|
|
30
|
+
// reason `createContainerWriteBridge` carries the same gate.
|
|
31
|
+
|
|
32
|
+
import type { Context } from '../context.ts';
|
|
33
|
+
import { createCellWriteNudge } from './cell-write-nudge.ts';
|
|
34
|
+
import { createCtlHealer } from './ctl-heal.ts';
|
|
35
|
+
import { createCtlProvider, resolveCellCtl } from './ctl-provider.ts';
|
|
36
|
+
|
|
37
|
+
export interface CellFileEvents {
|
|
38
|
+
stop(): void;
|
|
39
|
+
/** Pokes received on the channel. */
|
|
40
|
+
received(): number;
|
|
41
|
+
/** Paths announced onward as `fs:any`. */
|
|
42
|
+
healed(): number;
|
|
43
|
+
/** Nudge requests the hub accepted. */
|
|
44
|
+
nudged(): number;
|
|
45
|
+
/** Paths named in those requests. */
|
|
46
|
+
nudgedPaths(): number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start the hub→child file-event bridge. Returns null when this process is not
|
|
51
|
+
* a cell studio child (which is every desktop, and every local dev server).
|
|
52
|
+
*
|
|
53
|
+
* Never throws: the studio must start whether or not it has a doorbell.
|
|
54
|
+
*/
|
|
55
|
+
export function startCellFileEvents(
|
|
56
|
+
ctx: Context,
|
|
57
|
+
env: Record<string, string | undefined> = process.env
|
|
58
|
+
): CellFileEvents | null {
|
|
59
|
+
const target = resolveCellCtl(env);
|
|
60
|
+
if (!target) return null;
|
|
61
|
+
|
|
62
|
+
const nudge = createCellWriteNudge({
|
|
63
|
+
hubUrl: target.url,
|
|
64
|
+
token: target.token,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const healer = createCtlHealer({
|
|
68
|
+
hubUrl: target.url,
|
|
69
|
+
token: target.token,
|
|
70
|
+
// The one side effect: announce a path onto the bus. Nothing here writes a
|
|
71
|
+
// file, and nothing here trusts the hub's row beyond its shape — the
|
|
72
|
+
// canvas layer re-reads the real disk to decide what to do about it.
|
|
73
|
+
//
|
|
74
|
+
// The mute is what stops us telling the hub a fact it just told us. It is
|
|
75
|
+
// not a loop guard — `recordWrite` is a no-op on identical bytes, so the
|
|
76
|
+
// echo would die on its own — it is simply not paying for the round trip.
|
|
77
|
+
emit: (rel) => {
|
|
78
|
+
nudge.mute(rel);
|
|
79
|
+
ctx.bus.emit('fs:any', rel);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Baseline the heal cursor from the hub's head BEFORE the first poke — see
|
|
84
|
+
// `anchor`. Fire-and-forget: the studio must start whether or not the hub
|
|
85
|
+
// answers, and an unanchored healer still works the way it always did.
|
|
86
|
+
void healer.anchor();
|
|
87
|
+
|
|
88
|
+
const provider = createCtlProvider({
|
|
89
|
+
url: target.url,
|
|
90
|
+
token: target.token,
|
|
91
|
+
onPoke: (head) => healer.onPoke(head),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Every write this process makes surfaces here, because both synthetic-event
|
|
95
|
+
// sources (`createContainerWriteBridge` off `activity:suppress`, and
|
|
96
|
+
// `announceWrite` off the projector) converge on `fs:any`. See the module
|
|
97
|
+
// header for why that is the complete set and why incompleteness would only
|
|
98
|
+
// cost latency.
|
|
99
|
+
const offFsAny = ctx.bus.on('fs:any', (rel: string) => nudge.note(rel));
|
|
100
|
+
|
|
101
|
+
console.log(
|
|
102
|
+
'[sync/ctl] file-event channel attached to this cell’s own hub — hub-process writes heal open canvases without a reload, and writes made here reach the journal at once instead of on the walk-import belt.'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
stop() {
|
|
107
|
+
offFsAny();
|
|
108
|
+
provider.stop();
|
|
109
|
+
healer.stop();
|
|
110
|
+
nudge.stop();
|
|
111
|
+
},
|
|
112
|
+
received: () => provider.received(),
|
|
113
|
+
healed: () => healer.healed(),
|
|
114
|
+
nudged: () => nudge.sent(),
|
|
115
|
+
nudgedPaths: () => nudge.named(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -65,11 +65,25 @@ export interface CellPairingVerdict {
|
|
|
65
65
|
detail?: string;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
/** Env truthiness
|
|
68
|
+
/** Env truthiness for the opt-in interlocks below (explicit `1`-style value). */
|
|
69
69
|
function on(value: string | undefined): boolean {
|
|
70
70
|
return /^(1|true|on|yes)$/i.test(value ?? '');
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Is the single-shared-doc model enabled? — DEFAULT ON (the DDR-064 cutover,
|
|
75
|
+
* Sync v2 Increment 7). Only an explicit falsy value opts a machine back onto
|
|
76
|
+
* the proven two-doc path, and that opt-out IS the rollback for this release:
|
|
77
|
+
* both paths coexist, nothing is deleted, `MAUDE_SHARED_DOC=0` flips back.
|
|
78
|
+
*
|
|
79
|
+
* ONE parser, exported — `server.ts` and the pairing gate used to carry
|
|
80
|
+
* byte-identical mirrors of the opt-in regex, and a default flip is exactly
|
|
81
|
+
* the change that would have let them drift.
|
|
82
|
+
*/
|
|
83
|
+
export function sharedDocEnabled(env: Record<string, string | undefined> = process.env): boolean {
|
|
84
|
+
return !/^(0|false|off|no)$/i.test(env.MAUDE_SHARED_DOC ?? '');
|
|
85
|
+
}
|
|
86
|
+
|
|
73
87
|
/**
|
|
74
88
|
* Strip control characters and cap length before a value reaches a log line.
|
|
75
89
|
*
|
|
@@ -159,14 +173,15 @@ export function resolveCellPairing(
|
|
|
159
173
|
};
|
|
160
174
|
}
|
|
161
175
|
|
|
162
|
-
if (!
|
|
176
|
+
if (!sharedDocEnabled(env)) {
|
|
163
177
|
return {
|
|
164
178
|
pairing: null,
|
|
165
179
|
refusal: 'shared-doc-off',
|
|
166
180
|
detail:
|
|
167
|
-
'refusing to pair
|
|
168
|
-
'model (DDR-064). Without it the loopback provider would open a
|
|
169
|
-
'canvas beside the browser room, which is the two-doc world
|
|
181
|
+
'refusing to pair with MAUDE_SHARED_DOC explicitly off — pairing IS the ' +
|
|
182
|
+
'single-shared-doc model (DDR-064). Without it the loopback provider would open a ' +
|
|
183
|
+
'second Y.Doc per canvas beside the browser room, which is the two-doc world ' +
|
|
184
|
+
'pairing exists to end.',
|
|
170
185
|
};
|
|
171
186
|
}
|
|
172
187
|
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The doorbell's other button — the cell child telling its hub it wrote.
|
|
2
|
+
//
|
|
3
|
+
// Increment 3 shipped `POST /api/journal/report` and nothing that calls it. The
|
|
4
|
+
// asymmetry that left is the one a user actually feels:
|
|
5
|
+
//
|
|
6
|
+
// desktop → cloud a peer PUTs through the write door, the door hooks the
|
|
7
|
+
// journal synchronously, the hub pokes, peers pull. Seconds.
|
|
8
|
+
// cloud → desktop the cell's OWN studio child writes straight to the shared
|
|
9
|
+
// checkout. No door, no hook, no row — so the hub does not
|
|
10
|
+
// know, cannot poke, and the change waits for the 15-minute
|
|
11
|
+
// walk-import belt.
|
|
12
|
+
//
|
|
13
|
+
// Both halves "work"; only one is prompt, and that reads as "sync is broken"
|
|
14
|
+
// because a person drops an image in the cloud and it is not on their laptop
|
|
15
|
+
// ten minutes later. Observed live: `assets/2e32c88c.png` written at 14:45,
|
|
16
|
+
// journalled at 14:55 with `source: walk-import`.
|
|
17
|
+
//
|
|
18
|
+
// ── Why a nudge and not a report ───────────────────────────────────────────
|
|
19
|
+
//
|
|
20
|
+
// This carries PATHS ONLY. It cannot state a hash, a size, a class, or a
|
|
21
|
+
// deletion, because the hub re-stats and re-hashes its own disk for every path
|
|
22
|
+
// it is handed (`journal.recordWrite`). That is what makes it safe to accept
|
|
23
|
+
// from a process the hub supervises but does not trust to speak about content —
|
|
24
|
+
// and it is why being INCOMPLETE here is a latency bug and never a correctness
|
|
25
|
+
// one. `walk-import` remains the backstop it always was.
|
|
26
|
+
//
|
|
27
|
+
// ── Why `fs:any` is the right input ────────────────────────────────────────
|
|
28
|
+
//
|
|
29
|
+
// A cell's recursive `fs.watch` does not fire for atomic tmp+rename writes, so
|
|
30
|
+
// there is no watcher to subscribe to. But the studio already had to solve
|
|
31
|
+
// exactly this for hot-reload, and the answer it landed on is a single bus
|
|
32
|
+
// event: `createContainerWriteBridge` synthesises `fs:any` from the
|
|
33
|
+
// `activity:suppress` every API write path arms, and `announceWrite` does the
|
|
34
|
+
// same for the doc→file projector, which arms nothing. Between them they are
|
|
35
|
+
// the complete set of writes this process makes — which is the same
|
|
36
|
+
// completeness argument the HMR path already stakes itself on. Subscribing to
|
|
37
|
+
// their common output means this module needs no new instrumentation at any
|
|
38
|
+
// write site, and a future write path that remembers to hot-reload gets a
|
|
39
|
+
// journal row for free.
|
|
40
|
+
//
|
|
41
|
+
// ── The one thing it must not do ───────────────────────────────────────────
|
|
42
|
+
//
|
|
43
|
+
// `createCtlHealer` emits `fs:any` too, for paths the HUB just told us about.
|
|
44
|
+
// Nudging those back is harmless — same bytes, same hash, `recordWrite` is a
|
|
45
|
+
// no-op, no row, no poke, so it cannot loop — but it is a request per healed
|
|
46
|
+
// path for a fact the hub stated in the first place. `mute()` drops that echo
|
|
47
|
+
// at the source instead of paying for it.
|
|
48
|
+
|
|
49
|
+
import { isProjectFileShape, isRuntimeStateRel } from './file-membership.ts';
|
|
50
|
+
|
|
51
|
+
/** Collect a burst of writes into one request. */
|
|
52
|
+
const COALESCE_MS = 250;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* How long a healed path stays muted. Long enough to cover the healer's own
|
|
56
|
+
* `fs:any` (immediate) and the coalesce window behind it; short enough that a
|
|
57
|
+
* genuine local edit to a file we just pulled still nudges.
|
|
58
|
+
*/
|
|
59
|
+
const MUTE_MS = 1_500;
|
|
60
|
+
|
|
61
|
+
/** The route's own ceiling (`MAX_NUDGE_PATHS`). Batches split at it. */
|
|
62
|
+
const MAX_PATHS_PER_NUDGE = 64;
|
|
63
|
+
|
|
64
|
+
export interface CellWriteNudgeOptions {
|
|
65
|
+
hubUrl: string;
|
|
66
|
+
token: string;
|
|
67
|
+
fetchImpl?: typeof fetch;
|
|
68
|
+
log?: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
69
|
+
coalesceMs?: number;
|
|
70
|
+
muteMs?: number;
|
|
71
|
+
setTimeoutImpl?: typeof setTimeout;
|
|
72
|
+
clearTimeoutImpl?: typeof clearTimeout;
|
|
73
|
+
/** Injected in tests. Defaults to `Date.now`. */
|
|
74
|
+
now?: () => number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CellWriteNudge {
|
|
78
|
+
/** A path this process wrote. Filtered, coalesced, then named in a nudge. */
|
|
79
|
+
note(rel: string): void;
|
|
80
|
+
/** This path came FROM the hub — do not tell the hub about it. */
|
|
81
|
+
mute(rel: string): void;
|
|
82
|
+
/** Send whatever is pending now (boot, tests, shutdown). */
|
|
83
|
+
flush(): Promise<void>;
|
|
84
|
+
stop(): void;
|
|
85
|
+
/** Requests that got a 2xx — the sender half of the honesty counters. */
|
|
86
|
+
sent(): number;
|
|
87
|
+
/** Paths named across all requests. */
|
|
88
|
+
named(): number;
|
|
89
|
+
/** Paths dropped as hub echoes. */
|
|
90
|
+
muted(): number;
|
|
91
|
+
/** Consecutive failed requests. Non-zero and climbing means the hub is gone. */
|
|
92
|
+
failures(): number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Build the nudge sender.
|
|
97
|
+
*
|
|
98
|
+
* Never throws and never rejects: a hub that refuses the nudge costs the
|
|
99
|
+
* freshness this module exists to buy, and nothing else. The child keeps
|
|
100
|
+
* serving, the checkout keeps its bytes, and the reconciler still finds the
|
|
101
|
+
* drift on its own belt.
|
|
102
|
+
*/
|
|
103
|
+
export function createCellWriteNudge(opts: CellWriteNudgeOptions): CellWriteNudge {
|
|
104
|
+
const log = opts.log ?? console;
|
|
105
|
+
const coalesceMs = opts.coalesceMs ?? COALESCE_MS;
|
|
106
|
+
const muteMs = opts.muteMs ?? MUTE_MS;
|
|
107
|
+
const setTimeoutImpl = opts.setTimeoutImpl ?? setTimeout;
|
|
108
|
+
const clearTimeoutImpl = opts.clearTimeoutImpl ?? clearTimeout;
|
|
109
|
+
const now = opts.now ?? Date.now;
|
|
110
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
111
|
+
const url = `${opts.hubUrl.replace(/\/+$/, '')}/api/journal/report`;
|
|
112
|
+
|
|
113
|
+
const pending = new Set<string>();
|
|
114
|
+
const mutedUntil = new Map<string, number>();
|
|
115
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
116
|
+
let inFlight: Promise<void> | null = null;
|
|
117
|
+
let stopped = false;
|
|
118
|
+
let sent = 0;
|
|
119
|
+
let named = 0;
|
|
120
|
+
let muted = 0;
|
|
121
|
+
let failures = 0;
|
|
122
|
+
/** Log the first failure at error, the rest at debug — one hub outage is one line. */
|
|
123
|
+
let warnedFailure = false;
|
|
124
|
+
|
|
125
|
+
function sweepMutes(at: number): void {
|
|
126
|
+
for (const [rel, until] of mutedUntil) {
|
|
127
|
+
if (until <= at) mutedUntil.delete(rel);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function postOnce(paths: string[]): Promise<boolean> {
|
|
132
|
+
try {
|
|
133
|
+
const res = await doFetch(url, {
|
|
134
|
+
method: 'POST',
|
|
135
|
+
headers: {
|
|
136
|
+
authorization: `Bearer ${opts.token}`,
|
|
137
|
+
'content-type': 'application/json',
|
|
138
|
+
},
|
|
139
|
+
body: JSON.stringify({ paths }),
|
|
140
|
+
});
|
|
141
|
+
// The answer is `{ noted }` and deliberately says nothing about what
|
|
142
|
+
// landed (it would be an existence oracle over the checkout). So the ONLY
|
|
143
|
+
// thing worth reading here is the status.
|
|
144
|
+
return res.ok;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function drain(): Promise<void> {
|
|
151
|
+
if (stopped || pending.size === 0) return;
|
|
152
|
+
const batch = [...pending];
|
|
153
|
+
pending.clear();
|
|
154
|
+
for (let i = 0; i < batch.length; i += MAX_PATHS_PER_NUDGE) {
|
|
155
|
+
const slice = batch.slice(i, i + MAX_PATHS_PER_NUDGE);
|
|
156
|
+
const ok = await postOnce(slice);
|
|
157
|
+
if (ok) {
|
|
158
|
+
sent += 1;
|
|
159
|
+
named += slice.length;
|
|
160
|
+
failures = 0;
|
|
161
|
+
warnedFailure = false;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
failures += 1;
|
|
165
|
+
if (!warnedFailure) {
|
|
166
|
+
warnedFailure = true;
|
|
167
|
+
log.warn?.(
|
|
168
|
+
`[sync/nudge] could not tell the hub about ${slice.length} write(s) — it will find them on the walk-import belt instead. Changes made here reach peers late until this recovers.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
// A dropped nudge is latency, not loss: `walk-import` still finds the
|
|
172
|
+
// drift. Re-queueing a failed batch forever would turn a hub restart into
|
|
173
|
+
// an unbounded retry storm against a route that is rate-limited, so the
|
|
174
|
+
// paths go and the backstop takes it from here.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function schedule(): void {
|
|
179
|
+
if (stopped || timer !== null) return;
|
|
180
|
+
timer = setTimeoutImpl(() => {
|
|
181
|
+
timer = null;
|
|
182
|
+
void flush();
|
|
183
|
+
}, coalesceMs);
|
|
184
|
+
timer.unref?.();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function flush(): Promise<void> {
|
|
188
|
+
if (inFlight) return inFlight;
|
|
189
|
+
inFlight = drain().finally(() => {
|
|
190
|
+
inFlight = null;
|
|
191
|
+
});
|
|
192
|
+
return inFlight;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
note(rel: string): void {
|
|
197
|
+
if (stopped || typeof rel !== 'string' || rel.length === 0) return;
|
|
198
|
+
// Separators are NOT normalised here. Every `fs:any` producer already
|
|
199
|
+
// emits forward slashes (`fs-watch`, both halves of `hmr-broadcast`,
|
|
200
|
+
// `announceWrite`, the healer's journal rows), so there is nothing left to
|
|
201
|
+
// fix — and rewriting anyway would be actively wrong on POSIX, where
|
|
202
|
+
// `a\b.css` is one legal filename and `a/b.css` is a different file. Let
|
|
203
|
+
// the classifier judge what arrives, exactly as the hub will.
|
|
204
|
+
//
|
|
205
|
+
// Cheap and pure. The hub's classifier is still the authority on
|
|
206
|
+
// membership; this only keeps the obvious noise off the wire, and
|
|
207
|
+
// `_state/` alone would otherwise be most of the traffic on a busy canvas.
|
|
208
|
+
if (!isProjectFileShape(rel) || isRuntimeStateRel(rel)) return;
|
|
209
|
+
const at = now();
|
|
210
|
+
const until = mutedUntil.get(rel);
|
|
211
|
+
if (until !== undefined && until > at) {
|
|
212
|
+
mutedUntil.delete(rel);
|
|
213
|
+
muted += 1;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
pending.add(rel);
|
|
217
|
+
schedule();
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
mute(rel: string): void {
|
|
221
|
+
if (stopped || typeof rel !== 'string' || rel.length === 0) return;
|
|
222
|
+
const at = now();
|
|
223
|
+
sweepMutes(at);
|
|
224
|
+
mutedUntil.set(rel, at + muteMs);
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
flush,
|
|
228
|
+
|
|
229
|
+
stop(): void {
|
|
230
|
+
stopped = true;
|
|
231
|
+
if (timer !== null) {
|
|
232
|
+
clearTimeoutImpl(timer);
|
|
233
|
+
timer = null;
|
|
234
|
+
}
|
|
235
|
+
pending.clear();
|
|
236
|
+
mutedUntil.clear();
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
sent: () => sent,
|
|
240
|
+
named: () => named,
|
|
241
|
+
muted: () => muted,
|
|
242
|
+
failures: () => failures,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -194,8 +194,8 @@ export function annotationsFromDoc(doc: Y.Doc): string | null {
|
|
|
194
194
|
* This distinction is load-bearing for cold start (the 2026-08-14 annotations
|
|
195
195
|
* eraser): the wrapper is a non-empty STRING, so every `!== ''` emptiness
|
|
196
196
|
* guard let a stale hub wrapper overwrite a peer's real strokes — and with the
|
|
197
|
-
* strokes went the `assets/<sha8>` references
|
|
198
|
-
* dropped images never crossed machines. Live delete-all still materializes
|
|
197
|
+
* strokes went the `assets/<sha8>` references the asset lane pulled by, so
|
|
198
|
+
* freshly dropped images never crossed machines. Live delete-all still materializes
|
|
199
199
|
* the wrapper through `writeAnnotationsIfChanged` (deletes must propagate);
|
|
200
200
|
* only COLD-START decisions treat it as emptiness.
|
|
201
201
|
*/
|
|
@@ -256,8 +256,58 @@ function sharedMetaCanonical(meta: Record<string, unknown>): string {
|
|
|
256
256
|
return JSON.stringify(out);
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
/**
|
|
260
|
+
* THE META LANE CAN DUPLICATE ITSELF, SO IT HAS TO BE ABLE TO HEAL.
|
|
261
|
+
*
|
|
262
|
+
* Meta is a WHOLE VALUE carried in a Y.Text, written by delete-all +
|
|
263
|
+
* insert-all. That is duplication-prone by construction, and the case is not
|
|
264
|
+
* exotic — it is the normal one: every peer holding the file tries to publish
|
|
265
|
+
* it, and two peers inserting the same string into an empty lane produce two
|
|
266
|
+
* inserts Yjs has no reason to merge. The result is
|
|
267
|
+
* `{"title":"Home"}{"title":"Home"}`: not a value, not empty, and — because
|
|
268
|
+
* every consumer runs it through `JSON.parse` and bails — indistinguishable
|
|
269
|
+
* from "this canvas has no meta".
|
|
270
|
+
*
|
|
271
|
+
* Observed live: nine canvases created on a laptop reached the hub with correct
|
|
272
|
+
* bodies and doubled meta, so a third machine syncing the project got their
|
|
273
|
+
* titles, kinds and design-system bindings dropped on the floor. Silently.
|
|
274
|
+
*
|
|
275
|
+
* Preventing every interleaving is not on offer while the lane is a Y.Text. So
|
|
276
|
+
* the lane repairs instead: a stack of IDENTICAL copies is recognised for what
|
|
277
|
+
* it is — one value, published twice — and collapses back to one. Anything else
|
|
278
|
+
* unparseable stays `null`, which routes to the existing "the doc has no
|
|
279
|
+
* opinion" path rather than to a guess.
|
|
280
|
+
*/
|
|
281
|
+
export function normalizeSharedMeta(raw: string | null): string | null {
|
|
282
|
+
if (raw === null || raw.length === 0) return null;
|
|
283
|
+
if (parsesAsObject(raw)) return raw;
|
|
284
|
+
// Split only at a `}{` seam — the exact shape concurrent whole-value inserts
|
|
285
|
+
// produce. A JSON string containing "}{" cannot create a false seam here,
|
|
286
|
+
// because each resulting segment must itself parse as a complete object.
|
|
287
|
+
const parts = raw.split(/(?<=\})(?=\{)/);
|
|
288
|
+
if (parts.length < 2) return null;
|
|
289
|
+
const first = parts[0] ?? '';
|
|
290
|
+
if (!parts.every((p) => p === first) || !parsesAsObject(first)) return null;
|
|
291
|
+
return first;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function parsesAsObject(s: string): boolean {
|
|
295
|
+
try {
|
|
296
|
+
const v = parseJsonSafe(s);
|
|
297
|
+
return !!v && typeof v === 'object' && !Array.isArray(v);
|
|
298
|
+
} catch {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
259
303
|
/** The synced shared-meta JSON string held in the doc, or null when unset. */
|
|
260
304
|
export function metaFromDoc(doc: Y.Doc): string | null {
|
|
305
|
+
return normalizeSharedMeta(doc.getText(Y_SYNC_TYPES.meta).toString());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** What the doc LITERALLY holds — for the repair path, which needs to know
|
|
309
|
+
* that the stored text differs from the value it normalises to. */
|
|
310
|
+
export function rawMetaFromDoc(doc: Y.Doc): string | null {
|
|
261
311
|
const s = doc.getText(Y_SYNC_TYPES.meta).toString();
|
|
262
312
|
return s.length > 0 ? s : null;
|
|
263
313
|
}
|
|
@@ -283,7 +333,10 @@ export function applyMetaToDoc(doc: Y.Doc, fullMetaJson: string, origin?: unknow
|
|
|
283
333
|
return false;
|
|
284
334
|
}
|
|
285
335
|
const t = doc.getText(Y_SYNC_TYPES.meta);
|
|
286
|
-
|
|
336
|
+
// Compare against the NORMALISED value, not the literal text: a lane holding
|
|
337
|
+
// two identical copies already carries this exact meta, and treating it as a
|
|
338
|
+
// difference would rewrite it on every pass forever.
|
|
339
|
+
if (normalizeSharedMeta(t.toString()) === shared) return false;
|
|
287
340
|
doc.transact(() => {
|
|
288
341
|
if (t.length > 0) t.delete(0, t.length);
|
|
289
342
|
t.insert(0, shared);
|
|
@@ -291,6 +344,30 @@ export function applyMetaToDoc(doc: Y.Doc, fullMetaJson: string, origin?: unknow
|
|
|
291
344
|
return true;
|
|
292
345
|
}
|
|
293
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Collapse a duplicated meta lane back to one copy.
|
|
349
|
+
*
|
|
350
|
+
* Repairing on APPLY alone is not enough — a canvas nobody edits again would
|
|
351
|
+
* keep its doubled meta forever, and a machine that syncs the project
|
|
352
|
+
* afterwards would keep dropping it. This runs on cold start, where the doc has
|
|
353
|
+
* synced and "what it holds" is a fact rather than ignorance.
|
|
354
|
+
*
|
|
355
|
+
* Returns true when it changed something. A lane that is empty, already single,
|
|
356
|
+
* or unparseable-in-some-other-way is left exactly as it is: this repairs the
|
|
357
|
+
* one shape it can prove, and guesses at nothing.
|
|
358
|
+
*/
|
|
359
|
+
export function repairSharedMeta(doc: Y.Doc, origin?: unknown): boolean {
|
|
360
|
+
const t = doc.getText(Y_SYNC_TYPES.meta);
|
|
361
|
+
const raw = t.toString();
|
|
362
|
+
const single = normalizeSharedMeta(raw);
|
|
363
|
+
if (single === null || single === raw) return false;
|
|
364
|
+
doc.transact(() => {
|
|
365
|
+
t.delete(0, t.length);
|
|
366
|
+
t.insert(0, single);
|
|
367
|
+
}, origin);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
|
|
294
371
|
/**
|
|
295
372
|
* Merge the synced shared-meta into a local `.meta.json` string, PRESERVING the
|
|
296
373
|
* local per-user/security keys (META_LOCAL_KEYS). Shared keys become exactly
|
|
@@ -457,6 +534,81 @@ export function canvasPathFromDoc(doc: Y.Doc): string | null {
|
|
|
457
534
|
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
458
535
|
}
|
|
459
536
|
|
|
537
|
+
/* ------------------------------------------------------------- retirement */
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Mark this document RETIRED BY A MOVE: its canvas now lives at `toRel`, in a
|
|
541
|
+
* DIFFERENT document (the slug is derived from the path, so a moved canvas is
|
|
542
|
+
* a new document by construction — nothing can rename a Hocuspocus doc).
|
|
543
|
+
*
|
|
544
|
+
* Before this stamp existed, the pre-move document simply lived on: the hub
|
|
545
|
+
* kept materialising it at the old path, every peer's cold start saw "doc has
|
|
546
|
+
* a body, disk has no file" and resurrected it, and a moved canvas came back
|
|
547
|
+
* as a duplicate on every machine that ever synced it. Observed live: moving
|
|
548
|
+
* `shoj` into a folder on the desktop left BOTH `ui/dbucket/shoj.tsx` and a
|
|
549
|
+
* re-materialised `ui/shoj.tsx` on both machines, plus two documents on the
|
|
550
|
+
* hub.
|
|
551
|
+
*
|
|
552
|
+
* The stamp is a STATEMENT, deliberately not an empty body: emptiness is
|
|
553
|
+
* ambiguous (a crash mid-write, an unseeded doc), and DDR-223 spent a whole
|
|
554
|
+
* arc making emptiness weak. `movedTo` is unambiguous, carries WHERE the
|
|
555
|
+
* content went, and — because the content provably lives at the new path in
|
|
556
|
+
* the new document — quarantining the old file against it is safe in a way
|
|
557
|
+
* that acting on a bare deletion never is (that one stays Increment 6).
|
|
558
|
+
*
|
|
559
|
+
* Consumers:
|
|
560
|
+
* - every materialise path treats a retired doc as WRITE-INERT — it never
|
|
561
|
+
* lands another byte on disk and never accepts another local edit;
|
|
562
|
+
* - the sync runtime, on SEEING the stamp arrive, releases the canvas and
|
|
563
|
+
* quarantines the stale local copy into `_trash/` (recoverable, DDR-102's
|
|
564
|
+
* spine) — except on the machine performing the move, which renames the
|
|
565
|
+
* file itself;
|
|
566
|
+
* - the hub's workspace agent quarantines the checkout copy and commits the
|
|
567
|
+
* deletion, so the cloud tree stops listing the ghost.
|
|
568
|
+
*/
|
|
569
|
+
export function stampMovedTo(doc: Y.Doc, toRel: string, origin?: unknown): boolean {
|
|
570
|
+
const next = String(toRel ?? '').replace(/\\/g, '/');
|
|
571
|
+
if (!next) return false;
|
|
572
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
573
|
+
if (map.get('movedTo') === next) return false;
|
|
574
|
+
doc.transact(() => {
|
|
575
|
+
map.set('movedTo', next);
|
|
576
|
+
map.set('movedAt', Date.now());
|
|
577
|
+
map.set('movedBy', peerLabel());
|
|
578
|
+
}, origin);
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Un-retire a document that says it moved to where it already is.
|
|
584
|
+
*
|
|
585
|
+
* A move renames the canvas's artifacts onto the new slug, and the retirement
|
|
586
|
+
* stamp lives INSIDE the document — so any copy of the old document that reaches
|
|
587
|
+
* the new slug arrives pre-stamped "I have moved away", and every peer that
|
|
588
|
+
* opens it releases the canvas instead of syncing it. Clearing the stamp is the
|
|
589
|
+
* correction, and because it is a doc edit it travels to every peer that already
|
|
590
|
+
* believed the lie. The caller decides the "already is" part; this only writes.
|
|
591
|
+
*/
|
|
592
|
+
export function clearMovedTo(doc: Y.Doc, origin?: unknown): boolean {
|
|
593
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
594
|
+
if (map.get('movedTo') === undefined) return false;
|
|
595
|
+
doc.transact(() => {
|
|
596
|
+
map.delete('movedTo');
|
|
597
|
+
map.delete('movedAt');
|
|
598
|
+
map.delete('movedBy');
|
|
599
|
+
}, origin);
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Where a retired document says its canvas went, or null for a live one.
|
|
604
|
+
* UNTRUSTED — a consumer that turns this into a path must validate it the
|
|
605
|
+
* same way it validates `syncMeta.path`. Most consumers only need the
|
|
606
|
+
* null/non-null fact. */
|
|
607
|
+
export function movedToFromDoc(doc: Y.Doc): string | null {
|
|
608
|
+
const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('movedTo');
|
|
609
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
610
|
+
}
|
|
611
|
+
|
|
460
612
|
/* ---------------------------------------------------------------- css */
|
|
461
613
|
|
|
462
614
|
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|