@1agh/maude 1.2.0 → 1.3.0
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/README.md +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +35 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1586 -1554
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/whats-new.json +101 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +47 -0
- package/plugins/flow/dependencies.json +3 -3
|
@@ -52,12 +52,10 @@ import {
|
|
|
52
52
|
cssFromDoc,
|
|
53
53
|
htmlFromDoc,
|
|
54
54
|
isEmptyAnnotationsSvg,
|
|
55
|
-
markSeeded,
|
|
56
55
|
mergeSharedMetaIntoLocal,
|
|
57
56
|
metaFromDoc,
|
|
58
57
|
movedToFromDoc,
|
|
59
58
|
repairSharedMeta,
|
|
60
|
-
seededByFromDoc,
|
|
61
59
|
stampAnnotationsEdit,
|
|
62
60
|
stampBodyEdit,
|
|
63
61
|
Y_SYNC_TYPES,
|
|
@@ -66,13 +64,13 @@ import {
|
|
|
66
64
|
decideAnnotationsColdStart,
|
|
67
65
|
decideColdStart,
|
|
68
66
|
decideCssColdStart,
|
|
69
|
-
isExactRepeat,
|
|
70
67
|
unionCommentsById,
|
|
71
68
|
} from './cold-start.ts';
|
|
72
69
|
import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
|
|
73
70
|
import { dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
|
|
74
71
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
75
72
|
import type { SyncJournal } from './journal.ts';
|
|
73
|
+
import { rememberSeed, repairSeedDuplication } from './seed-repair.ts';
|
|
76
74
|
|
|
77
75
|
export const DOC_FLUSH_MS = 800;
|
|
78
76
|
|
|
@@ -82,7 +80,7 @@ export const DOC_FLUSH_MS = 800;
|
|
|
82
80
|
* the cold-start race — a genuine peer edit that arrives minutes later is NEVER
|
|
83
81
|
* collapsed back to our original seed. Generous relative to the hub sync RTT.
|
|
84
82
|
*/
|
|
85
|
-
export
|
|
83
|
+
export { SEED_REPAIR_WINDOW_MS } from './seed-repair.ts';
|
|
86
84
|
|
|
87
85
|
export interface CanvasSyncPaths {
|
|
88
86
|
/** Absolute path to <designRoot>/<canvas>.html. */
|
|
@@ -187,18 +185,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
187
185
|
let lastMeta: string | null = null;
|
|
188
186
|
let lastCss: string | null = null;
|
|
189
187
|
|
|
190
|
-
// F1 — the content this agent pushed on `seed-local-up`, plus the deadline
|
|
191
|
-
// past which the de-dup repair stops firing. Set on seed; nulled only when the
|
|
192
|
-
// window lapses (after convergence the repair is a cheap no-op — the
|
|
193
|
-
// `=== seedInfo.x` guards short-circuit, so we don't bother clearing).
|
|
194
|
-
// Null = this agent never seeded (so it never repairs).
|
|
195
|
-
//
|
|
196
|
-
// `css` rides along with the body (issue #114): a seed writes BOTH lanes in
|
|
197
|
-
// the same breath, so both can collide with a concurrent peer's identical
|
|
198
|
-
// seed, and repairing only the body left the canvas un-buildable anyway —
|
|
199
|
-
// duplicated css is as fatal to the build as a duplicated `export default`.
|
|
200
|
-
let seedInfo: { body: string; css: string | null; until: number } | null = null;
|
|
201
|
-
|
|
202
188
|
function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
|
|
203
189
|
if (stopped) return;
|
|
204
190
|
// Self-applied (we just synced from disk) — disk is already current.
|
|
@@ -246,45 +232,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
246
232
|
* variant is an accepted follow-up, not handled in-flight.
|
|
247
233
|
*/
|
|
248
234
|
function maybeRepairSeedDuplication(): void {
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
const owner = seededByFromDoc(doc);
|
|
255
|
-
if (owner === null || owner !== doc.clientID) return; // not the elected writer
|
|
256
|
-
const seeded = seedInfo;
|
|
257
|
-
const repaired: string[] = [];
|
|
258
|
-
|
|
259
|
-
const body = htmlFromDoc(doc);
|
|
260
|
-
// `=== seeded.body` → already a single copy; not an exact repeat → a
|
|
261
|
-
// divergent edit, which carries genuine bytes and belongs to the conflict
|
|
262
|
-
// path, not to a silent collapse.
|
|
263
|
-
if (body !== seeded.body && isExactRepeat(body, seeded.body)) {
|
|
264
|
-
const canonical = seeded.body;
|
|
265
|
-
doc.transact(() => {
|
|
266
|
-
if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
|
|
267
|
-
}, origin);
|
|
268
|
-
lastHtml = canonical;
|
|
269
|
-
opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
|
|
270
|
-
repaired.push('body');
|
|
235
|
+
const repaired = repairSeedDuplication(doc, origin);
|
|
236
|
+
if (repaired.includes('body')) {
|
|
237
|
+
lastHtml = htmlFromDoc(doc);
|
|
238
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(lastHtml) });
|
|
271
239
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// idempotence: the collapse deletes the same CRDT items on every peer that
|
|
276
|
-
// runs it, so concurrent recoveries converge instead of fighting.
|
|
277
|
-
if (paths.css && seeded.css !== null) {
|
|
278
|
-
const css = cssFromDoc(doc);
|
|
279
|
-
if (css !== null && css !== seeded.css && isExactRepeat(css, seeded.css)) {
|
|
280
|
-
const canonicalCss = seeded.css;
|
|
281
|
-
doc.transact(() => {
|
|
282
|
-
applyCssToDoc(doc, canonicalCss, origin);
|
|
283
|
-
}, origin);
|
|
284
|
-
lastCss = canonicalCss;
|
|
285
|
-
opts.journal?.record(slug, { cssHash: hashBytes(canonicalCss) });
|
|
286
|
-
repaired.push('css');
|
|
287
|
-
}
|
|
240
|
+
if (repaired.includes('css')) {
|
|
241
|
+
lastCss = cssFromDoc(doc);
|
|
242
|
+
if (lastCss !== null) opts.journal?.record(slug, { cssHash: hashBytes(lastCss) });
|
|
288
243
|
}
|
|
289
244
|
|
|
290
245
|
if (repaired.length > 0) {
|
|
@@ -518,9 +473,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
518
473
|
// F1 — adopt is also a "seed local up" (first link / fresh hub); claim
|
|
519
474
|
// it + arm the repair window so two peers adopting the same draft into
|
|
520
475
|
// one hub at once self-heal the same way the decision-table seed does.
|
|
521
|
-
|
|
476
|
+
rememberSeed(doc, localHtml, localCss, origin);
|
|
522
477
|
}, origin);
|
|
523
|
-
seedInfo = { body: localHtml, css: localCss, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
524
478
|
}
|
|
525
479
|
if (localComments !== null) {
|
|
526
480
|
const parsed = tryParseJsonArray(localComments);
|
|
@@ -575,15 +529,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
575
529
|
if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
|
|
576
530
|
// F1 — claim the seed (clientID marker) in the SAME update so a
|
|
577
531
|
// concurrent second seeder's merge resolves a single elected writer.
|
|
578
|
-
|
|
532
|
+
rememberSeed(doc, body, localCss, origin);
|
|
579
533
|
}, origin);
|
|
580
534
|
lastHtml = body;
|
|
581
535
|
// Arm the de-dup repair window: if another peer seeded the same content at
|
|
582
536
|
// the same instant, the merged Y.Text will double — maybeRepairSeedDuplication
|
|
583
537
|
// (on the elected owner) collapses it back during this window. `localCss`
|
|
584
|
-
// is
|
|
585
|
-
// below and collides identically (issue #114).
|
|
586
|
-
seedInfo = { body, css: localCss, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
538
|
+
// is remembered too, since it collides identically (issue #114).
|
|
587
539
|
opts.journal?.record(slug, { bodyHash: hashBytes(body) });
|
|
588
540
|
};
|
|
589
541
|
|
|
@@ -89,6 +89,9 @@ export function startCellFileEvents(
|
|
|
89
89
|
url: target.url,
|
|
90
90
|
token: target.token,
|
|
91
91
|
onPoke: (head) => healer.onPoke(head),
|
|
92
|
+
// The paired cell runtime does not open a second control provider. Route
|
|
93
|
+
// metadata invalidation to its bounded discovery queue, not the journal.
|
|
94
|
+
onDocuments: () => ctx.bus.emit('sync:documents-changed'),
|
|
92
95
|
});
|
|
93
96
|
|
|
94
97
|
// Every write this process makes surfaces here, because both synthetic-event
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ANNOTATION_WRITE_ID } from '../annotations-sync.ts';
|
|
1
2
|
// Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
|
|
2
3
|
//
|
|
3
4
|
// The agent shuttles three classes of files between disk and the Y.Doc the
|
|
@@ -22,11 +23,11 @@
|
|
|
22
23
|
// in the unchanged regions.
|
|
23
24
|
|
|
24
25
|
import { hostname } from 'node:os';
|
|
25
|
-
|
|
26
|
+
import { diffChars } from 'diff';
|
|
26
27
|
import type * as Y from 'yjs';
|
|
27
28
|
|
|
28
29
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
29
|
-
import { commentKey } from './comment-identity.ts';
|
|
30
|
+
import { commentKey, dedupeCommentsById } from './comment-identity.ts';
|
|
30
31
|
import {
|
|
31
32
|
MAX_ANNOTATIONS_BYTES,
|
|
32
33
|
MAX_COMMENTS_BYTES,
|
|
@@ -97,8 +98,8 @@ export function htmlFromDoc(doc: Y.Doc): string {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
|
-
* Apply `next`
|
|
101
|
-
*
|
|
101
|
+
* Apply `next` as bounded text hunks, preserving unchanged interior CRDT
|
|
102
|
+
* anchors as well as the common prefix/suffix (#121).
|
|
102
103
|
*
|
|
103
104
|
* THIS SHAPE IS A CONVERGENCE PROPERTY, NOT AN OPTIMIZATION (issue #114).
|
|
104
105
|
* Collapsing a concurrency-duplicated lane — `X + X` back to `X` — comes out of
|
|
@@ -137,12 +138,37 @@ function applyTextLane(doc: Y.Doc, lane: string, next: string, origin?: unknown)
|
|
|
137
138
|
suffix++;
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
// Y.Text offsets are UTF-16; never leave half a surrogate outside a hunk.
|
|
142
|
+
if (prefix > 0 && /[\uD800-\uDBFF]/.test(current[prefix - 1] ?? '')) prefix--;
|
|
143
|
+
if (suffix > 0 && /[\uDC00-\uDFFF]/.test(current[current.length - suffix] ?? '')) suffix--;
|
|
140
144
|
const deleteLen = current.length - prefix - suffix;
|
|
141
145
|
const insertStr = next.slice(prefix, next.length - suffix);
|
|
142
146
|
|
|
147
|
+
// Keep pure inserts/deletes (especially repeat collapse) as one operation.
|
|
148
|
+
// For replacements preserve interior anchors: one prefix/suffix splice can
|
|
149
|
+
// move a concurrent insertion inside an unrelated JSX attribute (#121).
|
|
150
|
+
const changes =
|
|
151
|
+
deleteLen > 0 && insertStr.length > 0
|
|
152
|
+
? diffChars(current.slice(prefix, current.length - suffix), insertStr, {
|
|
153
|
+
maxEditLength: 4096,
|
|
154
|
+
timeout: 50,
|
|
155
|
+
})
|
|
156
|
+
: null;
|
|
157
|
+
if (changes === undefined) throw new Error('Source diff exceeds the safe merge budget');
|
|
143
158
|
doc.transact(() => {
|
|
144
|
-
if (
|
|
145
|
-
|
|
159
|
+
if (changes) {
|
|
160
|
+
let offset = prefix;
|
|
161
|
+
for (const change of changes) {
|
|
162
|
+
if (change.removed) yText.delete(offset, change.value.length);
|
|
163
|
+
else {
|
|
164
|
+
if (change.added) yText.insert(offset, change.value);
|
|
165
|
+
offset += change.value.length;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
if (deleteLen > 0) yText.delete(prefix, deleteLen);
|
|
170
|
+
if (insertStr.length > 0) yText.insert(prefix, insertStr);
|
|
171
|
+
}
|
|
146
172
|
}, origin);
|
|
147
173
|
|
|
148
174
|
return true;
|
|
@@ -306,6 +332,8 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
|
|
|
306
332
|
if (currentStr === next) return false;
|
|
307
333
|
|
|
308
334
|
doc.transact(() => {
|
|
335
|
+
// A filesystem import is a new operation, never the previous UI author's echo.
|
|
336
|
+
map.delete(ANNOTATION_WRITE_ID);
|
|
309
337
|
if (next === null || next === '') {
|
|
310
338
|
map.delete('svg');
|
|
311
339
|
} else {
|
|
@@ -389,6 +417,45 @@ function parsesAsObject(s: string): boolean {
|
|
|
389
417
|
}
|
|
390
418
|
|
|
391
419
|
/** The synced shared-meta JSON string held in the doc, or null when unset. */
|
|
420
|
+
/**
|
|
421
|
+
* The canonical LANE value for a file's text — what the accepted-revisions
|
|
422
|
+
* kernel stores and what `readLaneFromDoc` returns once it is accepted
|
|
423
|
+
* (DDR-241). `null` when the file text cannot be a value of that lane.
|
|
424
|
+
*/
|
|
425
|
+
export function laneValueFromFile(
|
|
426
|
+
lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments',
|
|
427
|
+
text: string
|
|
428
|
+
): string | null {
|
|
429
|
+
if (lane === 'html' || lane === 'css' || lane === 'annotations') return text;
|
|
430
|
+
let parsed: unknown;
|
|
431
|
+
try {
|
|
432
|
+
parsed = parseJsonSafe(text);
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
if (lane === 'meta') {
|
|
437
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
438
|
+
return sharedMetaCanonical(parsed as Record<string, unknown>);
|
|
439
|
+
}
|
|
440
|
+
if (!Array.isArray(parsed)) return null;
|
|
441
|
+
const list = dedupeCommentsById(parsed);
|
|
442
|
+
return list.length ? JSON.stringify(list) : '';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** A lane's value as the accepted replica holds it (same canonical form). */
|
|
446
|
+
export function readLaneFromDoc(
|
|
447
|
+
doc: Y.Doc,
|
|
448
|
+
lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments'
|
|
449
|
+
): string {
|
|
450
|
+
if (lane === 'html' || lane === 'css' || lane === 'meta') return doc.getText(lane).toString();
|
|
451
|
+
if (lane === 'annotations') {
|
|
452
|
+
const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
|
|
453
|
+
return typeof svg === 'string' ? svg : '';
|
|
454
|
+
}
|
|
455
|
+
const list = doc.getArray<unknown>(Y_TYPES.comments).toArray();
|
|
456
|
+
return list.length ? JSON.stringify(list) : '';
|
|
457
|
+
}
|
|
458
|
+
|
|
392
459
|
export function metaFromDoc(doc: Y.Doc): string | null {
|
|
393
460
|
return normalizeSharedMeta(doc.getText(Y_SYNC_TYPES.meta).toString());
|
|
394
461
|
}
|
|
@@ -95,6 +95,8 @@ export interface CtlProviderOptions {
|
|
|
95
95
|
token: string;
|
|
96
96
|
/** Fired with the hub's head on every well-formed poke. */
|
|
97
97
|
onPoke: (head: number) => void;
|
|
98
|
+
/** Optional fast document-list invalidation; older consumers use onPoke. */
|
|
99
|
+
onDocuments?: () => void;
|
|
98
100
|
documentName?: string;
|
|
99
101
|
log?: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
100
102
|
/** Injected in tests; production builds one from `@hocuspocus/provider`. */
|
|
@@ -155,9 +157,19 @@ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
|
|
|
155
157
|
|
|
156
158
|
const wire = (p: CtlProviderLike): void => {
|
|
157
159
|
p.on('status', (data: { status?: string }) => {
|
|
158
|
-
if (typeof data?.status
|
|
160
|
+
if (typeof data?.status !== 'string') return;
|
|
161
|
+
const reconnect = data.status === 'connected' && status !== 'connected';
|
|
162
|
+
status = data.status;
|
|
163
|
+
if (reconnect && !stopped) {
|
|
164
|
+
try {
|
|
165
|
+
opts.onDocuments?.();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
log.error?.(`[sync/ctl] discovery handler threw: ${(error as Error).message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
159
170
|
});
|
|
160
171
|
p.on('stateless', (data: { payload: string }) => {
|
|
172
|
+
if (stopped) return;
|
|
161
173
|
const poke = parsePoke(data?.payload);
|
|
162
174
|
if (poke === null) {
|
|
163
175
|
malformed += 1;
|
|
@@ -172,7 +184,8 @@ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
|
|
|
172
184
|
}
|
|
173
185
|
received += 1;
|
|
174
186
|
try {
|
|
175
|
-
|
|
187
|
+
if (poke.documents && opts.onDocuments) opts.onDocuments();
|
|
188
|
+
else opts.onPoke(poke.head);
|
|
176
189
|
} catch (err) {
|
|
177
190
|
log.error?.(`[sync/ctl] poke handler threw: ${(err as Error).message}`);
|
|
178
191
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A bounded metadata read queue. File walks/uploads have their own scheduler.
|
|
2
|
+
// A notification during a read requires another read: the first response may
|
|
3
|
+
// already describe the old head. Keep one pending pass, never an unbounded queue.
|
|
4
|
+
export function createDocumentDiscovery(opts: {
|
|
5
|
+
run: () => Promise<void>;
|
|
6
|
+
onError: (error: unknown) => void;
|
|
7
|
+
delayMs?: number;
|
|
8
|
+
minIntervalMs?: number;
|
|
9
|
+
now?: () => number;
|
|
10
|
+
setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
11
|
+
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const now = opts.now ?? Date.now;
|
|
14
|
+
const setTimer = opts.setTimer ?? setTimeout;
|
|
15
|
+
const clearTimer = opts.clearTimer ?? clearTimeout;
|
|
16
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
17
|
+
let running = false;
|
|
18
|
+
let pending = false;
|
|
19
|
+
let stopped = false;
|
|
20
|
+
let lastStarted = -Infinity;
|
|
21
|
+
let waiters: Array<() => void> = [];
|
|
22
|
+
|
|
23
|
+
function arm(immediate = false) {
|
|
24
|
+
if (stopped || running || timer !== null || !pending) return;
|
|
25
|
+
const delay = Math.max(
|
|
26
|
+
immediate ? 0 : (opts.delayMs ?? 50),
|
|
27
|
+
(opts.minIntervalMs ?? 250) - (now() - lastStarted)
|
|
28
|
+
);
|
|
29
|
+
timer = setTimer(() => {
|
|
30
|
+
timer = null;
|
|
31
|
+
void drain();
|
|
32
|
+
}, delay);
|
|
33
|
+
timer.unref?.();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function drain() {
|
|
37
|
+
if (stopped || running || !pending) return;
|
|
38
|
+
running = true;
|
|
39
|
+
pending = false;
|
|
40
|
+
lastStarted = now();
|
|
41
|
+
const currentWaiters = waiters;
|
|
42
|
+
waiters = [];
|
|
43
|
+
try {
|
|
44
|
+
await opts.run();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
opts.onError(error);
|
|
47
|
+
} finally {
|
|
48
|
+
running = false;
|
|
49
|
+
for (const resolve of currentWaiters) resolve();
|
|
50
|
+
arm();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
schedule() {
|
|
56
|
+
if (!stopped) {
|
|
57
|
+
pending = true;
|
|
58
|
+
arm();
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
flush(): Promise<void> {
|
|
62
|
+
if (stopped) return Promise.resolve();
|
|
63
|
+
pending = true;
|
|
64
|
+
const done = new Promise<void>((resolve) => waiters.push(resolve));
|
|
65
|
+
if (timer !== null) {
|
|
66
|
+
clearTimer(timer);
|
|
67
|
+
timer = null;
|
|
68
|
+
}
|
|
69
|
+
arm(true);
|
|
70
|
+
return done;
|
|
71
|
+
},
|
|
72
|
+
stop() {
|
|
73
|
+
stopped = true;
|
|
74
|
+
pending = false;
|
|
75
|
+
if (timer !== null) clearTimer(timer);
|
|
76
|
+
timer = null;
|
|
77
|
+
for (const resolve of waiters) resolve();
|
|
78
|
+
waiters = [];
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -105,7 +105,7 @@ const INERT_MEDIA_EXTS = new Set([
|
|
|
105
105
|
'ttf',
|
|
106
106
|
'otf',
|
|
107
107
|
]);
|
|
108
|
-
const COMPANION_TEXT_EXTS = new Set(['css', 'md', 'srt']);
|
|
108
|
+
const COMPANION_TEXT_EXTS = new Set(['css', 'md', 'srt', 'vtt']);
|
|
109
109
|
const CODE_MODULE_EXTS = new Set(['ts', 'tsx', 'js', 'mjs']);
|
|
110
110
|
|
|
111
111
|
/**
|
|
@@ -118,7 +118,15 @@ const CODE_MODULE_EXTS = new Set(['ts', 'tsx', 'js', 'mjs']);
|
|
|
118
118
|
* machine silently loses every photo edit — the exact bug class this module
|
|
119
119
|
* exists to end.
|
|
120
120
|
*/
|
|
121
|
-
|
|
121
|
+
// + the footage analysis, component registry, edit decision list: versioned
|
|
122
|
+
// sidecars (DDR-115) the T32 scale run found never reaching a peer.
|
|
123
|
+
const COMPANION_SIDECAR_SUFFIXES = [
|
|
124
|
+
'.photo.json',
|
|
125
|
+
'.audio.json',
|
|
126
|
+
'.footage.json',
|
|
127
|
+
'.registry.json',
|
|
128
|
+
'.edl.json',
|
|
129
|
+
];
|
|
122
130
|
|
|
123
131
|
/**
|
|
124
132
|
* A DIRECTORY segment must start alphanumeric — the same rule as the hub's
|