@1agh/maude 0.60.6 → 1.0.2
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 +190 -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 -6
- 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 +919 -214
- 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 +90 -16
- 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 +99 -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
|
@@ -383,5 +383,10 @@ function subPathExternals(_pkg: RuntimePackage): string[] {
|
|
|
383
383
|
* own cold-start is already the longest tail.
|
|
384
384
|
*/
|
|
385
385
|
export async function prewarmRuntimeBundles(): Promise<void> {
|
|
386
|
-
|
|
386
|
+
// Arrow, not a bare reference: `.map` passes (value, index, array), so
|
|
387
|
+
// `.map(getRuntimeBundle)` handed the ARRAY INDEX to the function's optional
|
|
388
|
+
// `opts` parameter — every pre-warm after the first ran with a nonsense
|
|
389
|
+
// options object. Silent, because a pre-warm that misbehaves just means the
|
|
390
|
+
// real build happens on first GET.
|
|
391
|
+
await Promise.all(RUNTIME_PACKAGES.map((pkg) => getRuntimeBundle(pkg)));
|
|
387
392
|
}
|
package/apps/studio/server.ts
CHANGED
|
@@ -34,6 +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 { sharedDocEnabled } from './sync/cell-pairing.ts';
|
|
37
38
|
import { createSyncSupervisor } from './sync/supervisor.ts';
|
|
38
39
|
import {
|
|
39
40
|
assertContainment,
|
|
@@ -56,13 +57,15 @@ await bootSelfHeal();
|
|
|
56
57
|
|
|
57
58
|
const ctx = createContext();
|
|
58
59
|
|
|
59
|
-
//
|
|
60
|
-
// the
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
|
|
60
|
+
// DDR-064 cutover (Sync v2 Increment 7) — `MAUDE_SHARED_DOC` now defaults ON:
|
|
61
|
+
// the collab room's Y.Doc is THE doc per canvas, everywhere. The two-doc +
|
|
62
|
+
// disk-reconcile path still exists in full and `MAUDE_SHARED_DOC=0` flips a
|
|
63
|
+
// machine back onto it — that config flip is this release's rollback, which is
|
|
64
|
+
// why NOTHING is deleted here (the relay's deletion is the next increment,
|
|
65
|
+
// one soak later). The flag is threaded onto ctx here (before createCollab /
|
|
66
|
+
// createSyncRuntime read it) so every downstream consumer sees one source of
|
|
67
|
+
// truth, parsed by the same function the cell-pairing interlock uses.
|
|
68
|
+
ctx.sharedDoc = sharedDocEnabled(process.env);
|
|
66
69
|
|
|
67
70
|
// Forward-declared so the api.commentsAdd/patch/delete/addReply callback can
|
|
68
71
|
// reach into the collab registry (Phase 8 Task 3 bridge). collab is initialized
|
|
@@ -97,6 +100,19 @@ const api = createApi(ctx, {
|
|
|
97
100
|
// + `_active.json` retarget, bridged the same forward-declared way as the
|
|
98
101
|
// comments/annotations hooks above.
|
|
99
102
|
isRoomPinned: (slug) => collab?.registry.isPinned(slug) ?? false,
|
|
103
|
+
// The move protocol (codec stampMovedTo): the sync runtime stamps the old
|
|
104
|
+
// document retired + detaches, which unpins the room — after which the
|
|
105
|
+
// forceDrop below actually drops it and the rename is safe. Forward-declared
|
|
106
|
+
// like the other hooks; `ctx.syncControl` is set once the supervisor exists.
|
|
107
|
+
retireCanvasForMove: async (fromSlug, toRel) => {
|
|
108
|
+
try {
|
|
109
|
+
const runtime = ctx.syncControl?.current?.();
|
|
110
|
+
return (await runtime?.retireForMove?.(fromSlug, toRel)) ?? false;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
console.warn(`[move] retire failed for ${fromSlug}:`, err);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
100
116
|
flushAndDropRoom: async (slug) => {
|
|
101
117
|
if (collab) await collab.registry.forceDrop(slug);
|
|
102
118
|
},
|
|
@@ -580,7 +596,26 @@ const publicShellOrigin = (() => {
|
|
|
580
596
|
return '';
|
|
581
597
|
}
|
|
582
598
|
})();
|
|
583
|
-
|
|
599
|
+
// Additional legit embedders, space-separated (validated per-origin). A cell
|
|
600
|
+
// can genuinely have MORE than one shell name — the local rig serves its shell
|
|
601
|
+
// on `http://studio.cell.localhost:<port>` (same-site with the canvas origin,
|
|
602
|
+
// so the SameSite=Strict capability cookie flows exactly as it does in
|
|
603
|
+
// production) while `127.0.0.1:<port>` stays alive for tooling — and listing
|
|
604
|
+
// only one of them turns the other into a silent sad-page iframe refusal.
|
|
605
|
+
const extraShellOrigins = (process.env.MAUDE_EXTRA_SHELL_ORIGINS ?? '')
|
|
606
|
+
.split(/[\s,]+/)
|
|
607
|
+
.filter(Boolean)
|
|
608
|
+
.map((u) => {
|
|
609
|
+
try {
|
|
610
|
+
return new URL(u).origin;
|
|
611
|
+
} catch {
|
|
612
|
+
return '';
|
|
613
|
+
}
|
|
614
|
+
})
|
|
615
|
+
.filter(Boolean)
|
|
616
|
+
.map((o) => ` ${o}`)
|
|
617
|
+
.join('');
|
|
618
|
+
ctx.mainOrigin = `http://localhost:${server.port} http://127.0.0.1:${server.port}${publicShellOrigin}${extraShellOrigins}`;
|
|
584
619
|
|
|
585
620
|
// T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
|
|
586
621
|
// phase-9.1: a second listener binds an OS-assigned free port, advertised as
|
|
@@ -55,6 +55,8 @@ import {
|
|
|
55
55
|
markSeeded,
|
|
56
56
|
mergeSharedMetaIntoLocal,
|
|
57
57
|
metaFromDoc,
|
|
58
|
+
movedToFromDoc,
|
|
59
|
+
repairSharedMeta,
|
|
58
60
|
seededByFromDoc,
|
|
59
61
|
stampAnnotationsEdit,
|
|
60
62
|
stampBodyEdit,
|
|
@@ -66,6 +68,7 @@ import {
|
|
|
66
68
|
isExactRepeat,
|
|
67
69
|
unionCommentsById,
|
|
68
70
|
} from './cold-start.ts';
|
|
71
|
+
import { applyColdStart } from './cold-start-apply.ts';
|
|
69
72
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
70
73
|
import type { SyncJournal } from './journal.ts';
|
|
71
74
|
|
|
@@ -258,6 +261,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
258
261
|
|
|
259
262
|
async function flush(): Promise<void> {
|
|
260
263
|
if (!dirty || stopped) return;
|
|
264
|
+
// A RETIRED document is write-inert. Its canvas moved to a new path in a
|
|
265
|
+
// new document; landing another byte from THIS one is how a moved canvas
|
|
266
|
+
// resurrected itself at its old path on every machine (see stampMovedTo).
|
|
267
|
+
if (movedToFromDoc(doc) !== null) {
|
|
268
|
+
dirty = false;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
261
271
|
dirty = false;
|
|
262
272
|
if (flushTimer) {
|
|
263
273
|
clearTimeout(flushTimer);
|
|
@@ -345,6 +355,9 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
345
355
|
|
|
346
356
|
function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
|
|
347
357
|
if (stopped) return false;
|
|
358
|
+
// Write-inert both ways — a local edit to a stale pre-move file must not
|
|
359
|
+
// revive the retired document either (see stampMovedTo).
|
|
360
|
+
if (movedToFromDoc(doc) !== null) return false;
|
|
348
361
|
// Echo of our own atomicWrite — drop.
|
|
349
362
|
if (echoGuard.consume(evt.path, evt.hash)) return false;
|
|
350
363
|
|
|
@@ -404,6 +417,10 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
404
417
|
|
|
405
418
|
async function reconcile(): Promise<void> {
|
|
406
419
|
if (stopped) return;
|
|
420
|
+
// A retired doc reconciles NOTHING — materialising it is the resurrection
|
|
421
|
+
// this stamp exists to end. The runtime's retirement watcher owns what
|
|
422
|
+
// happens to the stale local file (quarantine to _trash/).
|
|
423
|
+
if (movedToFromDoc(doc) !== null) return;
|
|
407
424
|
const localHtml = readLocal(paths.html);
|
|
408
425
|
const localComments = readLocal(paths.comments);
|
|
409
426
|
const localAnnotations = readLocal(paths.annotations);
|
|
@@ -472,9 +489,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
472
489
|
docBodyEditAtMs: bodyEditAtFromDoc(doc),
|
|
473
490
|
});
|
|
474
491
|
|
|
475
|
-
// Which side owns the visually-coupled lanes (annotations/css) below.
|
|
476
|
-
let bodyWinner: 'local' | 'hub' = 'hub';
|
|
477
|
-
|
|
478
492
|
const writeBodyFromDoc = (): void => {
|
|
479
493
|
const hash = hashBytes(docHtml);
|
|
480
494
|
echoGuard.record(paths.html, hash);
|
|
@@ -497,84 +511,27 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
497
511
|
opts.journal?.record(slug, { bodyHash: hashBytes(body) });
|
|
498
512
|
};
|
|
499
513
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
// F1 — booting against a hub whose body is our local body repeated (a
|
|
522
|
-
// concurrent cold-seed that already duplicated). Re-apply local so the
|
|
523
|
-
// diff deletes the extra copy/copies; the content equals local, so the
|
|
524
|
-
// visually-coupled lanes follow local too. Idempotent across peers.
|
|
525
|
-
seedBodyUp(localHtml as string);
|
|
526
|
-
bodyWinner = 'local';
|
|
527
|
-
break;
|
|
528
|
-
case 'conflict': {
|
|
529
|
-
// Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
|
|
530
|
-
// write, then apply the newest-wins winner. Even a wrong pick costs
|
|
531
|
-
// one /design:rollback (the incident's class of loss is closed).
|
|
532
|
-
const snapshots: { local?: string; hub?: string } = {};
|
|
533
|
-
let snapshotAttempted = false;
|
|
534
|
-
if (opts.snapshot) {
|
|
535
|
-
snapshotAttempted = true;
|
|
536
|
-
try {
|
|
537
|
-
const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
|
|
538
|
-
if (localTs) snapshots.local = localTs;
|
|
539
|
-
const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
|
|
540
|
-
if (hubTs) snapshots.hub = hubTs;
|
|
541
|
-
} catch {
|
|
542
|
-
/* swallowed below — the missing snapshot ref drives the fail-closed guard */
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
// DDR-102 fail-closed (security F1): the whole guarantee is "the loser is
|
|
546
|
-
// recoverable from _history". A hub-wins resolution OVERWRITES local — so
|
|
547
|
-
// if we asked for a snapshot but the local one did NOT land (full disk,
|
|
548
|
-
// read-only `_history/`, a Bun.write error), refuse the destructive
|
|
549
|
-
// overwrite. Keep local on disk and seed it UP instead, so nothing is
|
|
550
|
-
// lost on either side (local survives; the hub still gets our content).
|
|
551
|
-
// `snapshotAttempted` gates this to production wiring — a test/standalone
|
|
552
|
-
// agent with no snapshot fn keeps the plain newest-wins behavior.
|
|
553
|
-
const localSnapshotMissing = snapshotAttempted && !snapshots.local;
|
|
554
|
-
let winner = decision.winner;
|
|
555
|
-
if (winner === 'hub' && localSnapshotMissing) {
|
|
556
|
-
winner = 'local';
|
|
557
|
-
console.error(
|
|
558
|
-
`[sync/${slug}] cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Keeping local + pushing it up; resolve the _history/ write failure (disk full / read-only?) to restore newest-wins.`
|
|
559
|
-
);
|
|
560
|
-
}
|
|
561
|
-
if (winner === 'local') {
|
|
562
|
-
seedBodyUp(localHtml as string);
|
|
563
|
-
bodyWinner = 'local';
|
|
564
|
-
} else {
|
|
565
|
-
writeBodyFromDoc();
|
|
566
|
-
}
|
|
567
|
-
console.warn(`[sync/${slug}] cold-start divergence — ${decision.reason}`);
|
|
568
|
-
opts.onConflict?.({
|
|
569
|
-
slug,
|
|
570
|
-
kind: 'cold-start-diverged',
|
|
571
|
-
winner,
|
|
572
|
-
...(snapshots.local || snapshots.hub ? { snapshots } : {}),
|
|
573
|
-
...(localSnapshotMissing ? { snapshotFailed: true } : {}),
|
|
574
|
-
});
|
|
575
|
-
break;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
514
|
+
// ONE application body, shared with migrate-seed.ts (DDR-226 Increment 0):
|
|
515
|
+
// exhaustive over every action with a compile-time `never` default, so the
|
|
516
|
+
// fail-closed snapshot guard, the conflict report and the row set can never
|
|
517
|
+
// drift between the two architectures again. Only the three EFFECTS differ
|
|
518
|
+
// and they are injected here.
|
|
519
|
+
const applied = await applyColdStart({
|
|
520
|
+
slug,
|
|
521
|
+
decision,
|
|
522
|
+
localBody: localHtml,
|
|
523
|
+
docBody: docHtml,
|
|
524
|
+
takeHub: writeBodyFromDoc,
|
|
525
|
+
takeLocal: seedBodyUp,
|
|
526
|
+
checkpointIdentity: (body) => opts.journal?.record(slug, { bodyHash: hashBytes(body) }),
|
|
527
|
+
...(opts.snapshot ? { snapshot: opts.snapshot } : {}),
|
|
528
|
+
...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
|
|
529
|
+
});
|
|
530
|
+
// `noop` leaves disk and doc alone; the local mirror still tracks the doc.
|
|
531
|
+
if (applied.action === 'noop') lastHtml = docHtml;
|
|
532
|
+
|
|
533
|
+
// Which side owns the visually-coupled lanes (annotations fallback / css).
|
|
534
|
+
const bodyWinner = applied.bodyWinner;
|
|
578
535
|
|
|
579
536
|
// ---- comments: id-union merge (DDR-102 — union loses nothing) ----------
|
|
580
537
|
const localParsedComments = localComments !== null ? tryParseJsonArray(localComments) : null;
|
|
@@ -656,7 +613,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
656
613
|
}
|
|
657
614
|
}
|
|
658
615
|
|
|
659
|
-
// ---- meta:
|
|
616
|
+
// ---- meta: hub wins where it has an opinion, local seeds where it does not
|
|
617
|
+
//
|
|
618
|
+
// Repair first. Two peers publishing the same meta into an empty lane leave
|
|
619
|
+
// two identical copies in the Y.Text, which every consumer's `JSON.parse`
|
|
620
|
+
// rejects — so the canvas reads as having no meta at all on any machine
|
|
621
|
+
// that syncs it afterwards. Collapsing it here, on a doc that has synced,
|
|
622
|
+
// is the only place the duplication is provable rather than suspected.
|
|
623
|
+
repairSharedMeta(doc, origin);
|
|
660
624
|
lastMeta = docMeta;
|
|
661
625
|
if (paths.meta && docMeta !== null) {
|
|
662
626
|
const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
|
|
@@ -665,6 +629,30 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
665
629
|
echoGuard.record(paths.meta, hash);
|
|
666
630
|
writer(paths.meta, merged);
|
|
667
631
|
}
|
|
632
|
+
} else if (paths.meta && localMeta !== null) {
|
|
633
|
+
// AN EMPTY DOC IS NOT A CANVAS WITH NO TITLE.
|
|
634
|
+
//
|
|
635
|
+
// This branch used to not exist, and the comment above it said meta was
|
|
636
|
+
// "unchanged in all cases". The consequence: a canvas created on a peer
|
|
637
|
+
// reached the hub as a body with NO meta — no title, no kind, no
|
|
638
|
+
// design-system binding — and stayed that way until somebody happened to
|
|
639
|
+
// move an artboard, because a meta EDIT was the only thing that ever
|
|
640
|
+
// pushed meta up.
|
|
641
|
+
//
|
|
642
|
+
// It looked fine from the cloud, which is what kept it hidden: a cell's
|
|
643
|
+
// studio child arms `activity:suppress` on create, the container write
|
|
644
|
+
// bridge turns that into an `fs:any` a quarter-second later, and by then
|
|
645
|
+
// the new canvas has an agent to receive it. A desktop has no bridge — its
|
|
646
|
+
// real `fs.watch` fires immediately, before the agent for a
|
|
647
|
+
// just-created canvas exists, and the event lands nowhere. So the race
|
|
648
|
+
// was won on one side and lost on the other, and the underlying gap
|
|
649
|
+
// (cold-start meta was doc→file only) was invisible from the winning end.
|
|
650
|
+
//
|
|
651
|
+
// Seeding here is safe by construction: it runs ONLY when the doc carries
|
|
652
|
+
// no shared meta at all, so it cannot overwrite another peer's opinion —
|
|
653
|
+
// the same "absence is never authority" rule the rest of the sync applies
|
|
654
|
+
// to files, applied to the one sidecar that was exempt from it.
|
|
655
|
+
if (applyMetaToDoc(doc, localMeta, origin)) lastMeta = metaFromDoc(doc);
|
|
668
656
|
}
|
|
669
657
|
}
|
|
670
658
|
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// THE LEGACY PUSH CLIENT — journal-less hubs only (Sync v2 Increment 5).
|
|
2
|
+
//
|
|
3
|
+
// On any hub that carries a journal, the file plane (`file-plane.ts`) is the
|
|
4
|
+
// one lane for every project file, both directions, and this module is never
|
|
5
|
+
// invoked (the capability probe in `sync/index.ts` decides the lane once per
|
|
6
|
+
// boot). What remains here is the compat client for self-hosted hubs that
|
|
7
|
+
// have not upgraded: the bounded, sequential, in-process push pass the pre-v2
|
|
8
|
+
// desktop ran. Retained AT LEAST TWO RELEASES after the burn-down (Open
|
|
9
|
+
// decision 4 of the journal arc) — delete it only against a compat matrix
|
|
10
|
+
// that says the window has closed.
|
|
11
|
+
//
|
|
12
|
+
// Original charter (still accurate for the hubs this serves) — DDR-217, the
|
|
13
|
+
// 2026-08-11 addendum, and the feature-sync-file-plane widening (binding
|
|
14
|
+
// decision maude/sync-two-plane-manifest-architecture):
|
|
4
15
|
//
|
|
5
16
|
// The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
|
|
6
17
|
// desktop-linked project's other files never reached the cell — first seen as
|
|
@@ -124,8 +135,9 @@ const UPLOAD_CONNECTION_HEADERS = { connection: 'close' } as const;
|
|
|
124
135
|
* over a 182-file sweep is one per request, not a leak. Second, the actual
|
|
125
136
|
* fault was isolated elsewhere and fixed: an HTTP/1.1 keep-alive desync after a
|
|
126
137
|
* peer refused a PUT before draining its body (see UPLOAD_CONNECTION_HEADERS).
|
|
127
|
-
*
|
|
128
|
-
*
|
|
138
|
+
* That fix is also why this pass runs in-process again — the out-of-process
|
|
139
|
+
* boundary (deleted in Increment 5) was protecting the editor from a transport
|
|
140
|
+
* fault that no longer exists.
|
|
129
141
|
*
|
|
130
142
|
* Removing them would trade a suspicion for a certainty: a request with no
|
|
131
143
|
* budget is how a sweep hangs forever with nothing to report — the exact
|
|
@@ -176,7 +188,12 @@ function readProjectConfig(designRoot: string): {
|
|
|
176
188
|
canvasGroups: Array.isArray(parsed?.canvasGroups)
|
|
177
189
|
? (parsed.canvasGroups as CanvasGroupLike[])
|
|
178
190
|
: undefined,
|
|
179
|
-
|
|
191
|
+
// Default ON — mirrors `sync/index.ts`. Both read the same key and a drift
|
|
192
|
+
// between them would mean the sweep and the plane disagree about which
|
|
193
|
+
// files exist, which is the jurisdiction overlap Sync v2 exists to end.
|
|
194
|
+
syncFiles:
|
|
195
|
+
parsed?.linkedHub?.syncFiles !== false &&
|
|
196
|
+
(process.env.MAUDE_SYNC_FILES !== '0' || parsed?.linkedHub?.syncFiles === true),
|
|
180
197
|
};
|
|
181
198
|
}
|
|
182
199
|
|
|
@@ -425,6 +442,10 @@ export async function pushAssets(opts: {
|
|
|
425
442
|
onProgress?: (progress: AssetPushProgress) => void;
|
|
426
443
|
/** Injectable clock for the throttle (tests). */
|
|
427
444
|
now?: () => number;
|
|
445
|
+
/** Asked before every upload — true abandons the rest of the pass (the Sync
|
|
446
|
+
* panel's cancel; a multi-hundred-MB upload must be killable). The final
|
|
447
|
+
* progress emit still fires, so the panel never freezes mid-count. */
|
|
448
|
+
cancelled?: () => boolean;
|
|
428
449
|
/** Injectable pause for the 429 backoff (tests — a fake clock, not a wait). */
|
|
429
450
|
sleep?: (ms: number) => Promise<void>;
|
|
430
451
|
/** Injectable per-request time budget (tests — seconds, not minutes). */
|
|
@@ -486,6 +507,7 @@ export async function pushAssets(opts: {
|
|
|
486
507
|
: null;
|
|
487
508
|
|
|
488
509
|
for (const rel of assets) {
|
|
510
|
+
if (opts.cancelled?.()) break;
|
|
489
511
|
emitProgress(rel, false);
|
|
490
512
|
const url = `${base}${routeFor(rel).url}`;
|
|
491
513
|
const headers = { authorization: `Bearer ${opts.token()}` };
|
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
// commit a precondition of the save would turn a transient git error into
|
|
26
26
|
// data loss, which is precisely backwards.
|
|
27
27
|
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
28
29
|
import path from 'node:path';
|
|
29
30
|
|
|
30
31
|
import { withRepoLock } from '../git/repo-lock.ts';
|
|
32
|
+
import { partitionSafeGitRels } from '../git/safe-rel.ts';
|
|
31
33
|
|
|
32
34
|
/** How long the tree must be quiet before a commit fires. */
|
|
33
35
|
const DEFAULT_DEBOUNCE_MS = 3000;
|
|
@@ -72,6 +74,23 @@ export interface AutoCommitOptions {
|
|
|
72
74
|
/** Committer identity — the machine, never the human. */
|
|
73
75
|
bot?: EditAttribution;
|
|
74
76
|
log?: Pick<Console, 'warn' | 'error' | 'log'>;
|
|
77
|
+
/**
|
|
78
|
+
* Stage paths git is ignoring (`git add -f`). **Hub only.**
|
|
79
|
+
*
|
|
80
|
+
* On a desktop this must stay off: the ignore file is the user's, and
|
|
81
|
+
* force-adding would commit what they told git to skip. On the HUB it is the
|
|
82
|
+
* opposite — the design root IS the product there, not a mirror of it.
|
|
83
|
+
*
|
|
84
|
+
* This exists because of a real hole. Once a project goes hub-owned
|
|
85
|
+
* (DDR-228) its `.gitignore` carries `/.design/`, the hub's checkout is
|
|
86
|
+
* seeded from that repo and inherits it, and the hub quietly stops
|
|
87
|
+
* committing the design root. Generation backups are `git bundle --all`, so
|
|
88
|
+
* they carry committed objects only — meaning the design system had no copy
|
|
89
|
+
* in any backup, on top of having none in object storage (only `assets/` is
|
|
90
|
+
* mirrored). A deletion would then have been unrecoverable everywhere except
|
|
91
|
+
* a per-machine `_trash/` nothing prunes or indexes.
|
|
92
|
+
*/
|
|
93
|
+
stageIgnored?: boolean;
|
|
75
94
|
}
|
|
76
95
|
|
|
77
96
|
export interface AutoCommit {
|
|
@@ -173,6 +192,7 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
173
192
|
maxDebounceMs = DEFAULT_MAX_DEBOUNCE_MS,
|
|
174
193
|
bot = DEFAULT_BOT,
|
|
175
194
|
log = console,
|
|
195
|
+
stageIgnored = false,
|
|
176
196
|
} = opts;
|
|
177
197
|
|
|
178
198
|
const touched = new Set<string>();
|
|
@@ -272,22 +292,103 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
272
292
|
return inFlight;
|
|
273
293
|
}
|
|
274
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Split a batch into what git can stage and what it cannot.
|
|
297
|
+
*
|
|
298
|
+
* `stage` — on disk, or gone but TRACKED (a real deletion to record).
|
|
299
|
+
* `drop` — gone and never tracked: git has nothing to say about it, and
|
|
300
|
+
* retrying it forever is what wedged the whole agent.
|
|
301
|
+
*
|
|
302
|
+
* One `ls-files` call for the whole batch, not one per path: this runs inside
|
|
303
|
+
* the repo lock and a per-file probe would put a fork on the critical section
|
|
304
|
+
* for every canvas in a busy window.
|
|
305
|
+
*/
|
|
306
|
+
async function partitionForStaging(
|
|
307
|
+
input: string[]
|
|
308
|
+
): Promise<{ stage: string[]; drop: string[] }> {
|
|
309
|
+
// SHAPE GATE FIRST (F-13/B12). These rels are built from `designRel` plus a
|
|
310
|
+
// path the file plane delivered, and they end up in `git add -- <paths>`.
|
|
311
|
+
// The `--` defuses a leading `-`, but nothing defuses `..`: a rel escaping
|
|
312
|
+
// the design root is a well-formed pathspec that stages a file the sync
|
|
313
|
+
// lane has no business touching — `../../.github/workflows/*` reached from
|
|
314
|
+
// a lane scoped to `.design/`. Refused paths are DROPPED, not thrown on:
|
|
315
|
+
// one poisoned rel must not wedge the queue for everybody else's work
|
|
316
|
+
// (the failure mode this whole function was written to avoid).
|
|
317
|
+
const { safe: files, refused } = partitionSafeGitRels(input);
|
|
318
|
+
if (refused.length > 0) {
|
|
319
|
+
console.warn(
|
|
320
|
+
`[autocommit] refusing ${refused.length} path(s) that are not safe repo-relative: ${refused
|
|
321
|
+
.slice(0, 5)
|
|
322
|
+
.map((r) => JSON.stringify(r))
|
|
323
|
+
.join(', ')}`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const missing = files.filter((f) => !existsSync(path.join(repoRoot, f)));
|
|
327
|
+
if (missing.length === 0) return { stage: files, drop: refused };
|
|
328
|
+
|
|
329
|
+
const known = await run(['ls-files', '--', ...missing], { cwd: repoRoot });
|
|
330
|
+
// If the probe itself fails, keep every path: guessing "untracked" here
|
|
331
|
+
// would DROP a real deletion, and a missed deletion is worse than a retry.
|
|
332
|
+
const tracked =
|
|
333
|
+
known.code === 0
|
|
334
|
+
? new Set(
|
|
335
|
+
known.stdout
|
|
336
|
+
.split('\n')
|
|
337
|
+
.map((l) => l.trim())
|
|
338
|
+
.filter(Boolean)
|
|
339
|
+
)
|
|
340
|
+
: new Set(missing);
|
|
341
|
+
const drop = [...refused, ...missing.filter((f) => !tracked.has(f))];
|
|
342
|
+
if (drop.length === 0) return { stage: files, drop: [] };
|
|
343
|
+
const dropSet = new Set(drop);
|
|
344
|
+
return { stage: files.filter((f) => !dropSet.has(f)), drop };
|
|
345
|
+
}
|
|
346
|
+
|
|
275
347
|
/** The critical section: stage exactly these files, commit them, report. */
|
|
276
348
|
async function commitCycle(files: string[], who: EditAttribution): Promise<CommitOutcome> {
|
|
277
349
|
// Stage ONLY what changed. `git add -A` in a workspace would sweep in
|
|
278
350
|
// whatever else is in the tree — including files a future feature drops
|
|
279
351
|
// there — and the cell must never commit something it wasn't told about.
|
|
280
|
-
|
|
352
|
+
// A PATH THAT VANISHED AND WAS NEVER TRACKED IS NOT AN ERROR TO RETRY.
|
|
353
|
+
//
|
|
354
|
+
// `git add -- <path that never existed in the index and is gone from disk>`
|
|
355
|
+
// exits 128 with "did not match any files". The batch then failed, re-queued
|
|
356
|
+
// ITSELF INCLUDING THAT PATH, and failed again the same way forever — so the
|
|
357
|
+
// first canvas anybody deleted stopped the cell committing ANYTHING, for the
|
|
358
|
+
// life of the process. Observed on a local cell: five commits, then
|
|
359
|
+
// twenty-plus identical `git add failed` lines and thirty canvases sitting
|
|
360
|
+
// untracked while `/health` answered 200 throughout.
|
|
361
|
+
//
|
|
362
|
+
// Paths are noted on the strength of a read taken up to `debounceMs`
|
|
363
|
+
// earlier, for files whose lifetime this process does not own, so a path
|
|
364
|
+
// disappearing mid-window is ROUTINE. The question is which kind of gone it
|
|
365
|
+
// is, and git already knows: a path in the index must be staged as a
|
|
366
|
+
// deletion (or the checkout and its history diverge permanently); a path
|
|
367
|
+
// git never heard of has nothing to record and is simply dropped.
|
|
368
|
+
const staging = await partitionForStaging(files);
|
|
369
|
+
if (staging.drop.length > 0) {
|
|
370
|
+
log.warn?.(
|
|
371
|
+
`[autocommit] ${staging.drop.length} path(s) vanished before staging and were never tracked; nothing to record: ${staging.drop.slice(0, 3).join(', ')}${staging.drop.length > 3 ? '…' : ''}`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
if (staging.stage.length === 0) return { ok: false, reason: 'nothing-to-commit', files };
|
|
375
|
+
files = staging.stage;
|
|
376
|
+
|
|
377
|
+
// `-A` so a tracked path that is gone stages as a DELETION rather than
|
|
378
|
+
// erroring. It does not widen the scope: the pathspec is still this exact
|
|
379
|
+
// file list, so the "never commit something it wasn't told about" rule the
|
|
380
|
+
// comment above states is intact.
|
|
381
|
+
// `-A` so a tracked path that is gone stages as a DELETION rather than
|
|
382
|
+
// erroring; `-f` only where the caller asked for it — see `stageIgnored`.
|
|
383
|
+
const add = await run(['add', '-A', ...(stageIgnored ? ['-f'] : []), '--', ...files], {
|
|
384
|
+
cwd: repoRoot,
|
|
385
|
+
});
|
|
281
386
|
if (add.code !== 0) {
|
|
282
387
|
log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
|
|
283
388
|
// RE-QUEUE, exactly as the `commit` branch below does. `touched` was
|
|
284
389
|
// cleared by `flush()` before we got here, so returning without this
|
|
285
390
|
// drops the whole coalesced batch — including other people's edits in
|
|
286
391
|
// 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
392
|
for (const f of files) touched.add(f);
|
|
292
393
|
return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
|
|
293
394
|
}
|
|
@@ -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
|
+
}
|