@1agh/maude 1.4.0 → 1.4.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/canvas-lib.tsx +28 -6
- package/apps/studio/client/app.jsx +17 -11
- package/apps/studio/client/command-palette-match.js +35 -0
- package/apps/studio/dist/client.bundle.js +480 -480
- package/apps/studio/http.ts +27 -8
- package/apps/studio/sync/file-plane.ts +43 -0
- package/apps/studio/sync/index.ts +135 -22
- package/apps/studio/sync/status.ts +17 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +11 -9
- package/plugins/design/templates/_shell.html +32 -1
package/apps/studio/http.ts
CHANGED
|
@@ -599,20 +599,26 @@ export function localDepsFromSource(
|
|
|
599
599
|
/** mtime signature over the .tsx + every inlined relative dep (`.css` + local
|
|
600
600
|
* modules). A missing/unreadable file contributes 0 — a delete is itself a
|
|
601
601
|
* change (and a later create too), so the signature differs. */
|
|
602
|
-
function canvasFreshnessSig(tsxAbsPath: string, deps: string[]): string {
|
|
602
|
+
function canvasFreshnessSig(tsxAbsPath: string, deps: string[], tsxMtime?: number): string {
|
|
603
603
|
const parts: string[] = [];
|
|
604
604
|
for (const p of [tsxAbsPath, ...deps]) {
|
|
605
|
-
const mt = Bun.file(p).lastModified;
|
|
605
|
+
const mt = p === tsxAbsPath && tsxMtime !== undefined ? tsxMtime : Bun.file(p).lastModified;
|
|
606
606
|
parts.push(`${p}@${Number.isFinite(mt) ? mt : 0}`);
|
|
607
607
|
}
|
|
608
608
|
return parts.join('|');
|
|
609
609
|
}
|
|
610
610
|
|
|
611
|
-
|
|
611
|
+
/** @internal Test seam — runs between reading the source and storing the build. */
|
|
612
|
+
export interface ServeCanvasTestHooks {
|
|
613
|
+
afterRead?: () => void | Promise<void>;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export async function serveCanvasTsx(
|
|
612
617
|
absPath: string,
|
|
613
618
|
req: Request,
|
|
614
619
|
ctx: Context,
|
|
615
|
-
locatorAbsPath: string
|
|
620
|
+
locatorAbsPath: string,
|
|
621
|
+
testHooks?: ServeCanvasTestHooks
|
|
616
622
|
): Promise<Response> {
|
|
617
623
|
// Phase 27 (E2) — DiffView "before" pane. `?sha=<ref>` builds the canvas from
|
|
618
624
|
// its source AT a past version (git show) instead of the working-tree file, so
|
|
@@ -634,8 +640,21 @@ async function serveCanvasTsx(
|
|
|
634
640
|
const sig = canvasFreshnessSig(absPath, cached?.deps ?? []);
|
|
635
641
|
|
|
636
642
|
if (!cached || cached.sig !== sig) {
|
|
643
|
+
// THE SIGNATURE OF WHAT WAS READ, taken before the read.
|
|
644
|
+
//
|
|
645
|
+
// It used to be recomputed after the build. A build is slow — out of
|
|
646
|
+
// process on a cell — and a canvas that changed again meanwhile stored the
|
|
647
|
+
// OLD module under the NEW file's signature, so the next request (the HMR
|
|
648
|
+
// for that very change) was a cache hit and served the previous body: a
|
|
649
|
+
// view stuck one edit behind while the file was right (surface row L23 on
|
|
650
|
+
// the hub, 2026-09-16). Stamped before reading, a change during the build
|
|
651
|
+
// leaves the stored signature older than the file, and the next request
|
|
652
|
+
// rebuilds. A dep is stamped before the build reads it, for the same reason.
|
|
653
|
+
const tsxMtime = file.lastModified;
|
|
637
654
|
const source = await file.text();
|
|
638
655
|
const deps = localDepsFromSource(source, absPath, ctx.paths.designRoot);
|
|
656
|
+
const builtSig = canvasFreshnessSig(absPath, deps, tsxMtime);
|
|
657
|
+
await testHooks?.afterRead?.();
|
|
639
658
|
let result: Awaited<ReturnType<typeof buildCanvasModule>>;
|
|
640
659
|
// DDR-209 A′2 — SAME ENGINE, DIFFERENT HOST. On a desktop the process that
|
|
641
660
|
// parses your canvas is the process you own, so an in-process build costs
|
|
@@ -662,7 +681,7 @@ async function serveCanvasTsx(
|
|
|
662
681
|
etag: built.etag,
|
|
663
682
|
} as Awaited<ReturnType<typeof buildCanvasModule>>;
|
|
664
683
|
cached = {
|
|
665
|
-
sig:
|
|
684
|
+
sig: builtSig,
|
|
666
685
|
etag: `${result.etag}-${RUNTIME_BOOT_ID}-${CHROME_EPOCH}`,
|
|
667
686
|
js: result.js,
|
|
668
687
|
deps,
|
|
@@ -694,10 +713,10 @@ async function serveCanvasTsx(
|
|
|
694
713
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
695
714
|
});
|
|
696
715
|
}
|
|
697
|
-
//
|
|
698
|
-
//
|
|
716
|
+
// The signature against the freshly-parsed deps — this very edit may have
|
|
717
|
+
// added or removed a `.css` import — stamped before the build (above).
|
|
699
718
|
cached = {
|
|
700
|
-
sig:
|
|
719
|
+
sig: builtSig,
|
|
701
720
|
// Fold in the boot id (restart) + chrome epoch (live edit) so a chrome
|
|
702
721
|
// change busts the browser's cached transpile even when the canvas source
|
|
703
722
|
// (hence result.etag) is unchanged. See RUNTIME_BOOT_ID / CHROME_EPOCH.
|
|
@@ -547,6 +547,16 @@ export interface FilePlane {
|
|
|
547
547
|
doruceka(): Record<string, DeliveryState>;
|
|
548
548
|
/** How many rows the ledger holds, before the doručenka's cap. */
|
|
549
549
|
dorucekaTotal(): number;
|
|
550
|
+
/**
|
|
551
|
+
* The media this machine cannot place, in BYTES as well as files — T29.
|
|
552
|
+
* `null` when nothing is blocked, so a healthy project carries no field.
|
|
553
|
+
*/
|
|
554
|
+
blocked(): {
|
|
555
|
+
files: number;
|
|
556
|
+
bytes: number;
|
|
557
|
+
unmeasured: number;
|
|
558
|
+
byClass: Record<string, { files: number; bytes: number }>;
|
|
559
|
+
} | null;
|
|
550
560
|
}
|
|
551
561
|
|
|
552
562
|
export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
@@ -2223,6 +2233,39 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
|
|
|
2223
2233
|
dorucekaTotal() {
|
|
2224
2234
|
return Object.keys(ledger.rows()).length;
|
|
2225
2235
|
},
|
|
2236
|
+
/**
|
|
2237
|
+
* HOW MUCH MEDIA IS NOT HERE — plan T29.
|
|
2238
|
+
*
|
|
2239
|
+
* `failed` counts files, and a count is the wrong unit for the question
|
|
2240
|
+
* an operator is actually asking. "Nine files failed" is unreadable: nine
|
|
2241
|
+
* CSS sidecars is nothing, nine videos is the project. The bytes are what
|
|
2242
|
+
* says whether a person is looking at a broken thumbnail or at a project
|
|
2243
|
+
* whose media never arrived.
|
|
2244
|
+
*
|
|
2245
|
+
* Bounded by construction — one entry per blocked CLASS, and the classes
|
|
2246
|
+
* are a fixed set (`file-ledger.ts`), never per path. Sizes come from the
|
|
2247
|
+
* ledger's stat cache; a row we have never measured contributes to the
|
|
2248
|
+
* file count and nothing to the bytes, which is honest about not knowing
|
|
2249
|
+
* rather than guessing a size.
|
|
2250
|
+
*/
|
|
2251
|
+
blocked() {
|
|
2252
|
+
const byClass: Record<string, { files: number; bytes: number }> = {};
|
|
2253
|
+
let files = 0;
|
|
2254
|
+
let bytes = 0;
|
|
2255
|
+
let unmeasured = 0;
|
|
2256
|
+
for (const row of Object.values(ledger.rows())) {
|
|
2257
|
+
if (row.state !== 'refused' && row.state !== 'stuck') continue;
|
|
2258
|
+
const cls = row.blockedClass ?? (row.state === 'stuck' ? 'unreachable' : 'refused');
|
|
2259
|
+
const size = typeof row.size === 'number' ? row.size : 0;
|
|
2260
|
+
if (!row.size) unmeasured += 1;
|
|
2261
|
+
byClass[cls] ??= { files: 0, bytes: 0 };
|
|
2262
|
+
byClass[cls].files += 1;
|
|
2263
|
+
byClass[cls].bytes += size;
|
|
2264
|
+
files += 1;
|
|
2265
|
+
bytes += size;
|
|
2266
|
+
}
|
|
2267
|
+
return files === 0 ? null : { files, bytes, unmeasured, byClass };
|
|
2268
|
+
},
|
|
2226
2269
|
};
|
|
2227
2270
|
}
|
|
2228
2271
|
|
|
@@ -2009,6 +2009,30 @@ export function createSyncRuntime(
|
|
|
2009
2009
|
* canvas accepted a moment ago is pulled on the next poll, not the one after
|
|
2010
2010
|
* its row is written.
|
|
2011
2011
|
*/
|
|
2012
|
+
/**
|
|
2013
|
+
* Where the PROJECT says a document lives, under accepted revisions — or
|
|
2014
|
+
* null when the project is not the authority (legacy mode, no bootstrap yet,
|
|
2015
|
+
* a retired entry, or a path it never recorded).
|
|
2016
|
+
*
|
|
2017
|
+
* A pulled canvas used to start at the slug-derived fallback
|
|
2018
|
+
* (`ui/surfaceboards-peer.tsx`) and wait for its document's own path to
|
|
2019
|
+
* correct it after the handshake. A fresh copy that reached the handshake
|
|
2020
|
+
* before that path was readable kept the fallback, and on a case-insensitive
|
|
2021
|
+
* disk the lowercase file then sat beside nothing and matched nothing on its
|
|
2022
|
+
* peers (surface run 2026-09-16, L24 final parity). The manifest already
|
|
2023
|
+
* knows the answer before anything is fetched.
|
|
2024
|
+
*/
|
|
2025
|
+
const manifestPathFor = (docName: string, slug?: string): string | null => {
|
|
2026
|
+
if (!acceptedOn()) return null;
|
|
2027
|
+
// By slug when there is one: the listing and the runtime name the same
|
|
2028
|
+
// document in more than one spelling (`ui-x`, `ws/<w>/<b>/ui-x`).
|
|
2029
|
+
const key = slug ?? slugFromDocName(docName);
|
|
2030
|
+
const entry = acceptedLink?.manifest?.docs.find(
|
|
2031
|
+
(d) => !d.retired && (d.doc === docName || slugFromDocName(d.doc) === key)
|
|
2032
|
+
);
|
|
2033
|
+
return typeof entry?.path === 'string' && entry.path.length > 0 ? entry.path : null;
|
|
2034
|
+
};
|
|
2035
|
+
|
|
2012
2036
|
function withAcceptedDocs(
|
|
2013
2037
|
listing: Awaited<ReturnType<typeof fetchRemoteListing>>
|
|
2014
2038
|
): Awaited<ReturnType<typeof fetchRemoteListing>> {
|
|
@@ -2310,7 +2334,7 @@ export function createSyncRuntime(
|
|
|
2310
2334
|
path.join,
|
|
2311
2335
|
path.resolve,
|
|
2312
2336
|
path.sep,
|
|
2313
|
-
{ ...pathOpts, realpath: realpathOfDeepestExisting }
|
|
2337
|
+
{ ...pathOpts, realpath: realpathOfDeepestExisting, pathFor: manifestPathFor }
|
|
2314
2338
|
);
|
|
2315
2339
|
const pullNote = describeRemoteDiff(remoteDiff);
|
|
2316
2340
|
if (pullNote) console.log(`[sync] ${pullNote}`);
|
|
@@ -3322,7 +3346,9 @@ export function createSyncRuntime(
|
|
|
3322
3346
|
if (movedToFromDoc(doc) !== null) return true;
|
|
3323
3347
|
const resolved = resolvePulledTarget({
|
|
3324
3348
|
slug: canvas.slug,
|
|
3325
|
-
|
|
3349
|
+
// The document's own record first (it is what a move rewrites), the
|
|
3350
|
+
// project's manifest when the document has not carried one yet.
|
|
3351
|
+
path: canvasPathFromDoc(doc) ?? manifestPathFor(docNameFor(canvas.slug), canvas.slug),
|
|
3326
3352
|
designRoot: ctx.paths.designRoot,
|
|
3327
3353
|
designRel: ctx.paths.designRel,
|
|
3328
3354
|
canvasGroups: ctx.cfg.canvasGroups,
|
|
@@ -3507,8 +3533,23 @@ export function createSyncRuntime(
|
|
|
3507
3533
|
);
|
|
3508
3534
|
}
|
|
3509
3535
|
// Cold-start reconcile fires once the provider has hub state.
|
|
3510
|
-
const synced = provider.onceSynced().then(() => {
|
|
3536
|
+
const synced = provider.onceSynced().then(async () => {
|
|
3511
3537
|
if (deferSetup) {
|
|
3538
|
+
// A DOCUMENT THAT DOES NOT NAME ITS PATH YET, UNDER A MANIFEST THAT
|
|
3539
|
+
// PREDATES IT. A canvas created a moment ago is listed before this
|
|
3540
|
+
// copy's last bootstrap knew it, and its document can answer the
|
|
3541
|
+
// handshake before the accepted write that carries its path — so
|
|
3542
|
+
// neither source names the path and the slug fallback (lowercase)
|
|
3543
|
+
// would be written for good (surface run 2026-09-16: two long-running
|
|
3544
|
+
// copies held `ui/surfaceassetsbusy.tsx`). Ask the project once.
|
|
3545
|
+
if (
|
|
3546
|
+
acceptedOn() &&
|
|
3547
|
+
canvasPathFromDoc(provider.document) === null &&
|
|
3548
|
+
manifestPathFor(docNameFor(canvas.slug), canvas.slug) === null
|
|
3549
|
+
) {
|
|
3550
|
+
await acceptedLink?.refresh().catch(() => null);
|
|
3551
|
+
}
|
|
3552
|
+
if (stopped) return;
|
|
3512
3553
|
// Settled either way below — run, or the canvas is released.
|
|
3513
3554
|
owedSetups.delete(canvas.slug);
|
|
3514
3555
|
// Abandon before `setup?.()`, so no projection and no agent is ever
|
|
@@ -3892,7 +3933,12 @@ export function createSyncRuntime(
|
|
|
3892
3933
|
// days. Once the body is on disk the ordinary rules apply to it.
|
|
3893
3934
|
for (const slug of [...pulledSlugs]) {
|
|
3894
3935
|
const body = descriptors.get(slug)?.html;
|
|
3895
|
-
|
|
3936
|
+
// …and only once THIS scan has seen it. A scan taken just before the
|
|
3937
|
+
// body landed does not list it; dropping the pin anyway released a
|
|
3938
|
+
// canvas pulled a moment ago and re-adopted it as a local file, whose
|
|
3939
|
+
// cold start then read its own first write as a local edit and held
|
|
3940
|
+
// the canvas ("source sync blocked (local-edit)" on a fresh copy).
|
|
3941
|
+
if (body && existsSync(body) && bySlug.has(slug)) pulledSlugs.delete(slug);
|
|
3896
3942
|
}
|
|
3897
3943
|
const { added, removed } = diffCanvasSet(
|
|
3898
3944
|
[...agents.keys(), ...projections.keys()],
|
|
@@ -4043,6 +4089,14 @@ export function createSyncRuntime(
|
|
|
4043
4089
|
// not told the document exists.
|
|
4044
4090
|
const pullRemoteOnce = async (): Promise<void> => {
|
|
4045
4091
|
if (stopped) return;
|
|
4092
|
+
// Read `token` at call time: a silent renewal swaps it in place.
|
|
4093
|
+
//
|
|
4094
|
+
// The listing FIRST, the manifest after it. A document the storage
|
|
4095
|
+
// listing shows was accepted before it was stored, so a manifest fetched
|
|
4096
|
+
// afterwards names it — and its path. The other order left a window in
|
|
4097
|
+
// which a canvas created between the two requests was pulled with no
|
|
4098
|
+
// path from either source (see `manifestPathFor`).
|
|
4099
|
+
const rawListing = await fetchRemoteListing(linkedHub.url, token);
|
|
4046
4100
|
// Accepted revisions: the save mode can change under a running peer (the
|
|
4047
4101
|
// hub fences every socket when it does), and folders are manifest
|
|
4048
4102
|
// entries — neither is in the document listing.
|
|
@@ -4050,8 +4104,7 @@ export function createSyncRuntime(
|
|
|
4050
4104
|
await acceptedLink.refresh();
|
|
4051
4105
|
if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
4052
4106
|
}
|
|
4053
|
-
|
|
4054
|
-
const listing = withAcceptedDocs(await fetchRemoteListing(linkedHub.url, token));
|
|
4107
|
+
const listing = withAcceptedDocs(rawListing);
|
|
4055
4108
|
// null = unreachable, refused, or a hub without the route. Not an error
|
|
4056
4109
|
// here any more than it is at boot — sync continues, we ask again later.
|
|
4057
4110
|
if (stopped || listing === null) return;
|
|
@@ -4078,6 +4131,7 @@ export function createSyncRuntime(
|
|
|
4078
4131
|
// NEVER the fresh-link relaxation after boot. See `strictPullSlugs`.
|
|
4079
4132
|
allowUndeclaredGroup: false,
|
|
4080
4133
|
realpath: realpathOfDeepestExisting,
|
|
4134
|
+
pathFor: manifestPathFor,
|
|
4081
4135
|
}
|
|
4082
4136
|
);
|
|
4083
4137
|
const admitted = targets
|
|
@@ -4304,6 +4358,9 @@ export function createSyncRuntime(
|
|
|
4304
4358
|
lastSeedProgress = progress;
|
|
4305
4359
|
reportSeedProgress(progress, result);
|
|
4306
4360
|
}
|
|
4361
|
+
// Read once: calling it inside the spread evaluated it twice and made
|
|
4362
|
+
// its `null` case the object's type.
|
|
4363
|
+
const blockedMedia = filePlane?.blocked() ?? null;
|
|
4307
4364
|
statusStore?.updateFiles?.({
|
|
4308
4365
|
...fileTotals,
|
|
4309
4366
|
pushed: filePushed,
|
|
@@ -4314,6 +4371,9 @@ export function createSyncRuntime(
|
|
|
4314
4371
|
...(filePlane.dorucekaTotal() > MAX_DORUCEKA_ROWS
|
|
4315
4372
|
? { deliveryTruncated: filePlane.dorucekaTotal() - MAX_DORUCEKA_ROWS }
|
|
4316
4373
|
: {}),
|
|
4374
|
+
// T29 — the bytes, not just the count. Omitted entirely when
|
|
4375
|
+
// nothing is blocked, so a healthy project carries no field.
|
|
4376
|
+
...(blockedMedia ? { blocked: blockedMedia } : {}),
|
|
4317
4377
|
}
|
|
4318
4378
|
: {}),
|
|
4319
4379
|
...(held.length > 0 ? { held } : {}),
|
|
@@ -5498,9 +5558,26 @@ export interface DisposableProviderFactory extends ProviderFactory {
|
|
|
5498
5558
|
}
|
|
5499
5559
|
|
|
5500
5560
|
/**
|
|
5501
|
-
*
|
|
5502
|
-
*
|
|
5503
|
-
*
|
|
5561
|
+
* Most documents one shared socket carries. Hocuspocus 4.3+ closes a socket
|
|
5562
|
+
* with more than 100 documents mid-authentication (`maxPendingDocuments`), so
|
|
5563
|
+
* this stays well under it with room for the refused documents a hub keeps
|
|
5564
|
+
* counting — see the socket choice in `createDefaultProviderFactory`.
|
|
5565
|
+
*/
|
|
5566
|
+
export const SOCKET_DOCUMENT_LIMIT = 64;
|
|
5567
|
+
|
|
5568
|
+
/** `SOCKET_DOCUMENT_LIMIT`, or a smaller positive override from
|
|
5569
|
+
* `MAUDE_SYNC_SOCKET_DOCUMENTS` — so a harness can put a small project on
|
|
5570
|
+
* several sockets without having to build a large one first. */
|
|
5571
|
+
function socketDocumentLimit(): number {
|
|
5572
|
+
const n = Number.parseInt(process.env.MAUDE_SYNC_SOCKET_DOCUMENTS ?? '', 10);
|
|
5573
|
+
return Number.isFinite(n) && n > 0 && n < SOCKET_DOCUMENT_LIMIT ? n : SOCKET_DOCUMENT_LIMIT;
|
|
5574
|
+
}
|
|
5575
|
+
|
|
5576
|
+
/**
|
|
5577
|
+
* The production provider factory — DDR-102: shared
|
|
5578
|
+
* `HocuspocusProviderWebsocket`s per hub URL, each carrying up to
|
|
5579
|
+
* `SOCKET_DOCUMENT_LIMIT` canvases' `HocuspocusProvider`s, instead of one
|
|
5580
|
+
* socket per canvas.
|
|
5504
5581
|
* An 83-canvas project used to open 83 WebSockets at boot — the auth burst
|
|
5505
5582
|
* tripped the hub's per-token rate limit (100/min) the moment two peers
|
|
5506
5583
|
* booted together, and the per-socket retry storm then pinned the bucket
|
|
@@ -5518,10 +5595,13 @@ export function createDefaultProviderFactory(
|
|
|
5518
5595
|
): DisposableProviderFactory {
|
|
5519
5596
|
// biome-ignore lint/suspicious/noExplicitAny: provider runtime typed at call site.
|
|
5520
5597
|
let mod: any = null;
|
|
5521
|
-
// wsUrl → shared
|
|
5522
|
-
// runtime only ever talks to
|
|
5598
|
+
// wsUrl → the shared sockets to that hub, each carrying at most
|
|
5599
|
+
// SOCKET_DOCUMENT_LIMIT documents (in practice a runtime only ever talks to
|
|
5600
|
+
// one hub, but the map keeps the contract exact).
|
|
5523
5601
|
// biome-ignore lint/suspicious/noExplicitAny: provider runtime typed at call site.
|
|
5524
|
-
const sockets = new Map<string, any>();
|
|
5602
|
+
const sockets = new Map<string, Array<{ socket: any; names: Set<string> }>>();
|
|
5603
|
+
const allSockets = () => [...sockets.values()].flat().map((s) => s.socket);
|
|
5604
|
+
const perSocket = socketDocumentLimit();
|
|
5525
5605
|
|
|
5526
5606
|
const factory = async (args: {
|
|
5527
5607
|
url: string;
|
|
@@ -5542,8 +5622,26 @@ export function createDefaultProviderFactory(
|
|
|
5542
5622
|
// the scheme. The provider also accepts http(s):// and upgrades internally
|
|
5543
5623
|
// in newer versions, but ws:// is explicit + portable.
|
|
5544
5624
|
const wsUrl = toWsUrl(args.url);
|
|
5545
|
-
|
|
5546
|
-
|
|
5625
|
+
// A DOCUMENT KEEPS ITS SOCKET; A FULL SOCKET TAKES NO NEW ONES.
|
|
5626
|
+
//
|
|
5627
|
+
// Hocuspocus (4.3+) closes a socket that has more than 100 documents
|
|
5628
|
+
// mid-authentication, and every document on a socket authenticates at
|
|
5629
|
+
// once — when it opens, and again on every reconnect. One socket for a
|
|
5630
|
+
// 123-canvas project was therefore cut off, reopened and cut off again
|
|
5631
|
+
// for as long as the copy ran: it never synced, and each lap spent the
|
|
5632
|
+
// designer's whole per-label allowance (2026-09-16 certification run,
|
|
5633
|
+
// 4 618 closes). Newer hubs raise the limit; deployed ones keep it.
|
|
5634
|
+
//
|
|
5635
|
+
// Names are never taken back off a socket. The hub keeps a pending slot
|
|
5636
|
+
// for a refused document that sends anything after its refusal — a
|
|
5637
|
+
// detach's Close included — until the socket closes, so what a socket has
|
|
5638
|
+
// EVER carried is what counts against the limit, not what it carries now.
|
|
5639
|
+
const shards = sockets.get(wsUrl) ?? [];
|
|
5640
|
+
sockets.set(wsUrl, shards);
|
|
5641
|
+
let shard =
|
|
5642
|
+
shards.find((s) => s.names.has(args.documentName)) ??
|
|
5643
|
+
shards.find((s) => s.names.size < perSocket);
|
|
5644
|
+
if (!shard) {
|
|
5547
5645
|
// CONFIGURED, not defaulted (issue #118). The socket used to be built
|
|
5548
5646
|
// from the URL alone, which inherited `timeout: 0` — no per-attempt
|
|
5549
5647
|
// deadline — and that is the property that let one parked connection
|
|
@@ -5564,13 +5662,28 @@ export function createDefaultProviderFactory(
|
|
|
5564
5662
|
//
|
|
5565
5663
|
// Liveness is the runtime's job instead — see the stall watchdog in
|
|
5566
5664
|
// `start()`, which is a layer we control and can make safe.
|
|
5567
|
-
|
|
5568
|
-
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5665
|
+
shard = {
|
|
5666
|
+
socket: new mod.HocuspocusProviderWebsocket({
|
|
5667
|
+
url: wsUrl,
|
|
5668
|
+
messageReconnectTimeout: 30_000,
|
|
5669
|
+
maxDelay: 30_000,
|
|
5670
|
+
}),
|
|
5671
|
+
names: new Set<string>(),
|
|
5672
|
+
};
|
|
5673
|
+
shards.push(shard);
|
|
5674
|
+
if (process.env.MAUDE_SYNC_DEBUG === '1') {
|
|
5675
|
+
const tag = `[sync/socket ${shards.length}]`;
|
|
5676
|
+
const own = shard;
|
|
5677
|
+
own.socket.on('open', () => console.log(`${tag} open · ${own.names.size} document(s)`));
|
|
5678
|
+
own.socket.on('close', (e: { event?: { code?: number; reason?: string } }) =>
|
|
5679
|
+
console.log(
|
|
5680
|
+
`${tag} close ${e?.event?.code ?? '?'} ${e?.event?.reason ?? ''} · ${own.names.size} document(s)`
|
|
5681
|
+
)
|
|
5682
|
+
);
|
|
5683
|
+
}
|
|
5573
5684
|
}
|
|
5685
|
+
shard.names.add(args.documentName);
|
|
5686
|
+
const socket = shard.socket;
|
|
5574
5687
|
// Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
|
|
5575
5688
|
// injected one; otherwise own a fresh doc (the legacy two-doc path).
|
|
5576
5689
|
const document = args.document ?? new Y.Doc();
|
|
@@ -5717,7 +5830,7 @@ export function createDefaultProviderFactory(
|
|
|
5717
5830
|
|
|
5718
5831
|
return Object.assign(factory, {
|
|
5719
5832
|
dispose(): void {
|
|
5720
|
-
for (const socket of
|
|
5833
|
+
for (const socket of allSockets()) {
|
|
5721
5834
|
try {
|
|
5722
5835
|
socket.destroy();
|
|
5723
5836
|
} catch {
|
|
@@ -5727,7 +5840,7 @@ export function createDefaultProviderFactory(
|
|
|
5727
5840
|
sockets.clear();
|
|
5728
5841
|
},
|
|
5729
5842
|
reconnect(): void {
|
|
5730
|
-
for (const socket of
|
|
5843
|
+
for (const socket of allSockets()) {
|
|
5731
5844
|
try {
|
|
5732
5845
|
// NOT `disconnect()` + `connect()` — that pair WEDGES THE SOCKET SHUT,
|
|
5733
5846
|
// deterministically, in exactly the state this is called from
|
|
@@ -159,6 +159,23 @@ export interface FilePlaneStatus {
|
|
|
159
159
|
* added to cure, one lane over.
|
|
160
160
|
*/
|
|
161
161
|
failed?: number;
|
|
162
|
+
/**
|
|
163
|
+
* HOW MUCH is not here — plan T29, the unit `failed` gets wrong.
|
|
164
|
+
*
|
|
165
|
+
* A count cannot answer the operator's question: nine blocked CSS sidecars
|
|
166
|
+
* is nothing and nine blocked videos is the project. `bytes` is what tells
|
|
167
|
+
* those apart. `byClass` is bounded by the ledger's fixed blocked-class set
|
|
168
|
+
* and never keyed by path; `unmeasured` says how many of the files have no
|
|
169
|
+
* size on record, so an under-count is visible rather than silent.
|
|
170
|
+
*
|
|
171
|
+
* Absent — not zero — when nothing is blocked.
|
|
172
|
+
*/
|
|
173
|
+
blocked?: {
|
|
174
|
+
files: number;
|
|
175
|
+
bytes: number;
|
|
176
|
+
unmeasured: number;
|
|
177
|
+
byClass: Record<string, { files: number; bytes: number }>;
|
|
178
|
+
};
|
|
162
179
|
/**
|
|
163
180
|
* The hub asked us to slow down and the WHOLE LANE is paused until `until`.
|
|
164
181
|
*
|
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "steadier-team-projects",
|
|
6
|
+
"version": "1.4.2",
|
|
7
|
+
"date": "2026-09-16",
|
|
8
|
+
"kind": "fix",
|
|
9
|
+
"title": "Steadier team projects",
|
|
10
|
+
"summary": "Large projects (over a hundred canvases) stay connected, the desktop app no longer freezes after switching canvases back and forth, and an open canvas always shows the newest change — including photo edits — instead of sometimes lagging one behind.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "history-on-a-team-project",
|
|
15
|
+
"version": "1.4.1",
|
|
16
|
+
"date": "2026-09-16",
|
|
17
|
+
"kind": "fix",
|
|
18
|
+
"title": "Your project history, from the status bar",
|
|
19
|
+
"summary": "On a project you were invited to, the status bar now opens the project history — every change with who made it, and Undo on the ones that are yours. It was only reachable from the View menu before.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
4
22
|
{
|
|
5
23
|
"id": "design-system-on-every-desktop",
|
|
6
24
|
"version": "1.4.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -29,7 +29,8 @@
|
|
|
29
29
|
"test:e2e:desktop:git": "pnpm --filter @maude/desktop-e2e e2e:git",
|
|
30
30
|
"build": "pnpm -r --if-present --filter '!@maude/desktop' '!@maude/desktop-e2e' run build",
|
|
31
31
|
"build:binary": "bun run apps/studio/build.ts --release",
|
|
32
|
-
"test": "node --test --test-reporter=spec cli/**/*.test.mjs && pnpm --filter @maude/hub test",
|
|
32
|
+
"test": "node --test --test-reporter=spec cli/**/*.test.mjs && pnpm --filter @maude/hub test && pnpm run test:harness",
|
|
33
|
+
"test:harness": "node --test $(find scripts/dev/sync-e2e -maxdepth 1 -name 'surface-*.test.mjs' ! -name 'surface-source-audit.test.mjs') && bun test scripts/dev/sync-e2e/surface-source-audit.test.mjs",
|
|
33
34
|
"test:dev-server": "cd apps/studio && bun test --timeout 20000",
|
|
34
35
|
"lint": "biome check .",
|
|
35
36
|
"format": "biome format --write .",
|
|
@@ -53,16 +54,17 @@
|
|
|
53
54
|
"test:e2e:desktop:parity": "pnpm --filter @maude/desktop-e2e e2e:parity",
|
|
54
55
|
"test:e2e:desktop:sidecar-respawn": "pnpm --filter @maude/desktop-e2e e2e:sidecar-respawn",
|
|
55
56
|
"test:e2e:desktop:team-project": "pnpm --filter @maude/desktop-e2e e2e:team-project",
|
|
57
|
+
"test:e2e:desktop:s20": "pnpm --filter @maude/desktop-e2e e2e:s20",
|
|
56
58
|
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
57
59
|
},
|
|
58
60
|
"optionalDependencies": {
|
|
59
|
-
"@1agh/maude-darwin-arm64": "1.4.
|
|
60
|
-
"@1agh/maude-darwin-x64": "1.4.
|
|
61
|
-
"@1agh/maude-linux-arm64": "1.4.
|
|
62
|
-
"@1agh/maude-linux-arm64-musl": "1.4.
|
|
63
|
-
"@1agh/maude-linux-x64": "1.4.
|
|
64
|
-
"@1agh/maude-linux-x64-musl": "1.4.
|
|
65
|
-
"@1agh/maude-win32-x64": "1.4.
|
|
61
|
+
"@1agh/maude-darwin-arm64": "1.4.2",
|
|
62
|
+
"@1agh/maude-darwin-x64": "1.4.2",
|
|
63
|
+
"@1agh/maude-linux-arm64": "1.4.2",
|
|
64
|
+
"@1agh/maude-linux-arm64-musl": "1.4.2",
|
|
65
|
+
"@1agh/maude-linux-x64": "1.4.2",
|
|
66
|
+
"@1agh/maude-linux-x64-musl": "1.4.2",
|
|
67
|
+
"@1agh/maude-win32-x64": "1.4.2"
|
|
66
68
|
},
|
|
67
69
|
"files": [
|
|
68
70
|
"cli",
|
|
@@ -272,12 +272,24 @@
|
|
|
272
272
|
// the runtime is mounted — agent edits, manual edits, AND cross-peer synced
|
|
273
273
|
// writes — so presence + in-canvas state survive. Falls back to the plain
|
|
274
274
|
// location.reload() below only when no runtime is present.
|
|
275
|
+
//
|
|
276
|
+
// THE NEWEST REQUEST WINS, NOT THE LAST RESPONSE. Two changes in quick
|
|
277
|
+
// succession start two imports, and nothing makes them finish in the
|
|
278
|
+
// order they started — the first can be the slower build. Remounting
|
|
279
|
+
// whichever resolved last put the OLDER body on screen over the newer
|
|
280
|
+
// one while the file on disk was right (surface row L21, a peer's view
|
|
281
|
+
// stuck one edit behind). Only the latest request may remount or hold.
|
|
282
|
+
// soft-reload:begin
|
|
283
|
+
let softReloadSeq = 0;
|
|
284
|
+
const loadCanvasModule = (url) => import(url);
|
|
275
285
|
function softReload(version) {
|
|
276
286
|
if (!canvasUrl) { location.reload(); return; }
|
|
277
287
|
const rt = window.__maudeCanvasRuntime;
|
|
278
288
|
if (!rt || !rt.remount) { location.reload(); return; }
|
|
279
|
-
|
|
289
|
+
const seq = ++softReloadSeq;
|
|
290
|
+
loadCanvasModule(canvasUrl + (canvasUrl.includes('?') ? '&' : '?') + 'v=' + (version || Date.now()))
|
|
280
291
|
.then((mod) => {
|
|
292
|
+
if (seq !== softReloadSeq) return;
|
|
281
293
|
if (mod && typeof mod.default === 'function') {
|
|
282
294
|
rt.remount(mod.default);
|
|
283
295
|
if (rt.setHolding) rt.setHolding(false);
|
|
@@ -287,16 +299,19 @@
|
|
|
287
299
|
}
|
|
288
300
|
})
|
|
289
301
|
.catch((err) => {
|
|
302
|
+
if (seq !== softReloadSeq) return;
|
|
290
303
|
// Build/transpile/import error → keep the current render, surface a
|
|
291
304
|
// non-destructive "holding" toast. Next good build swaps it in.
|
|
292
305
|
// Same M8 diagnosis as the mount path: the 422 body names the
|
|
293
306
|
// culprit, the TypeError never does.
|
|
294
307
|
diagnoseImportFailure(canvasUrl, err).then((msg) => {
|
|
308
|
+
if (seq !== softReloadSeq) return;
|
|
295
309
|
if (rt.setHolding) rt.setHolding(true, msg || 'build error');
|
|
296
310
|
});
|
|
297
311
|
console.warn('[canvas-shell] soft-reload held last good (build error):', err);
|
|
298
312
|
});
|
|
299
313
|
}
|
|
314
|
+
// soft-reload:end
|
|
300
315
|
// Phase 6.5 — exporters pass ?hide-chrome=1 to flip the export-mode
|
|
301
316
|
// stylesheet from `media="not all"` to `media="all"`, hiding the
|
|
302
317
|
// dev-server overlays during capture. See `<style id="canvas-hide-chrome">`.
|
|
@@ -634,6 +649,20 @@
|
|
|
634
649
|
}
|
|
635
650
|
connectHmr();
|
|
636
651
|
|
|
652
|
+
// NO TOP-LEVEL AWAIT IN THIS MODULE.
|
|
653
|
+
//
|
|
654
|
+
// The boot below used to `await` at the top level, which made this the
|
|
655
|
+
// one asynchronously-evaluated module on the page — suspended, with its
|
|
656
|
+
// own dynamic `import()`s in flight, for as long as the canvas took to
|
|
657
|
+
// build. On the desktop (WKWebView) switching canvases away and straight
|
|
658
|
+
// back, which tears the half-loaded frame down in exactly that state,
|
|
659
|
+
// eventually wedged the whole web content process inside JavaScriptCore's
|
|
660
|
+
// module loader: 100 % CPU in `moduleLoaderEvaluate` microtasks, no script
|
|
661
|
+
// ever running again, every canvas and the shell frozen (surface row L23,
|
|
662
|
+
// 2026-09-16 — reproduced in a full run and in a ten-minute soak). An
|
|
663
|
+
// async function that nothing awaits gives the same boot with a module
|
|
664
|
+
// that finishes evaluating at once, the path every engine exercises most.
|
|
665
|
+
const boot = async () => {
|
|
637
666
|
if (!canvasRel) {
|
|
638
667
|
showError('Missing ?canvas= query parameter. Add ?canvas=ui/<slug>.tsx.');
|
|
639
668
|
} else {
|
|
@@ -711,6 +740,8 @@
|
|
|
711
740
|
diagnoseImportFailure(canvasUrl, err).then(showError);
|
|
712
741
|
}
|
|
713
742
|
}
|
|
743
|
+
};
|
|
744
|
+
boot().catch((err) => showError(String((err && err.stack) || err)));
|
|
714
745
|
</script>
|
|
715
746
|
</body>
|
|
716
747
|
</html>
|