feat: ysync 0008 Stage D — wire the v2 items collab into the app + verify
- WasmTool: hard cutover to startKicadCollab (gates on kicadCollabSnapshotItems; scalar reconciler no longer runs in the app) - file-seed (ysync 0005 tie-in): empty rooms are seeded from the opened file via fileToDoc/docToY (meta+layout+items — file recoverable from the Y.Doc alone); populated rooms still adopt (seed-once unchanged) - binding: gate UP applies until seed() — the provider's initial state sync otherwise streams the full doc into an editor that already holds the file (trapped eeschema's paste path in the real app) - pcbnew blobForItem: footprints Format a uuid-corrected copy directly — SaveSelection's copy regenerated mandatory-field uuids (FOOTPRINT copy ctor assigns into fresh fields), breaking wire identity (rebuild) - roundtrip.spec on the v2 items wire; new passing pcbnew footprint-containment round trip (the 0004 containment win); full fixture stays fixme on the tracked envelope-parse limit - real-app two-tab emit verified for eeschema + pcbnew (see 0008 doc) Full kicad suite: 45 passed, 3 skipped (known), 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a06a267b0
commit
07863da416
7 changed files with 273 additions and 46 deletions
|
|
@ -3,20 +3,22 @@ import {
|
|||
collabRoomId,
|
||||
EXTENSION_TOOL,
|
||||
FILELESS_TOOLS,
|
||||
fileToDoc,
|
||||
toolSchema,
|
||||
type KicadDoc,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import { memfsProjectDir } from "@/wasm/constants";
|
||||
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||
import type { CollabWindow } from "@/wasm/collab";
|
||||
import type { KicadItemsWindow } from "@/wasm/collab";
|
||||
import { clog, cwarn } from "@/wasm/collab/debug";
|
||||
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
||||
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
||||
|
||||
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
|
||||
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
|
||||
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
|
||||
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
|
||||
".sch": "eeschema",
|
||||
|
|
@ -170,10 +172,35 @@ function installToolNavigationHook(
|
|||
}
|
||||
|
||||
/**
|
||||
* Collaborative editing (features/yjs-bridge), ON BY DEFAULT for any tool that has the
|
||||
* collab bridge. Open the same project URL in two tabs to edit together: the channel is
|
||||
* keyed to project+file, so both tabs share one Y.Doc over BroadcastChannel. Editor edits
|
||||
* (add/move items) fire the tool's change hook → the bridge → the peer tab.
|
||||
* Read the opened file back from MEMFS (what the editor actually loaded) and
|
||||
* parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the
|
||||
* Y.Doc LOSSLESSLY when this client opens an empty room (ysync 0005): the doc
|
||||
* then carries meta + layout + items, so the file is recoverable from the Y.Doc
|
||||
* alone. Falls back to undefined (→ editor-snapshot seed, items only) when the
|
||||
* file is absent or doesn't parse as a KiCad s-expr document.
|
||||
*/
|
||||
function seedDocFromMemfs(
|
||||
win: ToolWindow,
|
||||
slug: string,
|
||||
targetPath?: string,
|
||||
): KicadDoc | undefined {
|
||||
if (!targetPath) return undefined;
|
||||
try {
|
||||
const text = win.FS?.readFile(memfsFilePath(slug, targetPath), { encoding: "utf8" });
|
||||
if (typeof text !== "string") return undefined;
|
||||
return fileToDoc(text);
|
||||
} catch (err) {
|
||||
cwarn("seed: fileToDoc failed — falling back to editor-snapshot seed", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaborative editing (ysync 0008, Slot-model items wire), ON BY DEFAULT for any
|
||||
* tool that has the collab bridge. Open the same project URL in two tabs to edit
|
||||
* together: the channel is keyed to project+file, so both tabs share one Y.Doc over
|
||||
* BroadcastChannel. Editor edits (add/move items) fire the tool's change hook → the
|
||||
* bridge → the peer tab.
|
||||
*
|
||||
* Opt OUT with `?collab=0` (or `collab=false`). Tools without a bridge are skipped anyway.
|
||||
*/
|
||||
|
|
@ -181,6 +208,7 @@ async function maybeStartCollab(
|
|||
win: ToolWindow,
|
||||
opts: {
|
||||
tool: Tool;
|
||||
slug: string;
|
||||
projectId: string;
|
||||
targetPath?: string;
|
||||
log: (m: string) => void;
|
||||
|
|
@ -193,8 +221,8 @@ async function maybeStartCollab(
|
|||
collabParam,
|
||||
tool: opts.tool,
|
||||
hasModule: !!mod,
|
||||
hasSnapshot: typeof mod?.kicadCollabSnapshot,
|
||||
hasApply: typeof mod?.kicadCollabApply,
|
||||
hasSnapshotItems: typeof mod?.kicadCollabSnapshotItems,
|
||||
hasApplyItems: typeof mod?.kicadCollabApplyItems,
|
||||
url: win.location.href,
|
||||
});
|
||||
|
||||
|
|
@ -207,23 +235,28 @@ async function maybeStartCollab(
|
|||
clog(`tool ${opts.tool} has no collab bridge — skipping`);
|
||||
return;
|
||||
}
|
||||
if (typeof mod?.kicadCollabSnapshot !== "function") {
|
||||
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
|
||||
cwarn(
|
||||
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshot is",
|
||||
typeof mod?.kicadCollabSnapshot,
|
||||
`— the loaded ${opts.tool}.wasm predates the collab bridge. Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
|
||||
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshotItems is",
|
||||
typeof mod?.kicadCollabSnapshotItems,
|
||||
`— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startCollab } = await import("@/wasm/collab");
|
||||
const { startKicadCollab } = await import("@/wasm/collab");
|
||||
const provider = yjsProviderConfig();
|
||||
// One room per (project, document). Two tabs of the same build compute the
|
||||
// same id, so cross-tab BroadcastChannel still works; network providers use it
|
||||
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
|
||||
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
|
||||
clog("starting collab", provider.kind, "room", room);
|
||||
await startCollab(mod, win as unknown as CollabWindow, { provider, room });
|
||||
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
|
||||
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
|
||||
await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
|
||||
provider,
|
||||
room,
|
||||
seedDoc,
|
||||
});
|
||||
opts.log(`[collab] ${provider.kind} connected on ${room}`);
|
||||
opts.onStatus("Collab: connected");
|
||||
clog("connected ✓");
|
||||
|
|
@ -324,7 +357,7 @@ export function WasmTool({
|
|||
log: append,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
await maybeStartCollab(win, { tool, projectId, targetPath, log: append, onStatus: setStatus });
|
||||
await maybeStartCollab(win, { tool, slug, projectId, targetPath, log: append, onStatus: setStatus });
|
||||
} catch (err) {
|
||||
append(`[fatal] ${String(err)}`);
|
||||
setStatus(`Error: ${String(err)}`);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
|||
* Which Yjs collab provider this deployment uses (one active per env), and its
|
||||
* endpoint/token. Defaults to `broadcastchannel` so a vanilla checkout keeps
|
||||
* the cross-tab-only behavior with no backend. Built here at the composition
|
||||
* root and passed into `startCollab`, so `wasm/collab` stays env-agnostic.
|
||||
* root and passed into `startKicadCollab`, so `wasm/collab` stays env-agnostic.
|
||||
*/
|
||||
export function yjsProviderConfig(): ProviderConfig {
|
||||
const kind = (import.meta.env.VITE_YJS_PROVIDER ?? "broadcastchannel") as ProviderKind;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as Y from "yjs";
|
||||
import type { KicadDoc } from "@pcbjam/shared";
|
||||
import { clog } from "./debug";
|
||||
import {
|
||||
connectProvider,
|
||||
|
|
@ -55,6 +56,13 @@ export interface StartCollabOptions {
|
|||
provider: ProviderConfig;
|
||||
/** Room id — see @pcbjam/shared `collabRoomId`. Identifies one shared doc. */
|
||||
room: string;
|
||||
/**
|
||||
* The full `KicadDoc` parsed from the opened file (`fileToDoc`) — when the
|
||||
* room is empty, the Y.Doc is seeded from THIS (meta + layout + items, so the
|
||||
* file is recoverable from the doc alone — ysync 0005) instead of the editor
|
||||
* snapshot. Ignored by the legacy scalar `startCollab`.
|
||||
*/
|
||||
seedDoc?: KicadDoc;
|
||||
}
|
||||
|
||||
export interface CollabHandle {
|
||||
|
|
@ -127,7 +135,7 @@ export async function startKicadCollab(
|
|||
const provider = await connectProvider(doc, opts.provider, { room: opts.room });
|
||||
|
||||
await provider.whenSynced();
|
||||
binding.seed();
|
||||
binding.seed(opts.seedDoc);
|
||||
clog("startKicadCollab: ready; doc items =", binding.items.size);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
docToFile,
|
||||
fileToDoc,
|
||||
itemsWireToDelta,
|
||||
parseItemsWireDelta,
|
||||
renderItem,
|
||||
sexprToItems,
|
||||
yToDoc,
|
||||
type KicadItem,
|
||||
} from "@pcbjam/shared";
|
||||
import { bindKicadCollab, type KicadItemsBridge } from "./kicad-binding";
|
||||
|
|
@ -150,6 +153,61 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
|
|||
expect(edB.store["fp-1"]).toBeDefined(); // adopted
|
||||
});
|
||||
|
||||
it("seed(seedDoc) writes the FULL doc — file recoverable from the Y.Doc; peer adopts", () => {
|
||||
const { a, edA, edB, bindA, bindB } = setup();
|
||||
const file = `(kicad_wks (version 20220228) (generator "pl_editor")
|
||||
(setup (textsize 1.5 1.5) (linewidth 0.15))
|
||||
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
|
||||
)
|
||||
`;
|
||||
const seedDoc = fileToDoc(file);
|
||||
// Editor A opened the same file: its model holds the same flattened items.
|
||||
Object.assign(edA.store, seedDoc.items);
|
||||
|
||||
bindA.seed(seedDoc); // empty room → file-seeds meta + layout + items
|
||||
bindB.seed(); // joins → adopts
|
||||
|
||||
// The peer's editor received the items via adopt.
|
||||
expect(edB.store["r-1"]).toBeDefined();
|
||||
// Lossless: the file is recoverable from the Y.Doc ALONE (ysync 0005/0007),
|
||||
// which the editor-snapshot seed (items only, no layout/meta) cannot do.
|
||||
expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc));
|
||||
// The file-seed must not have echoed an apply into the seeding editor.
|
||||
expect(edA.applied.length).toBe(0);
|
||||
});
|
||||
|
||||
it("seed(seedDoc) on a populated room still ADOPTS (doc authority wins over the file)", () => {
|
||||
const { edA, edB, bindA, bindB } = setup();
|
||||
seedEditor(edA, FP);
|
||||
bindA.seed(); // A seeds the room from its editor
|
||||
|
||||
// B cold-opens a divergent copy of the file and offers it as seedDoc.
|
||||
const fileB = `(kicad_wks (version 20220228)
|
||||
(rect (uuid "fp-DIVERGENT") (name "border"))
|
||||
)
|
||||
`;
|
||||
const seedDocB = fileToDoc(fileB);
|
||||
Object.assign(edB.store, seedDocB.items);
|
||||
bindB.seed(seedDocB);
|
||||
|
||||
expect(edB.store["fp-DIVERGENT"]).toBeUndefined(); // dropped
|
||||
expect(edB.store["fp-1"]).toBeDefined(); // adopted
|
||||
});
|
||||
|
||||
it("pre-seed remote state does NOT stream into the editor (adopt covers it)", () => {
|
||||
const { edA, edB, bindA, bindB } = setup();
|
||||
seedEditor(edA, FP);
|
||||
bindA.seed(); // relays the full state into B's Y.Doc immediately
|
||||
|
||||
// B has NOT seeded yet: the initial state sync must not have been applied
|
||||
// item-by-item (in the real app that's a redundant full blob apply into an
|
||||
// editor that already opened the file).
|
||||
expect(edB.applied.length).toBe(0);
|
||||
|
||||
bindB.seed(); // the adopt delivers the same state, once
|
||||
expect(Object.keys(edB.store).sort()).toEqual(["fld-1", "fp-1", "pad-1"]);
|
||||
});
|
||||
|
||||
it("destroy() detaches the editor from further remote changes", () => {
|
||||
const { edA, edB, bindA, bindB } = setup();
|
||||
seedEditor(edA, FP);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
applyDeltaToY,
|
||||
deltaFromYEvents,
|
||||
deltaToItemsWire,
|
||||
docToY,
|
||||
isEmptyItemsWireDelta,
|
||||
isEmptyKicadDelta,
|
||||
itemsWireToDelta,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
renderItem,
|
||||
yToItem,
|
||||
type ItemsWireDelta,
|
||||
type KicadDoc,
|
||||
type KicadItem,
|
||||
type KicadYItems,
|
||||
} from "@pcbjam/shared";
|
||||
|
|
@ -46,12 +48,15 @@ export interface KicadItemsBridge {
|
|||
|
||||
export interface KicadBinding {
|
||||
/**
|
||||
* Seed-once join: if the shared doc holds no items this client seeds it from
|
||||
* the editor snapshot; otherwise the editor adopts the doc (doc authority —
|
||||
* local-only roots are removed, doc roots applied). Call once after the
|
||||
* doc/provider are connected.
|
||||
* Seed-once join: if the shared doc holds no items this client seeds it —
|
||||
* from `seedDoc` (the FULL `KicadDoc` parsed from the opened file via
|
||||
* `fileToDoc`; writes meta + layout + items so `docToFile` can regenerate the
|
||||
* file from the Y.Doc alone — ysync 0005/0007) when given, else from the
|
||||
* editor snapshot (items only). Otherwise the editor adopts the doc (doc
|
||||
* authority — local-only roots are removed, doc roots applied). Call once
|
||||
* after the doc/provider are connected.
|
||||
*/
|
||||
seed(): void;
|
||||
seed(seedDoc?: KicadDoc): void;
|
||||
destroy(): void;
|
||||
/** The underlying kdoc items map (exposed for tests/inspection). */
|
||||
readonly items: KicadYItems;
|
||||
|
|
@ -61,6 +66,12 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
const items = kicadItemsMap(doc);
|
||||
// Opaque per-instance origin tag so we can distinguish our own writes from peers'.
|
||||
const ORIGIN = { local: true };
|
||||
// Remote events arriving BEFORE seed() (e.g. the provider's initial state sync)
|
||||
// must not stream into the editor item-by-item: the editor already holds the
|
||||
// opened file, so that would be a redundant full-document blob apply (observed
|
||||
// to trap eeschema's paste path in the real app). seed()'s adopt branch covers
|
||||
// everything those early events contained.
|
||||
let seeded = false;
|
||||
|
||||
/** Plain snapshot of the Y items (the `current`/`view` the conversions need). */
|
||||
const itemsView = (): Record<string, KicadItem> => {
|
||||
|
|
@ -94,6 +105,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
// (the runtime); the event→delta computation is the shared default impl.
|
||||
const observer = (events: Y.YEvent<Y.Map<unknown>>[], txn: Y.Transaction) => {
|
||||
if (txn.origin === ORIGIN) return; // our own echo — ignore
|
||||
if (!seeded) return; // pre-seed state sync — seed()'s adopt covers it
|
||||
const delta = deltaFromYEvents(items, events);
|
||||
if (isEmptyKicadDelta(delta)) return;
|
||||
const wire = deltaToItemsWire(delta, itemsView());
|
||||
|
|
@ -107,7 +119,20 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
};
|
||||
items.observeDeep(observer);
|
||||
|
||||
function seed(): void {
|
||||
function seed(seedDoc?: KicadDoc): void {
|
||||
seeded = true; // open the UP gate; everything below runs synchronously
|
||||
if (items.size === 0 && seedDoc) {
|
||||
// First tab, file-seeded: write the FULL doc (meta + layout + items) so
|
||||
// the Y.Doc — not the editor snapshot — is the lossless source of truth
|
||||
// (the file is recoverable via docToFile). The editor already opened the
|
||||
// same file, so no applyItems is needed.
|
||||
clog(
|
||||
`seed: doc empty → SEEDING from file (${Object.keys(seedDoc.items).length} item(s), root ${seedDoc.root})`,
|
||||
);
|
||||
docToY(seedDoc, doc, ORIGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
let wire: ItemsWireDelta;
|
||||
try {
|
||||
wire = parseItemsWireDelta(bridge.snapshotItems());
|
||||
|
|
@ -123,7 +148,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
);
|
||||
|
||||
if (items.size === 0) {
|
||||
// First tab: seed the shared doc from the editor model.
|
||||
// First tab, no file source: seed the shared doc from the editor model.
|
||||
applyDeltaToY(doc, local, ORIGIN);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue