@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,581 @@
|
|
|
1
|
+
// The desktop file ledger — Sync v2 Increment 3 (DDR-226 §3).
|
|
2
|
+
//
|
|
3
|
+
// Three jobs, one file:
|
|
4
|
+
//
|
|
5
|
+
// 1. **The ancestor store.** Per path, the hash this machine last reconciled
|
|
6
|
+
// through. It is what turns "the two sides differ" from a coin flip into
|
|
7
|
+
// a fact (see `decide-file.ts`).
|
|
8
|
+
// 2. **The stat cache.** `(size, mtimeMs) → hash`, so a boot reconcile hashes
|
|
9
|
+
// only files that actually moved. On a converged project the steady-state
|
|
10
|
+
// cost is a stat per file and no reads at all.
|
|
11
|
+
// 3. **The doručenka.** Per-path delivery state, so "where is file X" is a
|
|
12
|
+
// lookup instead of archaeology across four logs (DDR-214's deferred
|
|
13
|
+
// ledger, now due).
|
|
14
|
+
//
|
|
15
|
+
// It lives at `<designRoot>/_state/file-ledger/<hubId>.json`. `_state/` is
|
|
16
|
+
// already IGNORED in all four DDR-115 lists, so this adds no taxonomy churn and
|
|
17
|
+
// no `_*` path anybody has to remember to add anywhere.
|
|
18
|
+
//
|
|
19
|
+
// **Deleting it is always safe.** It holds no content — only hashes of content
|
|
20
|
+
// that exists elsewhere. Losing it forces a re-anchor: every path looks
|
|
21
|
+
// first-seen, differences become conflict-copies rather than overwrites, and
|
|
22
|
+
// the project converges noisily instead of losing anything.
|
|
23
|
+
//
|
|
24
|
+
// ── The write-ordering invariant, and why it lives HERE ─────────────────────
|
|
25
|
+
//
|
|
26
|
+
// The one rule that makes crashes survivable:
|
|
27
|
+
//
|
|
28
|
+
// BYTES LAND FIRST. THE ANCESTOR MOVES SECOND.
|
|
29
|
+
//
|
|
30
|
+
// If we crash between the two, the ancestor LAGS: it names bytes older than
|
|
31
|
+
// what is on disk. The next pass then sees local ≠ ancestor and treats it as a
|
|
32
|
+
// local change — a conflict-copy at worst, noise. If the order were reversed
|
|
33
|
+
// the ancestor would LEAD: it would name bytes that never landed, and the next
|
|
34
|
+
// pass would read a real local file as "already reconciled" and let the hub
|
|
35
|
+
// overwrite it. That is the eraser class, and it is the reason the ordering is
|
|
36
|
+
// not left to callers to remember: `adoptAfter` takes the byte-landing as a
|
|
37
|
+
// callback and records the ancestor only on its success. There is no exported
|
|
38
|
+
// way to move an ancestor without landing something first.
|
|
39
|
+
|
|
40
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
41
|
+
import path from 'node:path';
|
|
42
|
+
|
|
43
|
+
import { atomicWrite } from './atomic-write.ts';
|
|
44
|
+
|
|
45
|
+
/** Debounce for persisting the ledger. Same figure as the doc journal. */
|
|
46
|
+
export const LEDGER_FLUSH_MS = 1_000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* How recently a file must have been written for its stat cache to be
|
|
50
|
+
* distrusted. Comfortably above any filesystem's timestamp granularity, and
|
|
51
|
+
* well below "a file somebody stopped editing".
|
|
52
|
+
*/
|
|
53
|
+
const RECENT_WRITE_MS = 3_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where a file has got to, most-severe first.
|
|
57
|
+
*
|
|
58
|
+
* The ORDER is the semantics (DDR-214, applied to files): a refusal outranks a
|
|
59
|
+
* cursor outranks any count. `synced`/`everywhere` is a positive assertion and
|
|
60
|
+
* the pessimistic branch is the default — a state nobody set reads as
|
|
61
|
+
* `local-only`, never as delivered.
|
|
62
|
+
*/
|
|
63
|
+
export type DeliveryState =
|
|
64
|
+
/** Both sides changed it; a copy is parked and a person has to look. */
|
|
65
|
+
| 'conflict'
|
|
66
|
+
/** We tried and could not — the reason says what to fix. */
|
|
67
|
+
| 'stuck'
|
|
68
|
+
/** Something references this asset and NO peer has ever offered it. */
|
|
69
|
+
| 'referenced-but-unoffered'
|
|
70
|
+
/** On this disk, and the hub has not acknowledged it. */
|
|
71
|
+
| 'local-only'
|
|
72
|
+
/** Upload in flight. */
|
|
73
|
+
| 'pushing'
|
|
74
|
+
/** The hub answered 2xx and gave it a seq. */
|
|
75
|
+
| 'on-hub'
|
|
76
|
+
/** The hub mirrored it to object storage. */
|
|
77
|
+
| 'durable'
|
|
78
|
+
/** At least one other peer's cursor has passed it (and did not refuse it). */
|
|
79
|
+
| 'at-peer'
|
|
80
|
+
/** A heal event was emitted for it (honestly: emitted, not render-acked). */
|
|
81
|
+
| 'ui-healed'
|
|
82
|
+
/** Everywhere we know of. */
|
|
83
|
+
| 'everywhere';
|
|
84
|
+
|
|
85
|
+
export interface LedgerRow {
|
|
86
|
+
/** THE ANCESTOR — the hash this machine last reconciled through. */
|
|
87
|
+
syncedHash: string | null;
|
|
88
|
+
/** The journal seq that ancestor corresponds to, when we learned it. */
|
|
89
|
+
remoteSeq?: number;
|
|
90
|
+
/**
|
|
91
|
+
* THE HUB'S SIDE, as last learned — this peer's replica of the hub manifest.
|
|
92
|
+
*
|
|
93
|
+
* Load-bearing, and subtle. A cursor read returns a DELTA: a path absent
|
|
94
|
+
* from the page means "no news about it", NOT "the hub does not have it".
|
|
95
|
+
* Feeding that absence to `decideFile` as `remote: null` would read as
|
|
96
|
+
* "the hub lost this file" and push every converged file back up on every
|
|
97
|
+
* pass — absence treated as authority, which is the exact trap DDR-076
|
|
98
|
+
* exists to close, reintroduced one layer up.
|
|
99
|
+
*
|
|
100
|
+
* So the delta UPDATES this, and only a full read (`since=0`) is allowed to
|
|
101
|
+
* clear it.
|
|
102
|
+
*/
|
|
103
|
+
remoteHash?: string | null;
|
|
104
|
+
/**
|
|
105
|
+
* STAT CACHE — the hash of what was last OBSERVED on disk, valid while
|
|
106
|
+
* `(size, mtimeMs)` still match.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately separate from `syncedHash`. They differ exactly when this
|
|
109
|
+
* machine has an unreconciled local change, and that difference is the
|
|
110
|
+
* signal `decideFile` reads. Folding them into one field would make the
|
|
111
|
+
* cache hand back an ancestor for bytes that have since changed — which is
|
|
112
|
+
* the ancestor-LEADS failure the whole ordering rule exists to prevent.
|
|
113
|
+
*/
|
|
114
|
+
localHash?: string;
|
|
115
|
+
/** Stat cache: the size `localHash` was computed for. */
|
|
116
|
+
size?: number;
|
|
117
|
+
/** Stat cache: the mtime `localHash` was computed for. */
|
|
118
|
+
mtimeMs?: number;
|
|
119
|
+
/** Doručenka. Absent reads as `local-only` — never as delivered. */
|
|
120
|
+
state?: DeliveryState;
|
|
121
|
+
/** One sentence a person can act on. Set with `stuck`/`conflict`. */
|
|
122
|
+
reason?: string;
|
|
123
|
+
/** Where the conflict copy was parked, so the panel can point at it. */
|
|
124
|
+
conflictCopy?: string;
|
|
125
|
+
/**
|
|
126
|
+
* The remote hash whose bytes we have ALREADY parked aside.
|
|
127
|
+
*
|
|
128
|
+
* Epoch-degraded parking is otherwise not idempotent: the decision is
|
|
129
|
+
* `noop` + `parkRemote`, so the ancestor deliberately does not move and the
|
|
130
|
+
* next pass finds the identical state — and the copy name carries a
|
|
131
|
+
* millisecond stamp, so it produces a NEW file every time. A hub answering
|
|
132
|
+
* `reanchor` on every request (or rotating its epoch, which is a legitimate
|
|
133
|
+
* event) therefore fills the disk one conflict copy per diverged path per
|
|
134
|
+
* pass, and each copy is then scanned as `create-up` and uploaded back.
|
|
135
|
+
* Remembering which remote we parked makes the second pass a no-op.
|
|
136
|
+
*/
|
|
137
|
+
parkedRemote?: string;
|
|
138
|
+
pushedAt?: number;
|
|
139
|
+
pulledAt?: number;
|
|
140
|
+
healedAt?: number;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface LedgerFileShape {
|
|
144
|
+
version: 1;
|
|
145
|
+
hubUrl: string | null;
|
|
146
|
+
/** The journal epoch these ancestors were anchored against. */
|
|
147
|
+
epoch: string | null;
|
|
148
|
+
/** How far through the journal this machine has read. */
|
|
149
|
+
cursor: number;
|
|
150
|
+
updatedAt: number;
|
|
151
|
+
rows: Record<string, LedgerRow>;
|
|
152
|
+
/**
|
|
153
|
+
* Deletions actually applied, per direction, within a rolling window.
|
|
154
|
+
*
|
|
155
|
+
* PERSISTED, and that is the whole point. The first version of the delete
|
|
156
|
+
* breaker recomputed its limit from one pass's worth of decisions, which
|
|
157
|
+
* makes it a rate limit and not a budget: ten per pass, six passes a minute
|
|
158
|
+
* once the hub can poke, and a two-hundred-file project is gone in three
|
|
159
|
+
* minutes without the limit ever tripping. Two per pass was under every arm
|
|
160
|
+
* of it unconditionally, at every project size.
|
|
161
|
+
*
|
|
162
|
+
* A budget has to remember, and it has to remember across a restart, or
|
|
163
|
+
* "restart the app" is the bypass.
|
|
164
|
+
*/
|
|
165
|
+
deleteBudget?: { out: DeleteWindow; in: DeleteWindow };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Deletions applied since `since`, and where they went — for the report. */
|
|
169
|
+
interface DeleteWindow {
|
|
170
|
+
since: number;
|
|
171
|
+
count: number;
|
|
172
|
+
recent: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface FileLedger {
|
|
176
|
+
/** The epoch our ancestors are anchored against, or null before first read. */
|
|
177
|
+
epoch(): string | null;
|
|
178
|
+
cursor(): number;
|
|
179
|
+
/** True when the hub's epoch no longer matches ours — ancestors stop being
|
|
180
|
+
* overwrite authority until we re-anchor (decide-file's degraded rows). */
|
|
181
|
+
isDegraded(hubEpoch: string | null): boolean;
|
|
182
|
+
/** Adopt a hub epoch + cursor after a successful read. */
|
|
183
|
+
setPosition(epoch: string | null, cursor: number): void;
|
|
184
|
+
/** Forget the cursor (not the ancestors) and re-anchor against a new epoch. */
|
|
185
|
+
reanchor(epoch: string | null): void;
|
|
186
|
+
|
|
187
|
+
row(rel: string): LedgerRow | null;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* How many deletions this direction has applied inside the live window.
|
|
191
|
+
* Rolls the window forward when it has expired, so a caller only ever sees
|
|
192
|
+
* the current budget.
|
|
193
|
+
*/
|
|
194
|
+
deletesInWindow(direction: 'out' | 'in', windowMs: number): number;
|
|
195
|
+
/** Charge applied deletions against the window. Persisted. */
|
|
196
|
+
noteDeletes(direction: 'out' | 'in', rels: readonly string[], windowMs: number): void;
|
|
197
|
+
ancestorOf(rel: string): string | null;
|
|
198
|
+
/** The hub's hash for this path, as last learned. `undefined` = never learned. */
|
|
199
|
+
remoteOf(rel: string): string | null | undefined;
|
|
200
|
+
/** Record what the hub holds. A delta read UPDATES; only a full read clears. */
|
|
201
|
+
noteRemote(rel: string, hash: string | null, seq?: number): void;
|
|
202
|
+
/** After a FULL read: forget every remote we did not just see. */
|
|
203
|
+
pruneRemotes(seen: Set<string>): void;
|
|
204
|
+
/** All rows, for the doručenka. Defensive copy. */
|
|
205
|
+
rows(): Record<string, LedgerRow>;
|
|
206
|
+
|
|
207
|
+
/** Cached hash for a file whose (size, mtimeMs) still match. */
|
|
208
|
+
cachedHash(rel: string, size: number, mtimeMs: number): string | null;
|
|
209
|
+
/** This path just changed — drop its stat-cache entry so it is re-read. */
|
|
210
|
+
noteChanged(rel: string): void;
|
|
211
|
+
/** Remember a freshly computed local hash (stat cache only — NOT an ancestor). */
|
|
212
|
+
noteLocal(rel: string, hash: string, size: number, mtimeMs: number): void;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Land bytes, THEN move the ancestor. The only way to move one.
|
|
216
|
+
*
|
|
217
|
+
* `land` must complete the byte-landing (tmp+rename) before it returns. If it
|
|
218
|
+
* throws, the ancestor is untouched and the row is marked `stuck`.
|
|
219
|
+
*/
|
|
220
|
+
adoptAfter(
|
|
221
|
+
rel: string,
|
|
222
|
+
hash: string,
|
|
223
|
+
land: () => void | Promise<void>,
|
|
224
|
+
meta?: { remoteSeq?: number; state?: DeliveryState; size?: number; mtimeMs?: number }
|
|
225
|
+
): Promise<boolean>;
|
|
226
|
+
|
|
227
|
+
setState(rel: string, state: DeliveryState, extra?: Partial<LedgerRow>): void;
|
|
228
|
+
forget(rel: string): void;
|
|
229
|
+
|
|
230
|
+
/** The outbox: hashes this machine has in flight, for self-echo detection. */
|
|
231
|
+
outboxAdd(hash: string): void;
|
|
232
|
+
outboxDone(hash: string): void;
|
|
233
|
+
outboxHas(hash: string): boolean;
|
|
234
|
+
|
|
235
|
+
flush(): void;
|
|
236
|
+
stop(): void;
|
|
237
|
+
/** Where this ledger persists. Tests and the panel read it. */
|
|
238
|
+
file(): string;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** A stable, filesystem-safe id for a hub URL. */
|
|
242
|
+
export function hubIdFor(url: string): string {
|
|
243
|
+
// Deterministic and short. Not a secret: it names a file inside the user's
|
|
244
|
+
// own runtime state, beside a `_sync.json` that already carries the URL.
|
|
245
|
+
let h = 0x811c9dc5;
|
|
246
|
+
for (let i = 0; i < url.length; i += 1) {
|
|
247
|
+
h ^= url.charCodeAt(i);
|
|
248
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
249
|
+
}
|
|
250
|
+
const host = (() => {
|
|
251
|
+
try {
|
|
252
|
+
return new URL(url).hostname.replace(/[^a-z0-9.-]/gi, '').slice(0, 40);
|
|
253
|
+
} catch {
|
|
254
|
+
return 'hub';
|
|
255
|
+
}
|
|
256
|
+
})();
|
|
257
|
+
return `${host || 'hub'}-${h.toString(16).padStart(8, '0')}`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface FileLedgerOptions {
|
|
261
|
+
designRoot: string;
|
|
262
|
+
hubUrl: string;
|
|
263
|
+
now?: () => number;
|
|
264
|
+
flushMs?: number;
|
|
265
|
+
log?: Pick<Console, 'warn'>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function createFileLedger(opts: FileLedgerOptions): FileLedger {
|
|
269
|
+
const now = opts.now ?? Date.now;
|
|
270
|
+
const flushMs = opts.flushMs ?? LEDGER_FLUSH_MS;
|
|
271
|
+
const log = opts.log ?? console;
|
|
272
|
+
const dir = path.join(opts.designRoot, '_state', 'file-ledger');
|
|
273
|
+
const file = path.join(dir, `${hubIdFor(opts.hubUrl)}.json`);
|
|
274
|
+
|
|
275
|
+
const data: LedgerFileShape = load();
|
|
276
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
277
|
+
const outbox = new Set<string>();
|
|
278
|
+
|
|
279
|
+
function load(): LedgerFileShape {
|
|
280
|
+
const fresh: LedgerFileShape = {
|
|
281
|
+
version: 1,
|
|
282
|
+
hubUrl: opts.hubUrl,
|
|
283
|
+
epoch: null,
|
|
284
|
+
cursor: 0,
|
|
285
|
+
updatedAt: now(),
|
|
286
|
+
rows: {},
|
|
287
|
+
};
|
|
288
|
+
try {
|
|
289
|
+
if (!existsSync(file)) return fresh;
|
|
290
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<LedgerFileShape>;
|
|
291
|
+
// A ledger recorded against a DIFFERENT hub says nothing about this one:
|
|
292
|
+
// the seqs belong to another log and the ancestors to another project.
|
|
293
|
+
if (parsed?.hubUrl !== opts.hubUrl) return fresh;
|
|
294
|
+
if (parsed.version !== 1 || typeof parsed.rows !== 'object' || parsed.rows === null) {
|
|
295
|
+
return fresh;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
version: 1,
|
|
299
|
+
hubUrl: opts.hubUrl,
|
|
300
|
+
epoch: typeof parsed.epoch === 'string' ? parsed.epoch : null,
|
|
301
|
+
// `typeof` first: Number.isInteger is a runtime guard, not a narrowing
|
|
302
|
+
// one, so a ledger written by an older build with no `cursor` key read
|
|
303
|
+
// as `number` to the checker while being `undefined` at runtime.
|
|
304
|
+
cursor:
|
|
305
|
+
typeof parsed.cursor === 'number' && Number.isInteger(parsed.cursor) && parsed.cursor >= 0
|
|
306
|
+
? parsed.cursor
|
|
307
|
+
: 0,
|
|
308
|
+
updatedAt: now(),
|
|
309
|
+
rows: sanitizeRows(parsed.rows as Record<string, unknown>),
|
|
310
|
+
};
|
|
311
|
+
} catch {
|
|
312
|
+
// Corrupt is treated as absent — which forces a safe re-anchor rather
|
|
313
|
+
// than acting on half-parsed ancestors.
|
|
314
|
+
return fresh;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function sanitizeRows(raw: Record<string, unknown>): Record<string, LedgerRow> {
|
|
319
|
+
const out: Record<string, LedgerRow> = {};
|
|
320
|
+
for (const [rel, value] of Object.entries(raw)) {
|
|
321
|
+
// Proto-pollution reviver discipline, same as every other parse in this
|
|
322
|
+
// tree: this file is on disk and a hostile writer is cheap to imagine.
|
|
323
|
+
if (rel === '__proto__' || rel === 'constructor' || rel === 'prototype') continue;
|
|
324
|
+
if (!value || typeof value !== 'object') continue;
|
|
325
|
+
const r = value as LedgerRow;
|
|
326
|
+
out[rel] = {
|
|
327
|
+
syncedHash: typeof r.syncedHash === 'string' ? r.syncedHash : null,
|
|
328
|
+
...(typeof r.localHash === 'string' ? { localHash: r.localHash } : {}),
|
|
329
|
+
...(Number.isInteger(r.remoteSeq) ? { remoteSeq: r.remoteSeq } : {}),
|
|
330
|
+
...(typeof r.remoteHash === 'string' || r.remoteHash === null
|
|
331
|
+
? { remoteHash: r.remoteHash }
|
|
332
|
+
: {}),
|
|
333
|
+
...(Number.isFinite(r.size) ? { size: r.size } : {}),
|
|
334
|
+
...(Number.isFinite(r.mtimeMs) ? { mtimeMs: r.mtimeMs } : {}),
|
|
335
|
+
...(typeof r.state === 'string' ? { state: r.state as DeliveryState } : {}),
|
|
336
|
+
...(typeof r.reason === 'string' ? { reason: r.reason.slice(0, 200) } : {}),
|
|
337
|
+
...(typeof r.conflictCopy === 'string' ? { conflictCopy: r.conflictCopy } : {}),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function schedule(): void {
|
|
344
|
+
if (timer !== null) return;
|
|
345
|
+
timer = setTimeout(() => {
|
|
346
|
+
timer = null;
|
|
347
|
+
persist();
|
|
348
|
+
}, flushMs);
|
|
349
|
+
timer.unref?.();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function persist(): void {
|
|
353
|
+
try {
|
|
354
|
+
mkdirSync(dir, { recursive: true });
|
|
355
|
+
data.updatedAt = now();
|
|
356
|
+
atomicWrite(file, `${JSON.stringify(data, null, 2)}\n`);
|
|
357
|
+
} catch (err) {
|
|
358
|
+
// Never throws into the sync hot path. A ledger we cannot persist costs
|
|
359
|
+
// a re-anchor next boot, which is noise rather than loss.
|
|
360
|
+
log.warn?.(`[sync/ledger] could not persist: ${(err as Error).message}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function rowFor(rel: string): LedgerRow {
|
|
365
|
+
const existing = data.rows[rel];
|
|
366
|
+
if (existing) return existing;
|
|
367
|
+
const created: LedgerRow = { syncedHash: null };
|
|
368
|
+
data.rows[rel] = created;
|
|
369
|
+
return created;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The live window for `direction`, rolled forward if it has expired. */
|
|
373
|
+
function windowFor(direction: 'out' | 'in', windowMs: number): DeleteWindow {
|
|
374
|
+
if (!data.deleteBudget) {
|
|
375
|
+
data.deleteBudget = {
|
|
376
|
+
out: { since: now(), count: 0, recent: [] },
|
|
377
|
+
in: { since: now(), count: 0, recent: [] },
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
const w = data.deleteBudget[direction];
|
|
381
|
+
if (now() - w.since >= windowMs) {
|
|
382
|
+
w.since = now();
|
|
383
|
+
w.count = 0;
|
|
384
|
+
w.recent = [];
|
|
385
|
+
}
|
|
386
|
+
return w;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
epoch: () => data.epoch,
|
|
391
|
+
cursor: () => data.cursor,
|
|
392
|
+
|
|
393
|
+
deletesInWindow(direction, windowMs) {
|
|
394
|
+
return windowFor(direction, windowMs).count;
|
|
395
|
+
},
|
|
396
|
+
|
|
397
|
+
noteDeletes(direction, rels, windowMs) {
|
|
398
|
+
if (rels.length === 0) return;
|
|
399
|
+
const w = windowFor(direction, windowMs);
|
|
400
|
+
w.count += rels.length;
|
|
401
|
+
// A short tail, so the panel can say WHAT went without the ledger
|
|
402
|
+
// growing without bound on a busy project.
|
|
403
|
+
w.recent = [...w.recent, ...rels].slice(-50);
|
|
404
|
+
schedule();
|
|
405
|
+
},
|
|
406
|
+
|
|
407
|
+
isDegraded(hubEpoch) {
|
|
408
|
+
// Before the first read we have no epoch and nothing to be degraded
|
|
409
|
+
// against; a fresh ledger anchors on whatever the hub says.
|
|
410
|
+
if (data.epoch === null || hubEpoch === null) return false;
|
|
411
|
+
return data.epoch !== hubEpoch;
|
|
412
|
+
},
|
|
413
|
+
|
|
414
|
+
setPosition(epoch, cursor) {
|
|
415
|
+
data.epoch = epoch;
|
|
416
|
+
if (Number.isInteger(cursor) && cursor >= 0) data.cursor = cursor;
|
|
417
|
+
schedule();
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
reanchor(epoch) {
|
|
421
|
+
// The ancestors STAY. They still record what this machine last
|
|
422
|
+
// reconciled, which is true regardless of what the hub's log did; they
|
|
423
|
+
// simply stop being overwrite authority until the next clean read
|
|
424
|
+
// (decide-file's degraded rows). Throwing them away would turn every
|
|
425
|
+
// path into a first-anchor conflict for no gain.
|
|
426
|
+
data.epoch = epoch;
|
|
427
|
+
data.cursor = 0;
|
|
428
|
+
schedule();
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
row: (rel) => data.rows[rel] ?? null,
|
|
432
|
+
ancestorOf: (rel) => data.rows[rel]?.syncedHash ?? null,
|
|
433
|
+
remoteOf: (rel) => data.rows[rel]?.remoteHash,
|
|
434
|
+
|
|
435
|
+
noteRemote(rel, hash, seq) {
|
|
436
|
+
const r = rowFor(rel);
|
|
437
|
+
r.remoteHash = hash;
|
|
438
|
+
if (seq !== undefined) r.remoteSeq = seq;
|
|
439
|
+
schedule();
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
pruneRemotes(seen) {
|
|
443
|
+
// ONLY a full compaction read may say "the hub does not have this".
|
|
444
|
+
// Called with the set of paths that read carried; anything else loses
|
|
445
|
+
// its remembered remote rather than keeping a value the hub has since
|
|
446
|
+
// dropped.
|
|
447
|
+
for (const [rel, r] of Object.entries(data.rows)) {
|
|
448
|
+
if (!seen.has(rel) && r.remoteHash !== undefined) r.remoteHash = null;
|
|
449
|
+
}
|
|
450
|
+
schedule();
|
|
451
|
+
},
|
|
452
|
+
rows: () => JSON.parse(JSON.stringify(data.rows)) as Record<string, LedgerRow>,
|
|
453
|
+
|
|
454
|
+
cachedHash(rel, size, mtimeMs) {
|
|
455
|
+
const r = data.rows[rel];
|
|
456
|
+
if (!r || r.size !== size || r.mtimeMs !== mtimeMs) return null;
|
|
457
|
+
// THE MTIME-GRANULARITY GUARD. `(size, mtime)` is a good enough identity
|
|
458
|
+
// for a file that has been sitting still, and a poor one for a file
|
|
459
|
+
// edited moments ago: two same-length writes inside the filesystem's
|
|
460
|
+
// timestamp resolution are indistinguishable, and trusting the cache
|
|
461
|
+
// there means a real edit is simply never noticed. rsync has carried
|
|
462
|
+
// this same guard for the same reason.
|
|
463
|
+
//
|
|
464
|
+
// So a recently-touched file is always re-read. It costs one hash of one
|
|
465
|
+
// file at exactly the moment somebody is working on it, and it buys back
|
|
466
|
+
// the class of silently-unsynced edit.
|
|
467
|
+
if (now() - mtimeMs < RECENT_WRITE_MS) return null;
|
|
468
|
+
return r.localHash ?? null;
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
noteChanged(rel) {
|
|
472
|
+
// The watcher told us this path moved. Drop the stat cache for it so the
|
|
473
|
+
// next scan reads the bytes rather than trusting a stamp — the cheap,
|
|
474
|
+
// exact version of the guard above.
|
|
475
|
+
const r = data.rows[rel];
|
|
476
|
+
if (!r) return;
|
|
477
|
+
r.localHash = undefined;
|
|
478
|
+
r.size = undefined;
|
|
479
|
+
r.mtimeMs = undefined;
|
|
480
|
+
schedule();
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
noteLocal(rel, hash, size, mtimeMs) {
|
|
484
|
+
// Records an OBSERVATION, never a reconciliation. This is what makes a
|
|
485
|
+
// boot reconcile cheap: next time, a file whose (size, mtime) still
|
|
486
|
+
// match is not read at all.
|
|
487
|
+
const r = rowFor(rel);
|
|
488
|
+
r.localHash = hash;
|
|
489
|
+
r.size = size;
|
|
490
|
+
r.mtimeMs = mtimeMs;
|
|
491
|
+
// A file whose bytes differ from the ancestor has unsent work on it, and
|
|
492
|
+
// saying so is the honest default — `local-only` is the pessimistic
|
|
493
|
+
// branch, and nothing here may claim delivery.
|
|
494
|
+
if (r.syncedHash !== hash && !r.state) r.state = 'local-only';
|
|
495
|
+
schedule();
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
async adoptAfter(rel, hash, land, meta) {
|
|
499
|
+
try {
|
|
500
|
+
await land();
|
|
501
|
+
} catch (err) {
|
|
502
|
+
const r = rowFor(rel);
|
|
503
|
+
r.state = 'stuck';
|
|
504
|
+
r.reason = (err as Error).message.slice(0, 200);
|
|
505
|
+
schedule();
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
// ONLY here. Bytes are on disk; the ancestor may move.
|
|
509
|
+
const r = rowFor(rel);
|
|
510
|
+
r.syncedHash = hash;
|
|
511
|
+
// The observation moves with it — what landed IS what is on disk now,
|
|
512
|
+
// so leaving a stale `localHash` behind would make the very next pass
|
|
513
|
+
// read a freshly-reconciled file as locally changed.
|
|
514
|
+
r.localHash = hash;
|
|
515
|
+
if (meta?.remoteSeq !== undefined) r.remoteSeq = meta.remoteSeq;
|
|
516
|
+
if (meta?.size !== undefined) r.size = meta.size;
|
|
517
|
+
if (meta?.mtimeMs !== undefined) r.mtimeMs = meta.mtimeMs;
|
|
518
|
+
if (meta?.state) {
|
|
519
|
+
r.state = meta.state;
|
|
520
|
+
} else if (
|
|
521
|
+
r.state === 'stuck' ||
|
|
522
|
+
r.state === 'conflict' ||
|
|
523
|
+
r.state === 'referenced-but-unoffered'
|
|
524
|
+
) {
|
|
525
|
+
// A resolved problem must stop being reported as a problem. Dropping
|
|
526
|
+
// the state (rather than inventing a cheerful one) leaves the row at
|
|
527
|
+
// the pessimistic default — we no longer know it is stuck, and we are
|
|
528
|
+
// not claiming it is delivered either. Leaving the old value would be
|
|
529
|
+
// the "status lies" failure DDR-214 exists to end, just slower.
|
|
530
|
+
r.state = undefined;
|
|
531
|
+
}
|
|
532
|
+
r.reason = undefined;
|
|
533
|
+
r.conflictCopy = undefined;
|
|
534
|
+
// B13 (post-1.0 burn-down) — the park memo dies with the conflict it
|
|
535
|
+
// memoised. `parkedRemote` used to survive convergence forever, so once
|
|
536
|
+
// hash H was memoised for a path, H was never parked again — after the
|
|
537
|
+
// user deleted the copy, after the row re-diverged, after a `_trash/`
|
|
538
|
+
// prune. A hub re-offering H a week later got a `noop` with no
|
|
539
|
+
// recoverable copy anywhere, while the row still claimed one was made.
|
|
540
|
+
r.parkedRemote = undefined;
|
|
541
|
+
schedule();
|
|
542
|
+
return true;
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
setState(rel, state, extra) {
|
|
546
|
+
const r = rowFor(rel);
|
|
547
|
+
r.state = state;
|
|
548
|
+
if (extra) Object.assign(r, extra);
|
|
549
|
+
schedule();
|
|
550
|
+
},
|
|
551
|
+
|
|
552
|
+
forget(rel) {
|
|
553
|
+
delete data.rows[rel];
|
|
554
|
+
schedule();
|
|
555
|
+
},
|
|
556
|
+
|
|
557
|
+
outboxAdd: (hash) => {
|
|
558
|
+
outbox.add(hash);
|
|
559
|
+
},
|
|
560
|
+
outboxDone: (hash) => {
|
|
561
|
+
outbox.delete(hash);
|
|
562
|
+
},
|
|
563
|
+
outboxHas: (hash) => outbox.has(hash),
|
|
564
|
+
|
|
565
|
+
flush() {
|
|
566
|
+
if (timer !== null) {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
timer = null;
|
|
569
|
+
}
|
|
570
|
+
persist();
|
|
571
|
+
},
|
|
572
|
+
stop() {
|
|
573
|
+
if (timer !== null) {
|
|
574
|
+
clearTimeout(timer);
|
|
575
|
+
timer = null;
|
|
576
|
+
}
|
|
577
|
+
persist();
|
|
578
|
+
},
|
|
579
|
+
file: () => file,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
@@ -218,6 +218,20 @@ export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): Fi
|
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
// The annotations sidecar's REAL shape: flat at the design root, keyed by
|
|
222
|
+
// the slug (`ui-2.annotations.svg`) — the naming asymmetry the canvas
|
|
223
|
+
// artifacts vocabulary documents. The in-group rule above never fires for
|
|
224
|
+
// it, so it fell through to `inert-media` and the FILE plane carried a file
|
|
225
|
+
// the DOC lane already owns. Two lanes, two conflict semantics, no shared
|
|
226
|
+
// ancestor: a stale doc-lane materialisation on one peer bumped the file's
|
|
227
|
+
// mtime, the file plane read that as a fresh local edit and pushed it, and
|
|
228
|
+
// a drawing made seconds earlier on the other machine was erased everywhere
|
|
229
|
+
// (observed live: 417 B of strokes at 10:50:28, an empty 72 B wrapper
|
|
230
|
+
// pushed over them at 10:50:33). The annotations lane's own stamped
|
|
231
|
+
// newest-wins protection never saw it coming — it guards the DOC lane, and
|
|
232
|
+
// this was the file plane acting alone. One owner: the canvas.
|
|
233
|
+
if (parts.length === 1 && lowerLast.endsWith('.annotations.svg')) return 'canvas-owned';
|
|
234
|
+
|
|
221
235
|
if (COMPANION_SIDECAR_SUFFIXES.some((s) => lowerLast.endsWith(s))) return 'companion-text';
|
|
222
236
|
|
|
223
237
|
const dot = lowerLast.lastIndexOf('.');
|
|
@@ -243,11 +257,29 @@ export function isProjectFileShape(rel: string): boolean {
|
|
|
243
257
|
}
|
|
244
258
|
|
|
245
259
|
/** The segment shape rules alone — split parts, or null on refusal. */
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Another program's conflict artifact. Never ours to carry.
|
|
263
|
+
*
|
|
264
|
+
* `~/git` is a real Syncthing tree, and Syncthing writes
|
|
265
|
+
* `hero.sync-conflict-20260818-101500-ABCDEF.png` beside the original. Nothing
|
|
266
|
+
* excluded those, so `scanLocalFiles` saw one as `create-up`, pushed it to the
|
|
267
|
+
* hub, journalled it, and delivered it to every peer — where Syncthing could
|
|
268
|
+
* in turn make conflict copies OF the conflict copies. A noise-amplification
|
|
269
|
+
* loop in the one environment the maintainer actually runs, and it makes
|
|
270
|
+
* conflict provenance exactly as unattributable as `conflictCopyName`'s own
|
|
271
|
+
* comment says it must not be.
|
|
272
|
+
*/
|
|
273
|
+
export function isForeignConflictArtifact(rel: string): boolean {
|
|
274
|
+
return /\.sync-conflict-/i.test(rel);
|
|
275
|
+
}
|
|
276
|
+
|
|
246
277
|
function relShape(rel: unknown): string[] | null {
|
|
247
278
|
if (typeof rel !== 'string' || rel.length === 0 || rel.length > MAX_REL_LEN) return null;
|
|
248
279
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: refusing them is the point.
|
|
249
280
|
if (/[\u0000-\u001f\u007f]/.test(rel)) return null;
|
|
250
281
|
if (rel.startsWith('/') || rel.includes('\\') || /^[A-Za-z]:/.test(rel)) return null;
|
|
282
|
+
if (isForeignConflictArtifact(rel)) return null;
|
|
251
283
|
const parts = rel.split('/');
|
|
252
284
|
if (parts.length > MAX_SEGMENTS) return null;
|
|
253
285
|
for (let i = 0; i < parts.length; i++) {
|