@1agh/maude 0.60.7 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/acp/index.ts +1 -0
- package/apps/studio/ai-banner.tsx +1 -0
- package/apps/studio/annotations-context-toolbar.tsx +3 -1
- package/apps/studio/annotations-layer.tsx +33 -16
- package/apps/studio/api.ts +108 -18
- package/apps/studio/artboard-guides-overlay.tsx +5 -1
- package/apps/studio/assets-s3.ts +6 -1
- package/apps/studio/bin/_import-figma.mjs +8 -3
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-artifacts.ts +21 -0
- package/apps/studio/canvas-build.ts +14 -9
- package/apps/studio/canvas-comment-mount.tsx +27 -30
- package/apps/studio/canvas-icons.tsx +1 -1
- package/apps/studio/canvas-lib.tsx +94 -5
- package/apps/studio/canvas-list-watch.ts +14 -1
- package/apps/studio/canvas-shell.tsx +8 -0
- package/apps/studio/client/app.jsx +224 -45
- package/apps/studio/client/panels/GitPanel.jsx +121 -13
- package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
- package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
- package/apps/studio/client/panels/SyncPanel.jsx +595 -1
- package/apps/studio/client/styles/3-shell-maude.css +38 -0
- package/apps/studio/clip-ops.ts +8 -1
- package/apps/studio/cloud/endpoints.ts +265 -3
- package/apps/studio/collab/origins.ts +3 -1
- package/apps/studio/comments-overlay.tsx +5 -0
- package/apps/studio/config.schema.json +24 -0
- package/apps/studio/context-menu.tsx +40 -27
- package/apps/studio/context.ts +57 -8
- package/apps/studio/cursors-overlay.tsx +25 -13
- package/apps/studio/dist/client.bundle.js +686 -686
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/jobs.ts +77 -15
- package/apps/studio/exporters/remote.ts +201 -0
- package/apps/studio/exporters/video-encode-lib.ts +10 -4
- package/apps/studio/figma/to-strokes.ts +11 -9
- package/apps/studio/gifenc.d.ts +51 -0
- package/apps/studio/git/log-format.ts +88 -0
- package/apps/studio/git/safe-rel.ts +96 -0
- package/apps/studio/git/service.ts +46 -27
- package/apps/studio/hmr-broadcast.ts +10 -0
- package/apps/studio/http.ts +384 -6
- package/apps/studio/participants-chrome.tsx +1 -0
- package/apps/studio/photo-store.ts +7 -0
- package/apps/studio/react-augment.d.ts +17 -0
- package/apps/studio/runtime-bundle.ts +6 -1
- package/apps/studio/server.ts +43 -8
- package/apps/studio/sync/agent.ts +70 -82
- package/apps/studio/sync/asset-push.ts +28 -71
- package/apps/studio/sync/autocommit.ts +106 -5
- package/apps/studio/sync/cell-file-events.ts +117 -0
- package/apps/studio/sync/cell-pairing.ts +20 -5
- package/apps/studio/sync/cell-write-nudge.ts +244 -0
- package/apps/studio/sync/codec.ts +155 -3
- package/apps/studio/sync/cold-start-apply.ts +211 -0
- package/apps/studio/sync/ctl-heal.ts +253 -0
- package/apps/studio/sync/ctl-provider.ts +217 -0
- package/apps/studio/sync/decide-file.ts +335 -0
- package/apps/studio/sync/file-ledger.ts +581 -0
- package/apps/studio/sync/file-membership.ts +32 -0
- package/apps/studio/sync/file-plane.ts +1400 -0
- package/apps/studio/sync/file-pull.ts +41 -4
- package/apps/studio/sync/hub-link.ts +16 -1
- package/apps/studio/sync/hub-listing.ts +46 -0
- package/apps/studio/sync/hubs-config.ts +16 -0
- package/apps/studio/sync/index.ts +915 -308
- package/apps/studio/sync/journal-client.ts +200 -0
- package/apps/studio/sync/migrate-seed.ts +99 -67
- package/apps/studio/sync/poke.ts +50 -0
- package/apps/studio/sync/projection.ts +13 -0
- package/apps/studio/sync/pull-budget.ts +86 -0
- package/apps/studio/sync/settings.ts +110 -0
- package/apps/studio/sync/status.ts +68 -0
- package/apps/studio/sync/trash.ts +243 -0
- package/apps/studio/sync/untrusted.ts +30 -10
- package/apps/studio/test/_helpers.ts +8 -0
- package/apps/studio/test/canvas-build.test.ts +63 -0
- package/apps/studio/test/canvas-list-watch.test.ts +17 -0
- package/apps/studio/test/canvas-move-api.test.ts +31 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
- package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
- package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
- package/apps/studio/test/cloud-history-posture.test.ts +230 -0
- package/apps/studio/test/cloud-session-role.test.ts +30 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
- package/apps/studio/test/cold-start-apply.test.ts +303 -0
- package/apps/studio/test/collab-stress.test.ts +9 -1
- package/apps/studio/test/export-lane.test.ts +245 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
- package/apps/studio/test/git-log-format.test.ts +95 -0
- package/apps/studio/test/git-safe-rel.test.ts +132 -0
- package/apps/studio/test/hmr-broadcast.test.ts +26 -0
- package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
- package/apps/studio/test/sync-agent.test.ts +78 -0
- package/apps/studio/test/sync-asset-push.test.ts +71 -108
- package/apps/studio/test/sync-autocommit.test.ts +80 -0
- package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
- package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
- package/apps/studio/test/sync-decide-file.test.ts +420 -0
- package/apps/studio/test/sync-file-ledger.test.ts +334 -0
- package/apps/studio/test/sync-file-membership.test.ts +17 -1
- package/apps/studio/test/sync-file-plane.test.ts +976 -0
- package/apps/studio/test/sync-hub-listing.test.ts +46 -0
- package/apps/studio/test/sync-meta-codec.test.ts +76 -0
- package/apps/studio/test/sync-move-retirement.test.ts +231 -0
- package/apps/studio/test/sync-panel-surface.test.ts +20 -0
- package/apps/studio/test/sync-path-pull.test.ts +67 -1
- package/apps/studio/test/sync-pull-budget.test.ts +169 -0
- package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
- package/apps/studio/test/sync-settings-routes.test.ts +195 -0
- package/apps/studio/test/sync-settings.test.ts +151 -0
- package/apps/studio/test/sync-status.test.ts +69 -0
- package/apps/studio/test/sync-trash.test.ts +132 -0
- package/apps/studio/test/workspace-containment.test.ts +45 -9
- package/apps/studio/tsconfig.json +9 -10
- package/apps/studio/use-annotation-resize.tsx +14 -3
- package/apps/studio/use-collab.tsx +3 -1
- package/apps/studio/whats-new.json +90 -0
- package/apps/studio/workspace-mode.ts +110 -62
- package/apps/studio/ws.ts +22 -1
- package/cli/bin/claude-design-server.mjs +19 -0
- package/cli/commands/design.mjs +25 -6
- package/cli/commands/hub-workspace.mjs +243 -22
- package/cli/commands/hub-workspace.test.mjs +171 -0
- package/cli/commands/hub.mjs +71 -1
- package/cli/lib/design-link.mjs +186 -1
- package/cli/lib/design-ownership.mjs +330 -0
- package/cli/lib/design-ownership.test.mjs +329 -0
- package/cli/lib/hubs-config.mjs +21 -0
- package/cli/lib/hubs-config.test.mjs +47 -1
- package/cli/lib/workspace-plan.mjs +298 -5
- package/cli/lib/workspace-plan.test.mjs +215 -1
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +43 -2
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
- package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
- package/apps/studio/server.mjs +0 -1312
- package/apps/studio/sync/asset-pull.ts +0 -210
- package/apps/studio/sync/asset-push-worker.ts +0 -84
- package/apps/studio/sync/asset-sweep.ts +0 -262
- package/apps/studio/test/sync-asset-pull.test.ts +0 -161
- package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
- package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The journal read client — Sync v2 Increment 2 (DDR-226 §5).
|
|
2
|
+
//
|
|
3
|
+
// One place that knows how to ask a hub "what changed since <seq>", and one
|
|
4
|
+
// place that knows how to ask "do you even have a journal". Increment 2 uses
|
|
5
|
+
// both for the cell child's heal path; Increment 3's pull engine uses the same
|
|
6
|
+
// two functions rather than growing its own.
|
|
7
|
+
//
|
|
8
|
+
// EVERYTHING HERE IS UNTRUSTED INPUT. The hub is semi-trusted by design
|
|
9
|
+
// (DDR-054): it may read and write synced state, and everything it says about
|
|
10
|
+
// that state is a hint. So this module's job is not to fetch — it is to REFUSE.
|
|
11
|
+
// A row whose shape is wrong is dropped; a `reanchor` is honoured rather than
|
|
12
|
+
// argued with; a page that cannot be parsed is `null`, which every caller reads
|
|
13
|
+
// as "ask again later", never as "nothing changed".
|
|
14
|
+
//
|
|
15
|
+
// FAIL CLOSED ON THE CURSOR. `{ reanchor: true }` is not an error and not an
|
|
16
|
+
// empty page — it is the hub saying "your cursor is not in this log". Reading
|
|
17
|
+
// it as "no changes" is exactly the shape that lets a stale peer believe it is
|
|
18
|
+
// current, which is the failure DDR-214's ordering amendment exists to prevent.
|
|
19
|
+
|
|
20
|
+
/** How long to wait for a journal page. Same figure as the manifest fetch. */
|
|
21
|
+
const JOURNAL_TIMEOUT_MS = 6000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The hub's own published page ceiling, restated here.
|
|
25
|
+
*
|
|
26
|
+
* A receiver that trusts the sender to respect the sender's cap has no cap.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_JOURNAL_PAGE = 2000;
|
|
29
|
+
|
|
30
|
+
/** An epoch is a UUID-ish token; anything longer is not one. */
|
|
31
|
+
const MAX_EPOCH_LEN = 128;
|
|
32
|
+
|
|
33
|
+
/** How long to wait for `/health`. A capability probe must never hang a boot. */
|
|
34
|
+
const HEALTH_TIMEOUT_MS = 4000;
|
|
35
|
+
|
|
36
|
+
/** The shape a peer may act on. Anything else is dropped at parse time. */
|
|
37
|
+
export interface JournalEntry {
|
|
38
|
+
seq: number;
|
|
39
|
+
path: string;
|
|
40
|
+
sha256: string | null;
|
|
41
|
+
size: number | null;
|
|
42
|
+
/** DISPLAY ONLY. Never an overwrite authority anywhere (F4). */
|
|
43
|
+
mtimeMs: number | null;
|
|
44
|
+
/** The hub's claimed class — a reporting hint; receivers re-classify. */
|
|
45
|
+
class: string;
|
|
46
|
+
deleted: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface JournalPage {
|
|
50
|
+
epoch: string | null;
|
|
51
|
+
head: number;
|
|
52
|
+
entries: JournalEntry[];
|
|
53
|
+
truncated: boolean;
|
|
54
|
+
/** The cursor is not in this log — re-anchor against a full compaction. */
|
|
55
|
+
reanchor: boolean;
|
|
56
|
+
reason?: string;
|
|
57
|
+
/** The hub sent more than its own published ceiling; the rest was dropped. */
|
|
58
|
+
overflowed?: true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A designRoot-relative path shape a peer will turn into a real file. */
|
|
62
|
+
const ENTRY_PATH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,255}$/;
|
|
63
|
+
|
|
64
|
+
function parseEntry(raw: unknown): JournalEntry | null {
|
|
65
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
66
|
+
const e = raw as Record<string, unknown>;
|
|
67
|
+
const seq = e.seq;
|
|
68
|
+
const p = e.path;
|
|
69
|
+
if (typeof seq !== 'number' || !Number.isInteger(seq) || seq <= 0) return null;
|
|
70
|
+
if (typeof p !== 'string' || !ENTRY_PATH_RE.test(p) || p.split('/').includes('..')) return null;
|
|
71
|
+
const sha = typeof e.sha256 === 'string' && /^[0-9a-f]{64}$/.test(e.sha256) ? e.sha256 : null;
|
|
72
|
+
return {
|
|
73
|
+
seq,
|
|
74
|
+
path: p,
|
|
75
|
+
sha256: sha,
|
|
76
|
+
size: typeof e.size === 'number' && Number.isFinite(e.size) ? e.size : null,
|
|
77
|
+
mtimeMs: typeof e.mtimeMs === 'number' && Number.isFinite(e.mtimeMs) ? e.mtimeMs : null,
|
|
78
|
+
class: typeof e.class === 'string' ? e.class.slice(0, 32) : '',
|
|
79
|
+
deleted: e.deleted === true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Fetch one journal page.
|
|
85
|
+
*
|
|
86
|
+
* Returns null when the hub is unreachable, refuses, or does not have the
|
|
87
|
+
* route — the `fetchRemoteListing` posture: sync continues either way and we
|
|
88
|
+
* ask again later. A null is never "nothing changed".
|
|
89
|
+
*/
|
|
90
|
+
export async function fetchJournal(opts: {
|
|
91
|
+
hubUrl: string;
|
|
92
|
+
token: string;
|
|
93
|
+
since?: number;
|
|
94
|
+
/** Send the epoch a cursor belongs to so a mismatch fails closed hub-side. */
|
|
95
|
+
epoch?: string | null;
|
|
96
|
+
fetchImpl?: typeof fetch;
|
|
97
|
+
}): Promise<JournalPage | null> {
|
|
98
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
99
|
+
const base = opts.hubUrl.replace(/\/+$/, '');
|
|
100
|
+
const params = new URLSearchParams();
|
|
101
|
+
params.set('since', String(Math.max(0, Math.trunc(opts.since ?? 0))));
|
|
102
|
+
if (opts.epoch) params.set('epoch', opts.epoch);
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetchImpl(`${base}/api/journal?${params}`, {
|
|
105
|
+
headers: { authorization: `Bearer ${opts.token}` },
|
|
106
|
+
signal: AbortSignal.timeout(JOURNAL_TIMEOUT_MS),
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok) return null;
|
|
109
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
110
|
+
// The head and the epoch are the anchor the whole cursor protocol rests
|
|
111
|
+
// on, and both arrive from a component DDR-054 calls untrusted. A
|
|
112
|
+
// fractional or negative head, or an unbounded epoch string, gets
|
|
113
|
+
// persisted into the ledger verbatim otherwise.
|
|
114
|
+
const rawHead = body?.head;
|
|
115
|
+
const head =
|
|
116
|
+
typeof rawHead === 'number' && Number.isInteger(rawHead) && rawHead >= 0 ? rawHead : 0;
|
|
117
|
+
const epoch =
|
|
118
|
+
typeof body?.epoch === 'string' && body.epoch.length > 0 && body.epoch.length <= MAX_EPOCH_LEN
|
|
119
|
+
? body.epoch
|
|
120
|
+
: null;
|
|
121
|
+
if (body?.reanchor === true) {
|
|
122
|
+
return {
|
|
123
|
+
epoch,
|
|
124
|
+
head,
|
|
125
|
+
entries: [],
|
|
126
|
+
truncated: false,
|
|
127
|
+
reanchor: true,
|
|
128
|
+
...(typeof body.reason === 'string' ? { reason: body.reason.slice(0, 120) } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// CAP THE PAGE at the hub's own published ceiling. An honest hub never
|
|
132
|
+
// exceeds it; a hostile one ignores its own cap, and every entry past this
|
|
133
|
+
// point becomes a ledger row on disk, a key in `_sync.json`, and a member
|
|
134
|
+
// of the union every future pass re-walks. One response should not be able
|
|
135
|
+
// to grow this machine's state without bound.
|
|
136
|
+
const rawEntries = Array.isArray(body?.entries) ? body.entries : [];
|
|
137
|
+
const overflowed = rawEntries.length > MAX_JOURNAL_PAGE;
|
|
138
|
+
const entries: JournalEntry[] = [];
|
|
139
|
+
for (const raw of rawEntries.slice(0, MAX_JOURNAL_PAGE)) {
|
|
140
|
+
const parsed = parseEntry(raw);
|
|
141
|
+
if (parsed !== null) entries.push(parsed);
|
|
142
|
+
}
|
|
143
|
+
return {
|
|
144
|
+
epoch,
|
|
145
|
+
head,
|
|
146
|
+
entries,
|
|
147
|
+
// An over-long page is treated as truncated, which is already the signal
|
|
148
|
+
// meaning "there is more; come back" — so the pass converges instead of
|
|
149
|
+
// silently believing it saw everything.
|
|
150
|
+
truncated: body?.truncated === true || overflowed,
|
|
151
|
+
reanchor: false,
|
|
152
|
+
...(overflowed ? { overflowed: true } : {}),
|
|
153
|
+
};
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What protocol features a hub advertises — the compat matrix's gate
|
|
161
|
+
* (DDR-226 §10, BINDING).
|
|
162
|
+
*
|
|
163
|
+
* Returns null when `/health` is unreachable or unparseable. A caller must
|
|
164
|
+
* treat BOTH null and a set without `ledger` as "this hub has no journal":
|
|
165
|
+
* attach no control channel, relax no polling, keep every legacy lane. The
|
|
166
|
+
* distinction between "an old hub that omits the field" and "a new hub with no
|
|
167
|
+
* checkout" matters for diagnostics, not for behaviour.
|
|
168
|
+
*/
|
|
169
|
+
export async function hubCapabilities(opts: {
|
|
170
|
+
hubUrl: string;
|
|
171
|
+
fetchImpl?: typeof fetch;
|
|
172
|
+
/**
|
|
173
|
+
* Cancel the probe when the caller goes away.
|
|
174
|
+
*
|
|
175
|
+
* A boot-time probe that outlives its runtime is a timer holding a dying
|
|
176
|
+
* process open and a promise resolving into torn-down state. The caller
|
|
177
|
+
* aborts this in `stop()`; without it the 4 s timeout alone decides.
|
|
178
|
+
*/
|
|
179
|
+
signal?: AbortSignal;
|
|
180
|
+
}): Promise<string[] | null> {
|
|
181
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
182
|
+
const base = opts.hubUrl.replace(/\/+$/, '');
|
|
183
|
+
try {
|
|
184
|
+
const timeout = AbortSignal.timeout(HEALTH_TIMEOUT_MS);
|
|
185
|
+
const res = await fetchImpl(`${base}/health`, {
|
|
186
|
+
signal: opts.signal ? AbortSignal.any([opts.signal, timeout]) : timeout,
|
|
187
|
+
});
|
|
188
|
+
if (!res.ok) return null;
|
|
189
|
+
const body = (await res.json()) as { capabilities?: unknown };
|
|
190
|
+
if (!Array.isArray(body?.capabilities)) return null;
|
|
191
|
+
return body.capabilities.filter((c): c is string => typeof c === 'string').slice(0, 32);
|
|
192
|
+
} catch {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Does this hub carry the journal file plane? */
|
|
198
|
+
export function hasLedger(capabilities: string[] | null): boolean {
|
|
199
|
+
return Array.isArray(capabilities) && capabilities.includes('ledger');
|
|
200
|
+
}
|
|
@@ -48,7 +48,9 @@ import {
|
|
|
48
48
|
stampBodyEdit,
|
|
49
49
|
Y_SYNC_TYPES,
|
|
50
50
|
} from './codec.ts';
|
|
51
|
+
import type { ColdStartAction } from './cold-start.ts';
|
|
51
52
|
import { decideAnnotationsColdStart, decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
53
|
+
import { applyColdStart } from './cold-start-apply.ts';
|
|
52
54
|
import { hashBytes } from './echo-guard.ts';
|
|
53
55
|
import type { SyncJournal } from './journal.ts';
|
|
54
56
|
import { ORIGINS } from './origins.ts';
|
|
@@ -73,6 +75,23 @@ export interface MigrateSeedOptions {
|
|
|
73
75
|
journal?: SyncJournal;
|
|
74
76
|
/** DDR-102 — body snapshot writer (history.ts), same contract as the agent's. */
|
|
75
77
|
snapshot?: (content: string, reason: 'pre-sync-local' | 'pre-sync-hub') => Promise<string | null>;
|
|
78
|
+
/**
|
|
79
|
+
* Does the HUB hold state for this slug, per its last document listing?
|
|
80
|
+
*
|
|
81
|
+
* `docIsEmpty` asks the local replica, and an empty replica has two
|
|
82
|
+
* completely different meanings: "the hub has never seen this canvas" (adopt
|
|
83
|
+
* — the local file is the only copy) and "the hub's state has not landed in
|
|
84
|
+
* this replica YET" (do nothing — it is on its way). Adopting in the second
|
|
85
|
+
* case is the DDR-102 F1 concurrent cold-seed collision: both peers
|
|
86
|
+
* clear-and-rebuild their own replica from a file with the same bytes, and
|
|
87
|
+
* because the two runs carry different client ids the CRDT merge
|
|
88
|
+
* CONCATENATES them — the canvas ends up with its body twice on every peer.
|
|
89
|
+
*
|
|
90
|
+
* The listing is the only enumeration a peer has, so it is also the only way
|
|
91
|
+
* to tell the two cases apart before writing. Absent ⇒ treated as "the hub
|
|
92
|
+
* does not have it", which is the pre-existing behaviour.
|
|
93
|
+
*/
|
|
94
|
+
hubHasState?: (slug: string) => boolean;
|
|
76
95
|
/** DDR-102 — divergence notification, same contract as the agent's. */
|
|
77
96
|
onConflict?: (info: {
|
|
78
97
|
slug: string;
|
|
@@ -90,9 +109,19 @@ export type MigrateSeedResult =
|
|
|
90
109
|
| 'empty'
|
|
91
110
|
/** DDR-102 — doc held state but no body; local body seeded up in-place. */
|
|
92
111
|
| 'body-seed-up'
|
|
112
|
+
/** DDR-102 F1 — the hub body was our local body repeated N≥2 times (a
|
|
113
|
+
* concurrent cold-seed collision); local was re-applied so the codec's diff
|
|
114
|
+
* deletes the duplicate. Before Increment 0 this decision had NO case in
|
|
115
|
+
* this module's switch and fell through to `hub-wins`, keeping (and
|
|
116
|
+
* materializing) the doubled body. */
|
|
117
|
+
| 'recover-seed-dup'
|
|
93
118
|
/** DDR-102 — divergence resolved newest-wins. */
|
|
94
119
|
| 'conflict-local-wins'
|
|
95
|
-
| 'conflict-hub-wins'
|
|
120
|
+
| 'conflict-hub-wins'
|
|
121
|
+
/** The replica is empty but the HUB is not — its state is still in flight.
|
|
122
|
+
* Seeding here is the F1 collision (see `hubHasState`), so this seed does
|
|
123
|
+
* nothing and lets the document arrive. */
|
|
124
|
+
| 'defer-hub-state';
|
|
96
125
|
|
|
97
126
|
/** True when the shared doc holds no synced content for any of the five types. */
|
|
98
127
|
export function docIsEmpty(doc: Y.Doc): boolean {
|
|
@@ -122,6 +151,26 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
122
151
|
// a single MIGRATION transaction. The apply* codecs delete-then-insert, so
|
|
123
152
|
// this is a clear+rebuild (re-running is a no-op once content matches).
|
|
124
153
|
if (docIsEmpty(doc)) {
|
|
154
|
+
// AN EMPTY REPLICA IS NOT AN EMPTY HUB. See `hubHasState` — on a cell the
|
|
155
|
+
// hub's workspace agent writes every document onto the checkout, so the
|
|
156
|
+
// studio child scans up a file whose document the hub already owns. Seeding
|
|
157
|
+
// from it doubles the body on every peer.
|
|
158
|
+
if (opts.hubHasState?.(slug)) {
|
|
159
|
+
// SNAPSHOT BEFORE STANDING ASIDE. Deferring means the local body never
|
|
160
|
+
// enters the doc — so when the hub's state does arrive, the projection
|
|
161
|
+
// writes it over this file with no `pre-sync-local` copy behind it and no
|
|
162
|
+
// conflict recorded, because both live in the non-empty branch this
|
|
163
|
+
// return skips. The adopt path it replaced could not lose the local body:
|
|
164
|
+
// it was inside the merge. One snapshot buys back the recoverability.
|
|
165
|
+
if (localHtml && opts.snapshot) {
|
|
166
|
+
try {
|
|
167
|
+
await opts.snapshot(localHtml, 'pre-sync-local');
|
|
168
|
+
} catch {
|
|
169
|
+
/* best-effort — history is a safety net, never a gate on syncing */
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return 'defer-hub-state';
|
|
173
|
+
}
|
|
125
174
|
const hasLocal =
|
|
126
175
|
!!localHtml || !!localComments || !!localAnnotations || !!localMeta || !!localCss;
|
|
127
176
|
if (!hasLocal) return 'empty';
|
|
@@ -175,8 +224,6 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
175
224
|
docBodyEditAtMs: bodyEditAtFromDoc(doc),
|
|
176
225
|
});
|
|
177
226
|
|
|
178
|
-
let result: MigrateSeedResult = 'hub-wins';
|
|
179
|
-
|
|
180
227
|
/** Rebuild body (+ visually-coupled css) from local, in ONE MIGRATION
|
|
181
228
|
* transaction — one source per type, never a two-history merge.
|
|
182
229
|
* Annotations are deliberately NOT here any more: they resolve per-lane
|
|
@@ -194,69 +241,29 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
194
241
|
});
|
|
195
242
|
};
|
|
196
243
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
try {
|
|
221
|
-
const localTs = await opts.snapshot(localHtml as string, 'pre-sync-local');
|
|
222
|
-
if (localTs) snapshots.local = localTs;
|
|
223
|
-
const hubTs = await opts.snapshot(docHtml, 'pre-sync-hub');
|
|
224
|
-
if (hubTs) snapshots.hub = hubTs;
|
|
225
|
-
} catch {
|
|
226
|
-
/* swallowed below — the missing snapshot ref drives the fail-closed guard */
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
// DDR-102 fail-closed (security F1): a hub-wins resolution keeps the doc
|
|
230
|
-
// (hub) state, which projection.reconcile() then materializes to disk —
|
|
231
|
-
// OVERWRITING local. If the local snapshot didn't land, rebuild the body
|
|
232
|
-
// from local instead so the projection writes local back (a no-op on
|
|
233
|
-
// disk) and nothing is lost. Gated on `snapshotAttempted` so a
|
|
234
|
-
// snapshot-less standalone call keeps plain newest-wins.
|
|
235
|
-
const localSnapshotMissing = snapshotAttempted && !snapshots.local;
|
|
236
|
-
let winner = decision.winner;
|
|
237
|
-
if (winner === 'hub' && localSnapshotMissing) {
|
|
238
|
-
winner = 'local';
|
|
239
|
-
console.error(
|
|
240
|
-
`[sync/${slug}] shared-doc cold-start divergence: hub won newest-wins but the local snapshot FAILED — REFUSING to overwrite local (DDR-102 fail-closed). Rebuilding the doc from local; resolve the _history/ write failure to restore newest-wins.`
|
|
241
|
-
);
|
|
242
|
-
}
|
|
243
|
-
if (winner === 'local') {
|
|
244
|
-
rebuildBodyFromLocal();
|
|
245
|
-
result = 'conflict-local-wins';
|
|
246
|
-
} else {
|
|
247
|
-
result = 'conflict-hub-wins';
|
|
248
|
-
}
|
|
249
|
-
console.warn(`[sync/${slug}] shared-doc cold-start divergence — ${decision.reason}`);
|
|
250
|
-
opts.onConflict?.({
|
|
251
|
-
slug,
|
|
252
|
-
kind: 'cold-start-diverged',
|
|
253
|
-
winner,
|
|
254
|
-
...(snapshots.local || snapshots.hub ? { snapshots } : {}),
|
|
255
|
-
...(localSnapshotMissing ? { snapshotFailed: true } : {}),
|
|
256
|
-
});
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
244
|
+
// ONE application body, shared with agent.ts reconcile (DDR-226 Increment 0).
|
|
245
|
+
// `takeHub` is a genuine no-op here: under shared-doc the doc KEEPS its state
|
|
246
|
+
// and `projection.reconcile()` materializes it to disk (recording the journal
|
|
247
|
+
// checkpoint on its own write). Everything else — the dual snapshot, the
|
|
248
|
+
// fail-closed refusal, the conflict report, and crucially the
|
|
249
|
+
// `recover-seed-dup` row this switch used to be missing — now lives in
|
|
250
|
+
// exactly one place.
|
|
251
|
+
const applied = await applyColdStart({
|
|
252
|
+
slug,
|
|
253
|
+
decision,
|
|
254
|
+
localBody: localHtml,
|
|
255
|
+
docBody: docHtml,
|
|
256
|
+
takeHub: () => {
|
|
257
|
+
/* the projection materializes the doc; nothing to do here */
|
|
258
|
+
},
|
|
259
|
+
takeLocal: rebuildBodyFromLocal,
|
|
260
|
+
checkpointIdentity: (body) => opts.journal?.record(slug, { bodyHash: hashBytes(body) }),
|
|
261
|
+
logLabel: 'shared-doc',
|
|
262
|
+
...(opts.snapshot ? { snapshot: opts.snapshot } : {}),
|
|
263
|
+
...(opts.onConflict ? { onConflict: opts.onConflict } : {}),
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const result: MigrateSeedResult = resultFor(applied.action, applied.bodyWinner);
|
|
260
267
|
|
|
261
268
|
// ---- annotations: PER-LANE newest-wins (the 2026-08-14 eraser fix; the
|
|
262
269
|
// same table as agent.ts reconcile). Under sharedDoc the collab room's
|
|
@@ -273,7 +280,7 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
273
280
|
isEmpty: isEmptyAnnotationsSvg,
|
|
274
281
|
localMtimeMs: localMtimeMs(paths.annotations),
|
|
275
282
|
docEditAtMs: annotationsEditAtFromDoc(doc),
|
|
276
|
-
bodyWinner:
|
|
283
|
+
bodyWinner: applied.bodyWinner,
|
|
277
284
|
});
|
|
278
285
|
if (annDecision.winner === 'local' && localAnnotations !== null) {
|
|
279
286
|
console.warn(`[sync/${slug}] shared-doc cold-start annotations: ${annDecision.reason}`);
|
|
@@ -312,6 +319,31 @@ export async function migrateSeed(opts: MigrateSeedOptions): Promise<MigrateSeed
|
|
|
312
319
|
|
|
313
320
|
/* ---------------------------------------------------------------- helpers */
|
|
314
321
|
|
|
322
|
+
/**
|
|
323
|
+
* Map the shared applier's verdict onto this module's public result string.
|
|
324
|
+
* Total over `ColdStartAction`, so a new row in the table surfaces here as a
|
|
325
|
+
* type error instead of silently reading as `hub-wins` (the exact shape of the
|
|
326
|
+
* `recover-seed-dup` bug this refactor fixed).
|
|
327
|
+
*/
|
|
328
|
+
function resultFor(action: ColdStartAction, bodyWinner: 'local' | 'hub'): MigrateSeedResult {
|
|
329
|
+
switch (action) {
|
|
330
|
+
case 'noop':
|
|
331
|
+
case 'materialize-hub':
|
|
332
|
+
case 'fast-forward-hub':
|
|
333
|
+
return 'hub-wins';
|
|
334
|
+
case 'seed-local-up':
|
|
335
|
+
return 'body-seed-up';
|
|
336
|
+
case 'recover-seed-dup':
|
|
337
|
+
return 'recover-seed-dup';
|
|
338
|
+
case 'conflict':
|
|
339
|
+
return bodyWinner === 'local' ? 'conflict-local-wins' : 'conflict-hub-wins';
|
|
340
|
+
default: {
|
|
341
|
+
const never: never = action;
|
|
342
|
+
throw new Error(`migrate-seed: unhandled cold-start action ${String(never)}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
315
347
|
function snapshotLocal(opts: MigrateSeedOptions): void {
|
|
316
348
|
if (!opts.historyDir) return;
|
|
317
349
|
try {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// The poke frame's vocabulary, receiver side — Sync v2 Increment 2
|
|
2
|
+
// (DDR-226 §4).
|
|
3
|
+
//
|
|
4
|
+
// A DELIBERATE TWIN of `apps/hub/src/files-ctl.mjs`'s `parsePoke`, for the same
|
|
5
|
+
// reason `file-membership.ts` has a `.mjs` mirror: the hub image installs from
|
|
6
|
+
// its own frozen lockfile and cannot import out of `apps/studio`, so the two
|
|
7
|
+
// ends of a wire format live in two files. DDR-198's rule applies — a twin is
|
|
8
|
+
// allowed only with a drift test that imports BOTH and asserts they agree. That
|
|
9
|
+
// test is `apps/studio/test/sync-poke-parity.test.ts`; if you change the shape
|
|
10
|
+
// here, change it there, and the test is what makes forgetting a red build.
|
|
11
|
+
//
|
|
12
|
+
// WHY THE FRAME IS THIS SMALL. Everything on this channel arrives from the hub,
|
|
13
|
+
// which is untrusted to peers (DDR-054). A frame that carried a path would be a
|
|
14
|
+
// path the hub chose; a frame that carried a hash would be a hash the hub chose.
|
|
15
|
+
// So it carries neither. `head` is a HINT that something moved — the receiver
|
|
16
|
+
// then asks through an authenticated, scope-filtered route and believes the
|
|
17
|
+
// answer, not the doorbell.
|
|
18
|
+
|
|
19
|
+
/** Max frame size. A doorbell has no reason to be long. */
|
|
20
|
+
const MAX_POKE_BYTES = 512;
|
|
21
|
+
|
|
22
|
+
export interface Poke {
|
|
23
|
+
/** The hub's journal head at emit time. A hint, never an authority. */
|
|
24
|
+
head: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse a control frame. Returns null for anything that is not exactly
|
|
29
|
+
* `{ t: 'files', head: <non-negative integer> }`.
|
|
30
|
+
*
|
|
31
|
+
* Extra properties are DROPPED rather than refused: a future hub adding a field
|
|
32
|
+
* must not silence the channel for an older peer (the additive-field rule the
|
|
33
|
+
* documents listing already follows). What is never accepted is a missing or
|
|
34
|
+
* malformed `head`, because that is the one value the receiver acts on.
|
|
35
|
+
*/
|
|
36
|
+
export function parsePoke(payload: unknown): Poke | null {
|
|
37
|
+
if (typeof payload !== 'string' || payload.length > MAX_POKE_BYTES) return null;
|
|
38
|
+
let parsed: unknown;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(payload);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
45
|
+
const frame = parsed as { t?: unknown; head?: unknown };
|
|
46
|
+
if (frame.t !== 'files') return null;
|
|
47
|
+
const head = frame.head;
|
|
48
|
+
if (typeof head !== 'number' || !Number.isInteger(head) || head < 0) return null;
|
|
49
|
+
return { head };
|
|
50
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
htmlFromDoc,
|
|
40
40
|
mergeSharedMetaIntoLocal,
|
|
41
41
|
metaFromDoc,
|
|
42
|
+
movedToFromDoc,
|
|
42
43
|
stampAnnotationsEdit,
|
|
43
44
|
stampBodyEdit,
|
|
44
45
|
} from './codec.ts';
|
|
@@ -259,6 +260,13 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
259
260
|
|
|
260
261
|
async function flush(): Promise<void> {
|
|
261
262
|
if (!dirty || stopped) return;
|
|
263
|
+
// A RETIRED document is write-inert — its canvas moved to a new path in a
|
|
264
|
+
// new document, and materialising this one is how a moved canvas
|
|
265
|
+
// resurrected itself at its old path (see codec stampMovedTo).
|
|
266
|
+
if (movedToFromDoc(doc) !== null) {
|
|
267
|
+
dirty = false;
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
262
270
|
dirty = false;
|
|
263
271
|
if (flushTimer) {
|
|
264
272
|
clearTimeout(flushTimer);
|
|
@@ -301,6 +309,9 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
301
309
|
|
|
302
310
|
function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
|
|
303
311
|
if (stopped) return false;
|
|
312
|
+
// Write-inert both ways — a local edit to a stale pre-move file must not
|
|
313
|
+
// revive the retired document (see codec stampMovedTo).
|
|
314
|
+
if (movedToFromDoc(doc) !== null) return false;
|
|
304
315
|
// Echo of our own doc→file write — drop.
|
|
305
316
|
if (opts.echoGuard?.consume(evt.path, evt.hash)) return false;
|
|
306
317
|
// Circuit breaker — a file that won't parse can't spin the loop.
|
|
@@ -370,6 +381,8 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
370
381
|
|
|
371
382
|
function reconcile(): void {
|
|
372
383
|
if (stopped) return;
|
|
384
|
+
// A retired doc materialises NOTHING (see codec stampMovedTo).
|
|
385
|
+
if (movedToFromDoc(doc) !== null) return;
|
|
373
386
|
// Materialize the converged doc to disk (html/css/meta). The *IfChanged
|
|
374
387
|
// writers already guard against clobbering non-empty local with empty doc
|
|
375
388
|
// values, so this is safe to run at cold start before the authoritative
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// F6 — the aggregate byte budget on every downward lane (Sync v2 Increment 0,
|
|
2
|
+
// DDR-226 §9; the gap named in the Plane-B flip gate).
|
|
3
|
+
//
|
|
4
|
+
// Every pull lane already caps ONE file (`MAX_PULL_BYTES`) and the COUNT of
|
|
5
|
+
// files per pass (`MAX_PULLS_PER_PASS` / `MAX_FILES_PER_PASS`). Nothing capped
|
|
6
|
+
// the product. 200 files × 512 MB is 100 GB, and the hub decides both factors:
|
|
7
|
+
// a hostile or merely broken hub could fill a person's disk one "legitimate"
|
|
8
|
+
// pass at a time, and every individual transfer would pass every check.
|
|
9
|
+
//
|
|
10
|
+
// So the lanes share ONE budget object per pass. It is deliberately tiny and
|
|
11
|
+
// leaf (no imports): a counter, a ceiling, and a single loud line when it is
|
|
12
|
+
// reached — the "loud-cap convention" every other truncation in this codebase
|
|
13
|
+
// follows, because a silent stop reads as "sync is broken" with no cause.
|
|
14
|
+
//
|
|
15
|
+
// The per-pass ceiling is not a quota: the remainder is simply the next pass's
|
|
16
|
+
// work, exactly like the count caps. The CUMULATIVE per-hub accumulation quota
|
|
17
|
+
// (a real quota, which refuses rather than defers) is Increment 4's job.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How many bytes one downward pass may land, across all files in that pass.
|
|
21
|
+
*
|
|
22
|
+
* 2 GiB is far above any real project's per-poll delta (a converged project
|
|
23
|
+
* transfers zero) and far below "fills the disk while you are at lunch". A
|
|
24
|
+
* fresh link of a large project takes several passes — which is already how
|
|
25
|
+
* the count caps behave.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_PULL_BYTES_PER_PASS = 2 * 1024 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
export interface PullBudget {
|
|
30
|
+
/**
|
|
31
|
+
* Charge `bytes` against the budget.
|
|
32
|
+
*
|
|
33
|
+
* Returns false when this transfer would exceed the ceiling — the caller
|
|
34
|
+
* must NOT land it and should stop the pass (`exhausted()` is then true).
|
|
35
|
+
* A charge that fits is committed, so `take` is not a pure predicate.
|
|
36
|
+
*/
|
|
37
|
+
take: (bytes: number) => boolean;
|
|
38
|
+
/** True once a `take` has been refused. */
|
|
39
|
+
exhausted: () => boolean;
|
|
40
|
+
/** Bytes committed so far this pass. */
|
|
41
|
+
spent: () => number;
|
|
42
|
+
/** Bytes still available. */
|
|
43
|
+
remaining: () => number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PullBudgetOptions {
|
|
47
|
+
/** Ceiling for this pass. Defaults to `MAX_PULL_BYTES_PER_PASS`. */
|
|
48
|
+
maxBytes?: number;
|
|
49
|
+
/** Log-line prefix — `sync/assets`, `sync/files`, … so the refusal names
|
|
50
|
+
* which lane hit the wall. */
|
|
51
|
+
label: string;
|
|
52
|
+
log?: Pick<Console, 'warn'>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One budget per pass. Never throws; refusing is a boolean, and the caller
|
|
57
|
+
* reports it the way it reports every other cap.
|
|
58
|
+
*/
|
|
59
|
+
export function createPullBudget(opts: PullBudgetOptions): PullBudget {
|
|
60
|
+
const max = opts.maxBytes ?? MAX_PULL_BYTES_PER_PASS;
|
|
61
|
+
const log = opts.log ?? console;
|
|
62
|
+
let spent = 0;
|
|
63
|
+
let hitTheWall = false;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
take(bytes: number): boolean {
|
|
67
|
+
// A negative/NaN size is a hub-supplied number: treat it as zero-cost
|
|
68
|
+
// rather than as a credit (it can never REFUND budget).
|
|
69
|
+
const cost = Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
|
|
70
|
+
if (spent + cost > max) {
|
|
71
|
+
if (!hitTheWall) {
|
|
72
|
+
hitTheWall = true;
|
|
73
|
+
log.warn(
|
|
74
|
+
`[${opts.label}] pass byte budget reached — ${spent} B landed, refusing a further ${cost} B (ceiling ${max} B, DDR-226 F6). The rest is the next pass's work.`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
spent += cost;
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
exhausted: () => hitTheWall,
|
|
83
|
+
spent: () => spent,
|
|
84
|
+
remaining: () => Math.max(0, max - spent),
|
|
85
|
+
};
|
|
86
|
+
}
|