@1agh/maude 1.4.4 → 1.4.5
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/api.ts +4 -0
- package/apps/studio/canvas-comment-mount.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +62 -13
- package/apps/studio/canvas-source-memo.ts +39 -0
- package/apps/studio/canvas-text-patch.ts +117 -0
- package/apps/studio/client/app.jsx +105 -60
- package/apps/studio/client/file-tree.jsx +157 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/DiffView.jsx +37 -8
- package/apps/studio/client/panels/GitPanel.jsx +2 -1
- package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
- package/apps/studio/client/styles/3-shell-maude.css +21 -9
- package/apps/studio/client/styles/4-components.css +4 -2
- package/apps/studio/dist/client.bundle.js +1086 -1086
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +59 -3
- package/apps/studio/http.ts +3 -0
- package/apps/studio/sync/accepted-cold-start.ts +35 -3
- package/apps/studio/sync/accepted-link.ts +165 -29
- package/apps/studio/sync/file-plane.ts +24 -1
- package/apps/studio/sync/index.ts +87 -17
- package/apps/studio/sync/journal-client.ts +9 -3
- package/apps/studio/sync/projection.ts +114 -11
- package/apps/studio/sync/remote-docs.ts +3 -2
- package/apps/studio/sync/source-recovery.ts +67 -0
- package/apps/studio/sync/status.ts +11 -3
- package/apps/studio/sync/transaction-client.ts +49 -4
- package/apps/studio/use-selection-set.tsx +9 -3
- package/apps/studio/use-undo-stack.tsx +22 -2
- package/apps/studio/whats-new.json +9 -0
- package/cli/lib/workspace-plan.mjs +10 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +21 -0
|
@@ -119,6 +119,14 @@ export interface SyncProvider {
|
|
|
119
119
|
* Optional: a provider without it is treated as writable.
|
|
120
120
|
*/
|
|
121
121
|
isWritable?(): boolean;
|
|
122
|
+
/**
|
|
123
|
+
* The hub's own word on this connection's write right, carried by a save-mode
|
|
124
|
+
* notice (`maude.mode` with `writable`). It outranks the scope the handshake
|
|
125
|
+
* returned: a connection admitted read-only while the project took proposals
|
|
126
|
+
* is writable again the moment the project returns to legacy — the hub fences
|
|
127
|
+
* per message, not per handshake — and nothing re-authenticates it.
|
|
128
|
+
*/
|
|
129
|
+
noteWritable?(writable: boolean): void;
|
|
122
130
|
/** The hub authenticated this connection (again) — scope may have changed. */
|
|
123
131
|
onAuthenticated?(cb: (scope: string) => void): () => void;
|
|
124
132
|
/** Out-of-band messages from the hub on this document's socket. */
|
|
@@ -915,6 +923,8 @@ export function createSyncRuntime(
|
|
|
915
923
|
// change is a proposal through the durable outbox, and the documents change
|
|
916
924
|
// when the project publishes the accepted revision. Shared-doc only: the
|
|
917
925
|
// two-doc agent path has no proposal lane and stays legacy.
|
|
926
|
+
/** Resolves once a previous run's outbox is drained: doc → own accepted html. */
|
|
927
|
+
let outboxDrained: Promise<Map<string, string>> = Promise.resolve(new Map());
|
|
918
928
|
const acceptedLink: AcceptedLink | null = useSharedDoc
|
|
919
929
|
? createAcceptedLink({
|
|
920
930
|
hubUrl: linkedHub.url,
|
|
@@ -925,7 +935,16 @@ export function createSyncRuntime(
|
|
|
925
935
|
retryMs: opts.transactionRetryMs,
|
|
926
936
|
onStats: (stats) => statusStore?.updateAccepted?.(stats),
|
|
927
937
|
onStage: (summary) => statusStore?.updateAiAction?.(summary),
|
|
928
|
-
onBootstrap: (b) =>
|
|
938
|
+
onBootstrap: (b) => {
|
|
939
|
+
noteProjectConfig(b.projectConfig);
|
|
940
|
+
// F3 S17 — a save made as the socket died is held (the connection
|
|
941
|
+
// was not writable). After the reconnect the handshake re-admits the
|
|
942
|
+
// socket read-only BEFORE this peer learns the project now takes
|
|
943
|
+
// proposals, so its retry found the write still blocked and nothing
|
|
944
|
+
// retried again: the save stayed on disk, and the status said
|
|
945
|
+
// synced, until a restart. Knowing the mode, hand it back now.
|
|
946
|
+
if (b.mode === 'transactions') for (const p of projections.values()) p.retryDeferred();
|
|
947
|
+
},
|
|
929
948
|
})
|
|
930
949
|
: null;
|
|
931
950
|
/**
|
|
@@ -2038,16 +2057,15 @@ export function createSyncRuntime(
|
|
|
2038
2057
|
): Awaited<ReturnType<typeof fetchRemoteListing>> {
|
|
2039
2058
|
const manifest = acceptedOn() ? acceptedLink?.manifest : null;
|
|
2040
2059
|
if (!manifest) return listing;
|
|
2041
|
-
//
|
|
2042
|
-
//
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
}
|
|
2060
|
+
// An open Yjs room is not necessarily an accepted canvas. Its author can
|
|
2061
|
+
// connect before doc.create commits; pulling that empty room would lock
|
|
2062
|
+
// the receiver onto a lossy slug-derived path before the real path arrives.
|
|
2063
|
+
// The accepted manifest alone names live canvases, including successors
|
|
2064
|
+
// of retired documents whose transport rows may still linger.
|
|
2065
|
+
const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
|
|
2066
|
+
const documents = manifest.docs
|
|
2067
|
+
.filter((d) => !d.retired)
|
|
2068
|
+
.map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
|
|
2051
2069
|
return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
|
|
2052
2070
|
}
|
|
2053
2071
|
|
|
@@ -2248,10 +2266,29 @@ export function createSyncRuntime(
|
|
|
2248
2266
|
);
|
|
2249
2267
|
// Work a previous run left unanswered goes first, in creation order —
|
|
2250
2268
|
// before any cold start can propose something built on top of it.
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2269
|
+
// What each canvas's disk was last saved as, when that save was one of
|
|
2270
|
+
// these: the base its cold start judges the disk against.
|
|
2271
|
+
const own = new Map<string, string>();
|
|
2272
|
+
outboxDrained = acceptedLink.client
|
|
2273
|
+
.drainOutbox((result, operations) => {
|
|
2274
|
+
for (const o of operations) {
|
|
2275
|
+
const html =
|
|
2276
|
+
o.op === 'lane.replace' && o.lane === 'html'
|
|
2277
|
+
? o.content
|
|
2278
|
+
: o.op === 'doc.create'
|
|
2279
|
+
? (o.lanes as Record<string, unknown> | undefined)?.html
|
|
2280
|
+
: undefined;
|
|
2281
|
+
if (typeof o.doc !== 'string' || typeof html !== 'string') continue;
|
|
2282
|
+
if (result.status === 'accepted') own.set(o.doc, html);
|
|
2283
|
+
else own.delete(o.doc);
|
|
2284
|
+
}
|
|
2285
|
+
})
|
|
2286
|
+
.then((results) => {
|
|
2287
|
+
if (results.length)
|
|
2288
|
+
console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
|
|
2289
|
+
return own;
|
|
2290
|
+
})
|
|
2291
|
+
.catch(() => own);
|
|
2255
2292
|
applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
2256
2293
|
proposeLocalFolders();
|
|
2257
2294
|
}
|
|
@@ -2585,6 +2622,21 @@ export function createSyncRuntime(
|
|
|
2585
2622
|
if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
|
|
2586
2623
|
});
|
|
2587
2624
|
activityUnsubs.push(unsubSourceOp);
|
|
2625
|
+
// Only the trusted API emits this after its write has finished. External
|
|
2626
|
+
// fs events retain the quiet window; both paths use the same projection,
|
|
2627
|
+
// proposal, validation and echo/deduplication rules.
|
|
2628
|
+
const unsubSourceWritten = ctx.bus.on('source-written', (payload: unknown) => {
|
|
2629
|
+
const p = payload as { rel?: unknown; content?: unknown } | null;
|
|
2630
|
+
if (!acceptedOn() || typeof p?.content !== 'string') return;
|
|
2631
|
+
const proj = projectionForRel(p.rel);
|
|
2632
|
+
if (!proj) return;
|
|
2633
|
+
proj.applyFromFs({
|
|
2634
|
+
path: path.join(ctx.paths.designRoot, p.rel as string),
|
|
2635
|
+
bytes: new TextEncoder().encode(p.content),
|
|
2636
|
+
hash: hashBytes(p.content),
|
|
2637
|
+
});
|
|
2638
|
+
});
|
|
2639
|
+
activityUnsubs.push(unsubSourceWritten);
|
|
2588
2640
|
const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
|
|
2589
2641
|
projectionForRel(rel)?.cancelLocalWrite();
|
|
2590
2642
|
});
|
|
@@ -3058,6 +3110,10 @@ export function createSyncRuntime(
|
|
|
3058
3110
|
inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
|
|
3059
3111
|
}
|
|
3060
3112
|
const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
|
|
3113
|
+
// A save a previous run left unanswered is answered first: a disk
|
|
3114
|
+
// edited after it was edited ON it (F3 S14 on the cloud cell — judged
|
|
3115
|
+
// against the older base, the save conflicted with itself).
|
|
3116
|
+
const ownAccepted = (await outboxDrained).get(docName) ?? null;
|
|
3061
3117
|
await acceptedColdStart({
|
|
3062
3118
|
slug: canvas.slug,
|
|
3063
3119
|
doc: provider.document,
|
|
@@ -3067,6 +3123,8 @@ export function createSyncRuntime(
|
|
|
3067
3123
|
projection,
|
|
3068
3124
|
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
3069
3125
|
journal: journal ?? undefined,
|
|
3126
|
+
ownAccepted,
|
|
3127
|
+
wasAccepted: (content) => acceptedLink.client.holdsValue(content),
|
|
3070
3128
|
createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
|
|
3071
3129
|
});
|
|
3072
3130
|
projection.reconcile();
|
|
@@ -3733,6 +3791,8 @@ export function createSyncRuntime(
|
|
|
3733
3791
|
}
|
|
3734
3792
|
if (msg?.type !== 'maude.mode') return;
|
|
3735
3793
|
if (msg.mode === 'transactions' || msg.mode === 'legacy') {
|
|
3794
|
+
const writable = (msg as { writable?: unknown }).writable;
|
|
3795
|
+
if (typeof writable === 'boolean') provider.noteWritable?.(writable);
|
|
3736
3796
|
acceptedLink.noteMode(msg.mode);
|
|
3737
3797
|
void refreshAcceptedMode();
|
|
3738
3798
|
}
|
|
@@ -5740,11 +5800,17 @@ export function createDefaultProviderFactory(
|
|
|
5740
5800
|
socket.on('status', resetSyncedOnDrop);
|
|
5741
5801
|
|
|
5742
5802
|
let authedThisConnection = false;
|
|
5803
|
+
/** A save-mode notice's verdict on THIS connection; a new handshake supersedes it. */
|
|
5804
|
+
let modeWritable: boolean | null = null;
|
|
5743
5805
|
provider.on('authenticated', () => {
|
|
5744
5806
|
authedThisConnection = true;
|
|
5807
|
+
modeWritable = null;
|
|
5745
5808
|
});
|
|
5746
5809
|
const forgetAuthOnDrop = (evt: { status?: string }) => {
|
|
5747
|
-
if (evt?.status !== 'connected')
|
|
5810
|
+
if (evt?.status !== 'connected') {
|
|
5811
|
+
authedThisConnection = false;
|
|
5812
|
+
modeWritable = null;
|
|
5813
|
+
}
|
|
5748
5814
|
};
|
|
5749
5815
|
socket.on('status', forgetAuthOnDrop);
|
|
5750
5816
|
return {
|
|
@@ -5757,7 +5823,11 @@ export function createDefaultProviderFactory(
|
|
|
5757
5823
|
// over from before a drop says nothing about the hub now: a project
|
|
5758
5824
|
// switched to accepted revisions while this peer was away re-admits it
|
|
5759
5825
|
// read-only, and anything written in between would be dropped there.
|
|
5760
|
-
isWritable: () =>
|
|
5826
|
+
isWritable: () =>
|
|
5827
|
+
authedThisConnection && (modeWritable ?? provider.authorizedScope === 'read-write'),
|
|
5828
|
+
noteWritable(writable: boolean) {
|
|
5829
|
+
if (authedThisConnection) modeWritable = writable;
|
|
5830
|
+
},
|
|
5761
5831
|
onAuthenticated(cb: (scope: string) => void): () => void {
|
|
5762
5832
|
const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
|
|
5763
5833
|
provider.on('authenticated', handler);
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
// it as "no changes" is exactly the shape that lets a stale peer believe it is
|
|
18
18
|
// current, which is the failure DDR-214's ordering amendment exists to prevent.
|
|
19
19
|
|
|
20
|
+
import { isProjectFileShape } from './file-membership.ts';
|
|
21
|
+
|
|
20
22
|
/** How long to wait for a journal page. Same figure as the manifest fetch. */
|
|
21
23
|
const JOURNAL_TIMEOUT_MS = 6000;
|
|
22
24
|
|
|
@@ -58,8 +60,12 @@ export interface JournalPage {
|
|
|
58
60
|
overflowed?: true;
|
|
59
61
|
}
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
// A designRoot-relative path a peer will turn into a real file: the SAME shape
|
|
64
|
+
// rule the hub's file door admits (`isProjectFileShape`). A private, narrower
|
|
65
|
+
// regex here dropped every journalled path with a space in it — `ui/Studio
|
|
66
|
+
// Docs.registry.json` was accepted from its author, journalled, and then
|
|
67
|
+
// silently discarded by every other peer, with no ledger row and no refusal
|
|
68
|
+
// shown (F3 S14, 2026-09-23).
|
|
63
69
|
|
|
64
70
|
function parseEntry(raw: unknown): JournalEntry | null {
|
|
65
71
|
if (!raw || typeof raw !== 'object') return null;
|
|
@@ -67,7 +73,7 @@ function parseEntry(raw: unknown): JournalEntry | null {
|
|
|
67
73
|
const seq = e.seq;
|
|
68
74
|
const p = e.path;
|
|
69
75
|
if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return null;
|
|
70
|
-
if (typeof p !== 'string' || !
|
|
76
|
+
if (typeof p !== 'string' || !isProjectFileShape(p)) return null;
|
|
71
77
|
const sha = typeof e.sha256 === 'string' && /^[0-9a-f]{64}$/.test(e.sha256) ? e.sha256 : null;
|
|
72
78
|
return {
|
|
73
79
|
seq,
|
|
@@ -54,7 +54,12 @@ import type { RevisionBarrier } from './revision-barrier.ts';
|
|
|
54
54
|
import { repairSeedDuplication } from './seed-repair.ts';
|
|
55
55
|
import { mergeSource } from './source-merge.ts';
|
|
56
56
|
import type { SourceOp } from './source-ops.ts';
|
|
57
|
-
import {
|
|
57
|
+
import {
|
|
58
|
+
preserveRecoveryCandidate,
|
|
59
|
+
readRecoveryCandidate,
|
|
60
|
+
resolveRecoveryCandidate,
|
|
61
|
+
saveRecoveryBody,
|
|
62
|
+
} from './source-recovery.ts';
|
|
58
63
|
import { sourceError } from './source-validation.ts';
|
|
59
64
|
import { laneHash } from './transaction-client.ts';
|
|
60
65
|
|
|
@@ -222,6 +227,13 @@ export interface DocProjection {
|
|
|
222
227
|
* write stays blocked with a visible conflict and a later save can merge.
|
|
223
228
|
*/
|
|
224
229
|
adoptBase(body: string): void;
|
|
230
|
+
/**
|
|
231
|
+
* Accepted mode, cold start: `value` is this disk's own html proposal that a
|
|
232
|
+
* previous run left in the outbox and the drain just had accepted. It is
|
|
233
|
+
* what the disk was saved as — the base of whatever it holds next — even
|
|
234
|
+
* while this replica still shows the value before it.
|
|
235
|
+
*/
|
|
236
|
+
adoptOwnAccepted(value: string): void;
|
|
225
237
|
/**
|
|
226
238
|
* Accepted-revisions mode: propose one lane value that did not come through
|
|
227
239
|
* the watcher (a comment/annotation API write). Resolves with the outcome;
|
|
@@ -240,8 +252,13 @@ export interface DocProjection {
|
|
|
240
252
|
* recovery slots. Returns false when nothing is held.
|
|
241
253
|
*/
|
|
242
254
|
takeAccepted(): boolean;
|
|
243
|
-
/** T28 —
|
|
244
|
-
conflictSides(): {
|
|
255
|
+
/** T28 — current sides plus the first candidate and proven base (null when unknown). */
|
|
256
|
+
conflictSides(): {
|
|
257
|
+
mine: string | null;
|
|
258
|
+
theirs: string;
|
|
259
|
+
original: string | null;
|
|
260
|
+
base: string | null;
|
|
261
|
+
} | null;
|
|
245
262
|
/** Re-deliver file changes held while the document was not writable. */
|
|
246
263
|
retryDeferred(): void;
|
|
247
264
|
/**
|
|
@@ -290,6 +307,22 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
290
307
|
let validationCache: { body: string; error: string | null } | null = null;
|
|
291
308
|
|
|
292
309
|
function recovered(): void {
|
|
310
|
+
// An older accepted proposal is not resolution of a later pending/held one.
|
|
311
|
+
if (pending.has('html') || held.has('html')) return;
|
|
312
|
+
try {
|
|
313
|
+
if (opts.historyDir) resolveRecoveryCandidate(opts.historyDir, paths.html);
|
|
314
|
+
} catch {
|
|
315
|
+
// The accepted action is real even if updating our local recovery record
|
|
316
|
+
// fails. Keep the original pinned and the storage problem visible.
|
|
317
|
+
rejectedKey ??= 'recovery-failed';
|
|
318
|
+
opts.onConflict?.({
|
|
319
|
+
slug,
|
|
320
|
+
kind: 'body-rejected',
|
|
321
|
+
reason: 'history-failed',
|
|
322
|
+
snapshotFailed: true,
|
|
323
|
+
});
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
293
326
|
if (rejectedKey !== null) opts.onRecovered?.();
|
|
294
327
|
rejectedKey = null;
|
|
295
328
|
}
|
|
@@ -325,13 +358,16 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
325
358
|
reason: BodyRejection['reason'],
|
|
326
359
|
local: string | null,
|
|
327
360
|
incoming: string,
|
|
328
|
-
file = paths.html
|
|
361
|
+
file = paths.html,
|
|
362
|
+
base: string | null = lastHtml
|
|
329
363
|
): void {
|
|
330
364
|
const key = `${file}:${reason}:${hashBytes(local ?? '')}:${hashBytes(incoming)}`;
|
|
331
365
|
if (key === rejectedKey) return;
|
|
332
366
|
rejectedKey = key;
|
|
333
367
|
let snapshotFailed = false;
|
|
334
368
|
try {
|
|
369
|
+
if (file === paths.html && opts.historyDir)
|
|
370
|
+
preserveRecoveryCandidate(opts.historyDir, file, local, base);
|
|
335
371
|
if (file === paths.html) preserveLocal(local);
|
|
336
372
|
if (opts.historyDir) {
|
|
337
373
|
if (local !== null) saveRecoveryBody(opts.historyDir, file, 'local', local);
|
|
@@ -461,11 +497,29 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
461
497
|
next = repeated.unit;
|
|
462
498
|
}
|
|
463
499
|
}
|
|
500
|
+
if (acceptedOwn !== null && next !== lastHtml) {
|
|
501
|
+
// The replica moved. When it moved to exactly our accepted value and the
|
|
502
|
+
// disk already holds a newer save, that save is ours on top of it: agree
|
|
503
|
+
// on the accepted value and leave the file for the watcher to propose.
|
|
504
|
+
const own = next === acceptedOwn;
|
|
505
|
+
acceptedOwn = null;
|
|
506
|
+
if (own) {
|
|
507
|
+
const local = readLocal(paths.html);
|
|
508
|
+
if (local !== null && local !== next && local !== observedBody) {
|
|
509
|
+
lastHtml = next;
|
|
510
|
+
rememberBase(next);
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
464
515
|
if (next === lastHtml) return true;
|
|
465
516
|
// Don't clobber a non-empty local body with an empty doc (cold-start before
|
|
466
517
|
// the doc is seeded — the safe-reconcile invariant; full adopt is Phase E).
|
|
467
518
|
if (next === '') {
|
|
468
|
-
|
|
519
|
+
// Accepted mode: an empty replica is one the project's publication has
|
|
520
|
+
// not reached yet, never a value this disk agreed on — a base already
|
|
521
|
+
// known (a canvas this disk just added, adopted on acceptance) stays.
|
|
522
|
+
if (lastHtml === null || !acceptedOn()) lastHtml = next;
|
|
469
523
|
return true;
|
|
470
524
|
}
|
|
471
525
|
if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return false;
|
|
@@ -675,14 +729,29 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
675
729
|
return lane === 'comments' ? paths.comments : paths.annotations;
|
|
676
730
|
}
|
|
677
731
|
|
|
732
|
+
/**
|
|
733
|
+
* Our own html proposal the hub accepted, whose publication has not reached
|
|
734
|
+
* this replica yet. Until it does, it — not the older replica value — is
|
|
735
|
+
* what the next save on this disk was made on top of (F3 S14, 2026-09-23:
|
|
736
|
+
* a save in that window was proposed on the value before the first save and
|
|
737
|
+
* refused as a base conflict, and the echo itself read as "a local edit
|
|
738
|
+
* overlaps an incoming change").
|
|
739
|
+
*/
|
|
740
|
+
let acceptedOwn: string | null = null;
|
|
741
|
+
|
|
678
742
|
/** The value disk and the accepted replica last agreed on for `lane`. */
|
|
679
743
|
function agreedValue(lane: ProposalLane): string {
|
|
680
|
-
if (lane === 'html') return lastHtml ?? htmlFromDoc(doc);
|
|
744
|
+
if (lane === 'html') return acceptedOwn ?? lastHtml ?? htmlFromDoc(doc);
|
|
681
745
|
if (lane === 'css') return lastCss ?? cssFromDoc(doc) ?? '';
|
|
682
746
|
return readLaneFromDoc(doc, lane);
|
|
683
747
|
}
|
|
684
748
|
|
|
685
|
-
function onRejected(
|
|
749
|
+
function onRejected(
|
|
750
|
+
lane: ProposalLane,
|
|
751
|
+
local: string,
|
|
752
|
+
outcome: ProposalOutcome,
|
|
753
|
+
base: string
|
|
754
|
+
): void {
|
|
686
755
|
held.add(lane);
|
|
687
756
|
// The candidate stays on disk: the projection's local-edit guard protects
|
|
688
757
|
// it (html), `held` blocks the lane writer (css/meta), and the recovery
|
|
@@ -704,7 +773,8 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
704
773
|
REJECTION_REASON[outcome.code ?? ''] ?? 'local-edit',
|
|
705
774
|
local,
|
|
706
775
|
readLaneFromDoc(doc, lane),
|
|
707
|
-
pathOfLane(lane)
|
|
776
|
+
pathOfLane(lane),
|
|
777
|
+
base
|
|
708
778
|
);
|
|
709
779
|
}
|
|
710
780
|
|
|
@@ -772,6 +842,15 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
772
842
|
}
|
|
773
843
|
if (outcome.status === 'accepted') {
|
|
774
844
|
if (!pending.has(lane)) held.delete(lane);
|
|
845
|
+
if (lane === 'html' && !pending.has(lane)) {
|
|
846
|
+
// Our accepted value is the base of whatever this disk holds next —
|
|
847
|
+
// whichever arrives first, the answer or the publication.
|
|
848
|
+
// Persisted too: a cold start judges the disk against this base.
|
|
849
|
+
if (readLaneFromDoc(doc, 'html') === value) {
|
|
850
|
+
lastHtml = value;
|
|
851
|
+
rememberBase(value);
|
|
852
|
+
} else acceptedOwn = value;
|
|
853
|
+
}
|
|
775
854
|
if (lane === 'html') recovered();
|
|
776
855
|
if (outcome.actionId) {
|
|
777
856
|
try {
|
|
@@ -793,7 +872,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
793
872
|
lastMeta = null;
|
|
794
873
|
}
|
|
795
874
|
} else {
|
|
796
|
-
onRejected(lane, local, outcome);
|
|
875
|
+
onRejected(lane, local, outcome, baseContent);
|
|
797
876
|
}
|
|
798
877
|
scheduleFlush();
|
|
799
878
|
return outcome;
|
|
@@ -864,6 +943,14 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
864
943
|
// 2026-09-15, L09 delete). Every edit to these lanes arrives through the
|
|
865
944
|
// API with the base it was made from (`proposeLane`).
|
|
866
945
|
if (lane === 'comments' || lane === 'annotations') return false;
|
|
946
|
+
// A STALE EVENT PROPOSES NOTHING. The reader took these bytes before a
|
|
947
|
+
// later write reached the file — typically this projection materializing a
|
|
948
|
+
// newer accepted value — and delivered them after. Proposing them would
|
|
949
|
+
// put an older body back over a teammate's accepted edit (F3 S15, cloud
|
|
950
|
+
// cell, 2026-09-24: v13 over v14). What the disk holds now arrives with its
|
|
951
|
+
// own event.
|
|
952
|
+
const onDisk = readLocal(evt.path);
|
|
953
|
+
if (onDisk !== null && onDisk !== str) return false;
|
|
867
954
|
if (lane === 'html') {
|
|
868
955
|
if (str === lastHtml && !held.has('html')) return false; // a redelivered projection
|
|
869
956
|
if (!withinCap(paths.html, str, MAX_HTML_BYTES)) return false;
|
|
@@ -1127,6 +1214,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
1127
1214
|
lastHtml = body;
|
|
1128
1215
|
observedBody = body;
|
|
1129
1216
|
},
|
|
1217
|
+
adoptOwnAccepted(value: string) {
|
|
1218
|
+
// Exactly what an answer in this process does (see `submit`): the
|
|
1219
|
+
// replica either holds it already, or its publication is still coming.
|
|
1220
|
+
const replica = htmlFromDoc(doc);
|
|
1221
|
+
if (replica === value) lastHtml = value;
|
|
1222
|
+
else {
|
|
1223
|
+
lastHtml = replica;
|
|
1224
|
+
acceptedOwn = value;
|
|
1225
|
+
}
|
|
1226
|
+
rememberBase(value);
|
|
1227
|
+
},
|
|
1130
1228
|
hold(lane, base, local) {
|
|
1131
1229
|
held.add(lane);
|
|
1132
1230
|
if (lane === 'html') {
|
|
@@ -1146,12 +1244,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
1146
1244
|
lastHtml = null;
|
|
1147
1245
|
dirty = true;
|
|
1148
1246
|
void flush();
|
|
1149
|
-
recovered();
|
|
1150
1247
|
return true;
|
|
1151
1248
|
},
|
|
1152
1249
|
conflictSides() {
|
|
1153
1250
|
if (!held.has('html') && rejectedKey === null) return null;
|
|
1154
|
-
|
|
1251
|
+
const candidate = opts.historyDir ? readRecoveryCandidate(opts.historyDir, paths.html) : null;
|
|
1252
|
+
return {
|
|
1253
|
+
mine: readLocal(paths.html),
|
|
1254
|
+
theirs: htmlFromDoc(doc),
|
|
1255
|
+
original: candidate?.original ?? null,
|
|
1256
|
+
base: candidate?.base ?? null,
|
|
1257
|
+
};
|
|
1155
1258
|
},
|
|
1156
1259
|
proposeLane(lane, value, o) {
|
|
1157
1260
|
if (!acceptedOn() || stopped) return null;
|
|
@@ -341,8 +341,8 @@ export function pullTargets(
|
|
|
341
341
|
* readable. Both hops go through the same function, so "where does this canvas
|
|
342
342
|
* go" cannot have two answers.
|
|
343
343
|
*
|
|
344
|
-
* Returns null when
|
|
345
|
-
*
|
|
344
|
+
* Returns null when a present path is refused or containment fails. Only a
|
|
345
|
+
* document with no path may use the legacy slug-derived fallback.
|
|
346
346
|
*/
|
|
347
347
|
export function resolvePulledTarget(args: {
|
|
348
348
|
slug: string;
|
|
@@ -376,6 +376,7 @@ export function resolvePulledTarget(args: {
|
|
|
376
376
|
allowUndeclaredGroup: args.allowUndeclaredGroup,
|
|
377
377
|
onRefused: args.onRefused,
|
|
378
378
|
});
|
|
379
|
+
if (args.path !== undefined && args.path !== null && !fromPath) return null;
|
|
379
380
|
const bodyAbs = args.join(args.designRoot, rel);
|
|
380
381
|
// Belt and braces at a create. The validator already refuses everything that
|
|
381
382
|
// could escape lexically; this catches whatever a platform's own `resolve`
|
|
@@ -12,6 +12,73 @@ import { sourceError } from './source-validation.ts';
|
|
|
12
12
|
*/
|
|
13
13
|
export type RecoverySlot = 'last-valid' | 'local' | 'incoming' | 'base';
|
|
14
14
|
|
|
15
|
+
/** The first rejected draft and its proven base, not the mutable latest draft. */
|
|
16
|
+
export interface RecoveryCandidate {
|
|
17
|
+
version: 1;
|
|
18
|
+
original: string | null;
|
|
19
|
+
base: string | null;
|
|
20
|
+
resolved: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function candidatePath(historyDir: string, file: string): string {
|
|
24
|
+
return path.join(historyDir, 'sync-recovery', `candidate${path.extname(file)}.json`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function boundedBody(body: unknown): body is string | null {
|
|
28
|
+
return (
|
|
29
|
+
body === null || (typeof body === 'string' && Buffer.byteLength(body, 'utf8') <= MAX_HTML_BYTES)
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Corrupt/inaccessible records fail closed: never replace an unreadable draft. */
|
|
34
|
+
export function readRecoveryCandidate(historyDir: string, file: string): RecoveryCandidate | null {
|
|
35
|
+
const target = candidatePath(historyDir, file);
|
|
36
|
+
let size: number;
|
|
37
|
+
try {
|
|
38
|
+
size = statSync(target).size;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
// Two byte-capped strings, each potentially JSON-escaped as six bytes/char.
|
|
44
|
+
if (size > 12 * MAX_HTML_BYTES + 256) throw new Error('Recovery candidate exceeds size limit');
|
|
45
|
+
const value: unknown = JSON.parse(readFileSync(target, 'utf8'));
|
|
46
|
+
if (!value || typeof value !== 'object') throw new Error('Invalid recovery candidate');
|
|
47
|
+
const record = value as Record<string, unknown>;
|
|
48
|
+
if (
|
|
49
|
+
record.version !== 1 ||
|
|
50
|
+
typeof record.resolved !== 'boolean' ||
|
|
51
|
+
!boundedBody(record.original) ||
|
|
52
|
+
!boundedBody(record.base)
|
|
53
|
+
)
|
|
54
|
+
throw new Error('Invalid recovery candidate');
|
|
55
|
+
return { version: 1, original: record.original, base: record.base, resolved: record.resolved };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One bounded, atomic record per source file; retries/restarts cannot evict it. */
|
|
59
|
+
export function preserveRecoveryCandidate(
|
|
60
|
+
historyDir: string,
|
|
61
|
+
file: string,
|
|
62
|
+
original: string | null,
|
|
63
|
+
base: string | null
|
|
64
|
+
): void {
|
|
65
|
+
if (!boundedBody(original) || !boundedBody(base))
|
|
66
|
+
throw new Error('Recovery source exceeds size limit');
|
|
67
|
+
const previous = readRecoveryCandidate(historyDir, file);
|
|
68
|
+
if (previous && !previous.resolved) return;
|
|
69
|
+
atomicWrite(
|
|
70
|
+
candidatePath(historyDir, file),
|
|
71
|
+
JSON.stringify({ version: 1, original, base, resolved: false })
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Retain the bytes after resolution, allowing replacement only by a new episode. */
|
|
76
|
+
export function resolveRecoveryCandidate(historyDir: string, file: string): void {
|
|
77
|
+
const previous = readRecoveryCandidate(historyDir, file);
|
|
78
|
+
if (!previous || previous.resolved) return;
|
|
79
|
+
atomicWrite(candidatePath(historyDir, file), JSON.stringify({ ...previous, resolved: true }));
|
|
80
|
+
}
|
|
81
|
+
|
|
15
82
|
export function saveRecoveryBody(
|
|
16
83
|
historyDir: string,
|
|
17
84
|
file: string,
|
|
@@ -447,9 +447,17 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
447
447
|
clearSourceConflict(slug) {
|
|
448
448
|
const id = `source-conflict-${slug}`;
|
|
449
449
|
const index = notices.findIndex((n) => n.id === id);
|
|
450
|
-
|
|
451
|
-
notices.splice(index, 1);
|
|
452
|
-
|
|
450
|
+
let changed = index >= 0;
|
|
451
|
+
if (index >= 0) notices.splice(index, 1);
|
|
452
|
+
// The presentation reads these facts, not the dismissible notice. Clear
|
|
453
|
+
// every rejection of this source while keeping other sources and notes.
|
|
454
|
+
for (let i = conflicts.length - 1; i >= 0; i--) {
|
|
455
|
+
if (conflicts[i].slug === slug && conflicts[i].kind === 'body-rejected') {
|
|
456
|
+
conflicts.splice(i, 1);
|
|
457
|
+
changed = true;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (changed) flush(true);
|
|
453
461
|
},
|
|
454
462
|
get: payload,
|
|
455
463
|
};
|
|
@@ -337,9 +337,33 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
337
337
|
* process knew the project is bound first — it was never sent, so its bytes
|
|
338
338
|
* may still change; once sent, they never do.
|
|
339
339
|
*/
|
|
340
|
+
/**
|
|
341
|
+
* Learn the project (id + epoch) before binding or rebasing an entry. A
|
|
342
|
+
* transport failure is WAITED OUT like an unknown delivery — a desktop that
|
|
343
|
+
* restarts offline drains its outbox before the hub is reachable, and a
|
|
344
|
+
* throw here was an unhandled rejection that ended the process (F3/S06).
|
|
345
|
+
* An answer — a legacy hub (`absent`), a refused sign-in — is still thrown.
|
|
346
|
+
*/
|
|
347
|
+
async function bootstrapWhenReachable(): Promise<void> {
|
|
348
|
+
for (let attempt = 1; ; attempt++) {
|
|
349
|
+
if (stopped) throw new TransactionError('client stopped', 'stopped');
|
|
350
|
+
try {
|
|
351
|
+
await bootstrap();
|
|
352
|
+
return;
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (err instanceof TransactionError) throw err;
|
|
355
|
+
if (attempt === 1 || attempt % 10 === 0)
|
|
356
|
+
log.warn(
|
|
357
|
+
`[sync/tx] the project is not reachable yet (${(err as Error).message}); waiting`
|
|
358
|
+
);
|
|
359
|
+
await sleep(Math.min(retryMs * 2 ** Math.min(attempt - 1, 4), 30_000));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
340
364
|
async function settle(file: string, entry: OutboxEntry): Promise<ProposalResult> {
|
|
341
365
|
if (entry.unbound || projectId === null) {
|
|
342
|
-
if (projectId === null) await
|
|
366
|
+
if (projectId === null) await bootstrapWhenReachable();
|
|
343
367
|
if (entry.unbound && entry.action) {
|
|
344
368
|
entry = { ...entry, bytes: envelope(entry.action, entry.transactionId) };
|
|
345
369
|
delete entry.unbound;
|
|
@@ -351,7 +375,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
351
375
|
if (result.status === 'rejected' && result.code === 'epoch-stale' && entry.action) {
|
|
352
376
|
// A rebase is a NEW transaction under the current epoch, never a mutated
|
|
353
377
|
// retry — and anything that depended on the old id now depends on this.
|
|
354
|
-
await
|
|
378
|
+
await bootstrapWhenReachable();
|
|
355
379
|
const action = {
|
|
356
380
|
...entry.action,
|
|
357
381
|
...(entry.action.dependsOn
|
|
@@ -433,7 +457,9 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
433
457
|
* Resend what a previous process left in the outbox — in creation order, the
|
|
434
458
|
* same bytes, each resolved first in case its answer was simply lost.
|
|
435
459
|
*/
|
|
436
|
-
function drainOutbox(
|
|
460
|
+
function drainOutbox(
|
|
461
|
+
onEach?: (result: ProposalResult, operations: Operation[]) => void
|
|
462
|
+
): Promise<ProposalResult[]> {
|
|
437
463
|
return enqueue(async () => {
|
|
438
464
|
const entries = readOutbox().filter(({ file }) => !owned.has(file));
|
|
439
465
|
const results: ProposalResult[] = [];
|
|
@@ -441,7 +467,9 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
441
467
|
waiting.set(entry.transactionId, entry.createdAt);
|
|
442
468
|
setPending(1);
|
|
443
469
|
try {
|
|
444
|
-
|
|
470
|
+
const result = await settle(file, entry);
|
|
471
|
+
results.push(result);
|
|
472
|
+
onEach?.(result, entry.action?.operations ?? []);
|
|
445
473
|
} finally {
|
|
446
474
|
waiting.delete(entry.transactionId);
|
|
447
475
|
setPending(-1);
|
|
@@ -451,6 +479,22 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
451
479
|
});
|
|
452
480
|
}
|
|
453
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Is `content` a value the project store holds — i.e. one it accepted at
|
|
484
|
+
* some revision? `null` when it cannot tell (unreachable, older hub).
|
|
485
|
+
*/
|
|
486
|
+
async function holdsValue(content: string): Promise<boolean | null> {
|
|
487
|
+
try {
|
|
488
|
+
if (projectId === null) await bootstrap();
|
|
489
|
+
const hash = createHash('sha256').update(content, 'utf8').digest('hex');
|
|
490
|
+
const { status, json } = await request('GET', `blobs/${hash}`);
|
|
491
|
+
if (status === 200) return (json as { body?: unknown } | null)?.body === content;
|
|
492
|
+
return status === 404 ? false : null;
|
|
493
|
+
} catch {
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
454
498
|
/** A read route (`history`, `lane`, `revisions`) — no retry, bounded. */
|
|
455
499
|
async function read(route: string, params: Record<string, string | number>): Promise<unknown> {
|
|
456
500
|
if (projectId === null) await bootstrap();
|
|
@@ -468,6 +512,7 @@ export function createTransactionClient(opts: TransactionClientOptions) {
|
|
|
468
512
|
read,
|
|
469
513
|
newTransactionId,
|
|
470
514
|
drainOutbox,
|
|
515
|
+
holdsValue,
|
|
471
516
|
/** The epoch proposals are currently made under. */
|
|
472
517
|
get epoch() {
|
|
473
518
|
return epoch;
|