@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
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
readFileSync,
|
|
25
25
|
realpathSync,
|
|
26
26
|
renameSync,
|
|
27
|
+
rmdirSync,
|
|
28
|
+
rmSync,
|
|
27
29
|
writeFileSync,
|
|
28
30
|
} from 'node:fs';
|
|
29
31
|
import { readdir } from 'node:fs/promises';
|
|
@@ -36,6 +38,8 @@ import { Y_TYPES } from '../collab/persistence.ts';
|
|
|
36
38
|
import type { Context, LinkedHub } from '../context.ts';
|
|
37
39
|
import { createHistory } from '../history.ts';
|
|
38
40
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
41
|
+
import { acceptedColdStart } from './accepted-cold-start.ts';
|
|
42
|
+
import { type AcceptedLink, createAcceptedLink } from './accepted-link.ts';
|
|
39
43
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
40
44
|
import { isPushableAssetRel, pushAssets } from './asset-push.ts';
|
|
41
45
|
import { atomicWrite } from './atomic-write.ts';
|
|
@@ -43,6 +47,7 @@ import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pai
|
|
|
43
47
|
import {
|
|
44
48
|
canvasPathFromDoc,
|
|
45
49
|
clearMovedTo,
|
|
50
|
+
laneValueFromFile,
|
|
46
51
|
movedToFromDoc,
|
|
47
52
|
stampCanvasPath,
|
|
48
53
|
stampMovedTo,
|
|
@@ -55,7 +60,8 @@ import {
|
|
|
55
60
|
import { createCtlProvider } from './ctl-provider.ts';
|
|
56
61
|
import { createRescanScheduler, diffCanvasSet, type RescanScheduler } from './discovery.ts';
|
|
57
62
|
import { createDocNameResolver } from './doc-name.ts';
|
|
58
|
-
import {
|
|
63
|
+
import { createDocumentDiscovery } from './document-discovery.ts';
|
|
64
|
+
import { createEchoGuard, hashBytes } from './echo-guard.ts';
|
|
59
65
|
import { createFileLedger } from './file-ledger.ts';
|
|
60
66
|
import { createFilePlane, MAX_DORUCEKA_ROWS } from './file-plane.ts';
|
|
61
67
|
import { type FilePullResult, pullFiles } from './file-pull.ts';
|
|
@@ -80,7 +86,9 @@ import {
|
|
|
80
86
|
stateDocumentGone,
|
|
81
87
|
tombstonedSlugs,
|
|
82
88
|
} from './remote-docs.ts';
|
|
89
|
+
import { createRevisionBarrier } from './revision-barrier.ts';
|
|
83
90
|
import { computeSeedProgress } from './seed-progress.ts';
|
|
91
|
+
import { replaySourceOp, type SourceOp } from './source-ops.ts';
|
|
84
92
|
import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
|
|
85
93
|
import { quarantineCanvas } from './tombstone-apply.ts';
|
|
86
94
|
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
@@ -95,6 +103,25 @@ export interface SyncProvider {
|
|
|
95
103
|
* the bridge.
|
|
96
104
|
*/
|
|
97
105
|
readonly awareness?: Awareness;
|
|
106
|
+
/**
|
|
107
|
+
* Did this transaction origin come off the wire from the hub? Accepted
|
|
108
|
+
* revisions use it as a tripwire: under a fenced (read-only) connection any
|
|
109
|
+
* OTHER origin is a local write the hub will drop — a replica diverging.
|
|
110
|
+
* Optional: a test stub without it is simply not checked.
|
|
111
|
+
*/
|
|
112
|
+
isRemoteOrigin?(origin: unknown): boolean;
|
|
113
|
+
/**
|
|
114
|
+
* May a local write to this document reach the hub right now? False before
|
|
115
|
+
* the handshake authenticates and whenever the hub admitted this connection
|
|
116
|
+
* read-only — a write made then is dropped by the hub while the local
|
|
117
|
+
* replica keeps it, which is a divergence nothing would ever repair.
|
|
118
|
+
* Optional: a provider without it is treated as writable.
|
|
119
|
+
*/
|
|
120
|
+
isWritable?(): boolean;
|
|
121
|
+
/** The hub authenticated this connection (again) — scope may have changed. */
|
|
122
|
+
onAuthenticated?(cb: (scope: string) => void): () => void;
|
|
123
|
+
/** Out-of-band messages from the hub on this document's socket. */
|
|
124
|
+
onStateless?(cb: (payload: string) => void): () => void;
|
|
98
125
|
/**
|
|
99
126
|
* Resolves when the first hub sync handshake completes.
|
|
100
127
|
*
|
|
@@ -125,9 +152,10 @@ export interface SyncProvider {
|
|
|
125
152
|
/* ------------------------------------------------- auth-failure classification */
|
|
126
153
|
|
|
127
154
|
/** DDR-102 — rejection classes the runtime distinguishes. `rate-limit` and
|
|
128
|
-
* `generic` are transient (
|
|
129
|
-
*
|
|
130
|
-
*
|
|
155
|
+
* `generic` are transient (the runtime re-authenticates the refused document
|
|
156
|
+
* after one hub window, paced and backed off — see `AUTH_TRANSIENT_RETRY_MS`);
|
|
157
|
+
* `not-authorized` and `invalid-token` are permanent (retrying spams the hub
|
|
158
|
+
* bucket — destroy the provider and re-probe on a slow timer instead). */
|
|
131
159
|
export type AuthFailureClass = 'rate-limit' | 'not-authorized' | 'invalid-token' | 'generic';
|
|
132
160
|
|
|
133
161
|
/** Map a raw hub rejection reason to a class. New hubs send distinct reasons
|
|
@@ -148,6 +176,33 @@ export function classifyAuthFailure(raw: string): AuthFailureClass {
|
|
|
148
176
|
|
|
149
177
|
export const AUTH_WARN_DEBOUNCE_MS = 2_000;
|
|
150
178
|
export const AUTH_REPROBE_MS = 5 * 60 * 1000;
|
|
179
|
+
/**
|
|
180
|
+
* How long a transiently-refused document (`rate-limit` / `generic`) waits
|
|
181
|
+
* before the runtime asks the hub again — one full hub window, since the
|
|
182
|
+
* refusal itself says "retry in up to 60s".
|
|
183
|
+
*
|
|
184
|
+
* The runtime owns this retry because nothing else will do it. These classes
|
|
185
|
+
* used to be left to "the provider's built-in backoff", which does not exist
|
|
186
|
+
* per document under DDR-102 multiplexing: a provider re-sends its token only
|
|
187
|
+
* when the SHARED socket opens, and a partial refusal never closes a socket
|
|
188
|
+
* that the documents which did authenticate keep busy. 16 of 112 canvases sat
|
|
189
|
+
* `auth-rejected` for the life of the process that way (RCA
|
|
190
|
+
* issue-sync-rate-limited-docs-never-retried, 2026-09-11).
|
|
191
|
+
*
|
|
192
|
+
* Repeat refusals of the same document double this, capped at the re-probe
|
|
193
|
+
* interval; a handshake that lands resets it.
|
|
194
|
+
*/
|
|
195
|
+
export const AUTH_TRANSIENT_RETRY_MS = 60_000;
|
|
196
|
+
/** Random spread added to each transient retry, so peers refused in the same
|
|
197
|
+
* burst do not all come back on the window boundary together. */
|
|
198
|
+
export const AUTH_TRANSIENT_JITTER_MS = 15_000;
|
|
199
|
+
/**
|
|
200
|
+
* Most transient re-authentications inside any `AUTH_TRANSIENT_RETRY_MS` span.
|
|
201
|
+
* A quarter of the hub's default valid-token ceiling (600/min): a 16-document
|
|
202
|
+
* retry never notices it, and a 1 000-canvas project cannot turn its own
|
|
203
|
+
* recovery into the next burst (the F1 lesson, applied to this lane).
|
|
204
|
+
*/
|
|
205
|
+
export const AUTH_TRANSIENT_BATCH = 150;
|
|
151
206
|
export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
|
|
152
207
|
/** F1 — minimum wall-clock between renewal attempts. A burst of rejections
|
|
153
208
|
* collapses to one renewal (single-flight); the NEXT burst waits this out. */
|
|
@@ -276,6 +331,11 @@ export const REMOTE_POLL_SOON_MS = 1_500;
|
|
|
276
331
|
* edits arrive in seconds, bounded enough that spam buys almost nothing.
|
|
277
332
|
*/
|
|
278
333
|
export const POKE_COOLDOWN_MS = REMOTE_POLL_MS / 2;
|
|
334
|
+
/** Accepted revisions: how soon, and how often at most, a poke pulls. */
|
|
335
|
+
export const ACCEPTED_POKE_DELAY_MS = 60;
|
|
336
|
+
/** T16 — how long the last tool write of an AI action gets to reach the stage. */
|
|
337
|
+
export const AI_ACTION_SETTLE_MS = 250;
|
|
338
|
+
export const ACCEPTED_POKE_COOLDOWN_MS = 1_000;
|
|
279
339
|
|
|
280
340
|
/**
|
|
281
341
|
* How many previously-unknown canvases one listing may land.
|
|
@@ -311,13 +371,13 @@ export function validExpiry(raw: unknown, nowMs: number = Date.now()): number |
|
|
|
311
371
|
|
|
312
372
|
const AUTH_CLASS_HINT: Record<AuthFailureClass, string> = {
|
|
313
373
|
'rate-limit':
|
|
314
|
-
'
|
|
374
|
+
'refused on volume — retrying these in ~60 s, in paced batches (no restart needed). If this repeats every boot, the hub’s HUB_CONN_RATE_LIMIT is below this project’s size (DDR-102 hubs default to 600/min for valid tokens; every canvas authenticates separately).',
|
|
315
375
|
'not-authorized':
|
|
316
376
|
"the token's scope does not cover these canvases — mint a hub-wide token (`maude hub token generate --scope '*'` or an admin-UI invite) and re-link. Retries stopped; re-probing in 5 min.",
|
|
317
377
|
'invalid-token':
|
|
318
378
|
'the stored token was rejected — re-run `maude design link <url> --token …` on this machine. Retries stopped; re-probing in 5 min.',
|
|
319
379
|
generic:
|
|
320
|
-
'the hub refused auth without a specific reason (older hub?) — check `maude design status` and the hub logs.',
|
|
380
|
+
'the hub refused auth without a specific reason (older hub?) — retrying these in ~60 s; if it keeps refusing, check `maude design status` and the hub logs.',
|
|
321
381
|
};
|
|
322
382
|
|
|
323
383
|
/**
|
|
@@ -422,9 +482,120 @@ export interface SyncRuntime {
|
|
|
422
482
|
* flag-off) — the caller then proceeds with the plain local move.
|
|
423
483
|
*/
|
|
424
484
|
retireForMove(fromSlug: string, toRel: string): Promise<boolean>;
|
|
485
|
+
/**
|
|
486
|
+
* Accepted-revisions mode (DDR-241): propose one lane value of a synced
|
|
487
|
+
* canvas that a privileged API route produced (comments, annotations).
|
|
488
|
+
* `null` when the project is not in that mode or this runtime does not carry
|
|
489
|
+
* the canvas — the caller then writes the shared document as before.
|
|
490
|
+
*/
|
|
491
|
+
proposeLane?(
|
|
492
|
+
slug: string,
|
|
493
|
+
lane: 'comments' | 'annotations' | 'meta',
|
|
494
|
+
text: string,
|
|
495
|
+
opts?: { baseText?: string; writeId?: string }
|
|
496
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string }> | null;
|
|
497
|
+
/**
|
|
498
|
+
* Accepted-revisions mode: a folder operation as ONE project action. `null`
|
|
499
|
+
* when the project is not in that mode; otherwise the (possibly queued)
|
|
500
|
+
* outcome. Canvases inside are moved/deleted by the hub, not one by one.
|
|
501
|
+
*/
|
|
502
|
+
proposeFolder?(
|
|
503
|
+
op:
|
|
504
|
+
| { op: 'dir.create'; path: string }
|
|
505
|
+
| { op: 'dir.delete'; path: string }
|
|
506
|
+
| {
|
|
507
|
+
op: 'dir.move';
|
|
508
|
+
from: string;
|
|
509
|
+
to: string;
|
|
510
|
+
}
|
|
511
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
|
|
512
|
+
/** True while the linked project is in accepted-revisions mode. */
|
|
513
|
+
acceptedMode?(): boolean;
|
|
514
|
+
/** Tripwire count: local writes that reached an accepted replica. */
|
|
515
|
+
acceptedWriteViolations?(): number;
|
|
516
|
+
/** Accepted revisions: the project's logical history (T27). Null when legacy. */
|
|
517
|
+
acceptedHistory?(q: {
|
|
518
|
+
limit?: number;
|
|
519
|
+
before?: number | null;
|
|
520
|
+
path?: string | null;
|
|
521
|
+
}): Promise<AcceptedHistoryRow[] | null>;
|
|
522
|
+
/** A canvas source (repo-relative path) as it stood at a revision. */
|
|
523
|
+
acceptedVersion?(repoRel: string, revision: number): Promise<string | null>;
|
|
524
|
+
/** Restore a canvas to a revision as a NEW action. */
|
|
525
|
+
acceptedRestore?(
|
|
526
|
+
repoRel: string,
|
|
527
|
+
revision: number
|
|
528
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean } | null>;
|
|
529
|
+
/** Personal undo/redo of one of this actor's accepted actions (T28). */
|
|
530
|
+
acceptedUndo?(
|
|
531
|
+
actionId: string,
|
|
532
|
+
redo?: boolean
|
|
533
|
+
): Promise<{
|
|
534
|
+
status: 'accepted' | 'rejected';
|
|
535
|
+
code?: string;
|
|
536
|
+
queued?: boolean;
|
|
537
|
+
actionId?: string;
|
|
538
|
+
} | null>;
|
|
539
|
+
/**
|
|
540
|
+
* T16 — an AI action begins: file changes tools make until it ends are
|
|
541
|
+
* published together as one action (see action-stage.ts). `key` names the
|
|
542
|
+
* participant (an agent turn, a /design:edit run).
|
|
543
|
+
*/
|
|
544
|
+
beginAiAction?(key: string, label: string): void;
|
|
545
|
+
/** T16 — `done` publishes (when the last participant ends); `failed` holds. */
|
|
546
|
+
endAiAction?(
|
|
547
|
+
key: string,
|
|
548
|
+
outcome: 'done' | 'failed'
|
|
549
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string } | null>;
|
|
550
|
+
/**
|
|
551
|
+
* T19 — make the whole project available offline now: file-plane passes
|
|
552
|
+
* back to back (no pacing floor) until nothing is left to pull, or a pass
|
|
553
|
+
* stops making progress. Resolves with what came down and what could not.
|
|
554
|
+
*/
|
|
555
|
+
prepareOffline?(): Promise<{
|
|
556
|
+
complete: boolean;
|
|
557
|
+
pulled: number;
|
|
558
|
+
passes: number;
|
|
559
|
+
failed: number;
|
|
560
|
+
} | null>;
|
|
561
|
+
/** T26 — the accepted action that carried this content of a canvas, if ours. */
|
|
562
|
+
acceptedActionForContent?(repoRel: string, content: string): string | null;
|
|
563
|
+
/** T28 — the two sides of a canvas's held source conflict. */
|
|
564
|
+
conflictVersions?(repoRel: string): { slug: string; mine: string | null; theirs: string } | null;
|
|
565
|
+
/**
|
|
566
|
+
* T28 — resolve it: `mine` proposes the local version on top of the
|
|
567
|
+
* project's (a new action), `theirs` takes the project's version.
|
|
568
|
+
*/
|
|
569
|
+
resolveConflict?(
|
|
570
|
+
repoRel: string,
|
|
571
|
+
choice: 'mine' | 'theirs'
|
|
572
|
+
): Promise<{ status: 'accepted' | 'rejected' | 'taken'; code?: string } | null>;
|
|
573
|
+
/** T16 — the person's decision on a held (unfinished) AI action. */
|
|
574
|
+
resolveAiAction?(choice: 'publish' | 'discard'): Promise<{
|
|
575
|
+
status: 'accepted' | 'rejected' | 'discarded';
|
|
576
|
+
code?: string;
|
|
577
|
+
canvases?: number;
|
|
578
|
+
} | null>;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export interface AcceptedHistoryRow {
|
|
582
|
+
revision: number;
|
|
583
|
+
actionId: string;
|
|
584
|
+
actor: string;
|
|
585
|
+
mine: boolean;
|
|
586
|
+
kind: string;
|
|
587
|
+
label: string | null;
|
|
588
|
+
committedAt: number;
|
|
589
|
+
undoes: string | null;
|
|
590
|
+
/** Design-root-relative canvas paths the action touched. */
|
|
591
|
+
canvases: string[];
|
|
425
592
|
}
|
|
426
593
|
|
|
427
594
|
export interface CreateSyncRuntimeOptions {
|
|
595
|
+
/** Transaction transport override (tests) — defaults to global fetch. */
|
|
596
|
+
transactionFetch?: typeof fetch;
|
|
597
|
+
/** Base backoff between re-sends of an unanswered proposal (tests). */
|
|
598
|
+
transactionRetryMs?: number;
|
|
428
599
|
/** Override the HocuspocusProvider factory (test injection). */
|
|
429
600
|
providerFactory?: ProviderFactory;
|
|
430
601
|
/** Force-enable/disable adopt mode (overrides cfg.linkedHub.adopt). */
|
|
@@ -490,6 +661,14 @@ export interface CreateSyncRuntimeOptions {
|
|
|
490
661
|
renewMinIntervalMs?: number;
|
|
491
662
|
/** F1 — consecutive no-progress renewals before giving up. Default RENEW_MAX_WITHOUT_PROGRESS. */
|
|
492
663
|
renewMaxWithoutProgress?: number;
|
|
664
|
+
/** Base wait before a transient refusal is retried. Default AUTH_TRANSIENT_RETRY_MS. */
|
|
665
|
+
transientRetryMs?: number;
|
|
666
|
+
/** Max random spread per transient retry. Default AUTH_TRANSIENT_JITTER_MS. */
|
|
667
|
+
transientJitterMs?: number;
|
|
668
|
+
/** Transient re-auths allowed per `transientRetryMs` span. Default AUTH_TRANSIENT_BATCH. */
|
|
669
|
+
transientBatch?: number;
|
|
670
|
+
/** Jitter source (test injection). Default Math.random. */
|
|
671
|
+
random?: () => number;
|
|
493
672
|
};
|
|
494
673
|
}
|
|
495
674
|
|
|
@@ -728,8 +907,67 @@ export function createSyncRuntime(
|
|
|
728
907
|
// registry can hand us the canvas's single doc. Flag OFF / no registry / a
|
|
729
908
|
// minimal test registry without getDoc → the proven two-doc path, unchanged.
|
|
730
909
|
const useSharedDoc = !!ctx.sharedDoc && typeof opts.registry?.getDoc === 'function';
|
|
910
|
+
// ---- ACCEPTED REVISIONS (DDR-241) ----------------------------------------
|
|
911
|
+
//
|
|
912
|
+
// Asked of the hub at start and on every poll — never assumed. In
|
|
913
|
+
// `transactions` mode this runtime writes NO shared document: every local
|
|
914
|
+
// change is a proposal through the durable outbox, and the documents change
|
|
915
|
+
// when the project publishes the accepted revision. Shared-doc only: the
|
|
916
|
+
// two-doc agent path has no proposal lane and stays legacy.
|
|
917
|
+
const acceptedLink: AcceptedLink | null = useSharedDoc
|
|
918
|
+
? createAcceptedLink({
|
|
919
|
+
hubUrl: linkedHub.url,
|
|
920
|
+
token: () => token,
|
|
921
|
+
designRoot: ctx.paths.designRoot,
|
|
922
|
+
docNameFor: (slug) => docNameFor(slug),
|
|
923
|
+
fetchImpl: opts.transactionFetch,
|
|
924
|
+
retryMs: opts.transactionRetryMs,
|
|
925
|
+
onStats: (stats) => statusStore?.updateAccepted?.(stats),
|
|
926
|
+
onStage: (summary) => statusStore?.updateAiAction?.(summary),
|
|
927
|
+
})
|
|
928
|
+
: null;
|
|
929
|
+
const acceptedOn = (): boolean => acceptedLink?.on() === true;
|
|
930
|
+
/**
|
|
931
|
+
* T26 — which accepted action carried a given canvas content of ours, so the
|
|
932
|
+
* shell's Cmd+Z of an edit can undo THAT action (effect-aware) instead of
|
|
933
|
+
* swapping the whole file back. Bounded; keyed by slug + lane + content hash.
|
|
934
|
+
*/
|
|
935
|
+
const acceptedByContent = new Map<string, string>();
|
|
936
|
+
const noteAcceptedContent = (slug: string, lane: string, value: string, actionId: string) => {
|
|
937
|
+
acceptedByContent.set(`${slug}|${lane}|${hashBytes(value)}`, actionId);
|
|
938
|
+
if (acceptedByContent.size > 500) {
|
|
939
|
+
const oldest = acceptedByContent.keys().next().value;
|
|
940
|
+
if (oldest !== undefined) acceptedByContent.delete(oldest);
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
/** T14 — one barrier for every projection: a revision shows whole. */
|
|
944
|
+
const revisionBarrier = createRevisionBarrier();
|
|
945
|
+
/** Canvases a folder action already moved/deleted — nothing more to propose for them. */
|
|
946
|
+
const coveredByFolderAction = new Set<string>();
|
|
947
|
+
/** Folder entries this peer has materialized from the project manifest. */
|
|
948
|
+
const knownProjectDirs = new Set<string>();
|
|
949
|
+
/** Local writes that reached an accepted replica (tripwire; should stay 0). */
|
|
950
|
+
let acceptedWriteViolations = 0;
|
|
951
|
+
/** The synced canvas at a repo-relative (or design-root-relative) path. */
|
|
952
|
+
const slugForRepoRel = (rel: string): string | null => {
|
|
953
|
+
const clean = String(rel).replace(/\\/g, '/').replace(/^\/+/, '');
|
|
954
|
+
for (const abs of [
|
|
955
|
+
path.join(ctx.paths.repoRoot, clean),
|
|
956
|
+
path.join(ctx.paths.designRoot, clean),
|
|
957
|
+
]) {
|
|
958
|
+
for (const [slug, d] of descriptors) if (d.html === abs) return slug;
|
|
959
|
+
}
|
|
960
|
+
return null;
|
|
961
|
+
};
|
|
962
|
+
const describeOrigin = (origin: unknown): string => {
|
|
963
|
+
if (origin === null || origin === undefined) return String(origin);
|
|
964
|
+
if (typeof origin === 'string') return origin.slice(0, 40);
|
|
965
|
+
if (typeof origin === 'object') return (origin as object).constructor?.name ?? 'object';
|
|
966
|
+
return typeof origin;
|
|
967
|
+
};
|
|
731
968
|
let fsReader: FsReader | null = null;
|
|
732
969
|
let busUnsub: (() => void) | null = null;
|
|
970
|
+
const activityUnsubs: Array<() => void> = [];
|
|
733
971
|
let started = false;
|
|
734
972
|
let stopped = false;
|
|
735
973
|
// ---- THE LEGACY PUSH CLIENT (journal-less hubs only) --------------------
|
|
@@ -878,6 +1116,39 @@ export function createSyncRuntime(
|
|
|
878
1116
|
string,
|
|
879
1117
|
{ canvas: CanvasDescriptor; canvasPaths: import('./agent.ts').CanvasSyncPaths; doc: Y.Doc }
|
|
880
1118
|
>();
|
|
1119
|
+
/**
|
|
1120
|
+
* Transiently-refused docs (`rate-limit` / `generic`) awaiting the paced
|
|
1121
|
+
* retry — see `AUTH_TRANSIENT_RETRY_MS`. Unlike `rejectedPermanent` the
|
|
1122
|
+
* provider is KEPT until the retry swaps it (a refused provider sends nothing
|
|
1123
|
+
* more, so there is no storm to stop), and the retry acts only if `provider`
|
|
1124
|
+
* is still the live one: a release or another recovery path that replaced it
|
|
1125
|
+
* in the meantime owns the document now. `dueAt` is on the `renewNow` clock.
|
|
1126
|
+
*/
|
|
1127
|
+
const rejectedTransient = new Map<
|
|
1128
|
+
string,
|
|
1129
|
+
{
|
|
1130
|
+
canvas: CanvasDescriptor;
|
|
1131
|
+
canvasPaths: import('./agent.ts').CanvasSyncPaths;
|
|
1132
|
+
doc: Y.Doc;
|
|
1133
|
+
provider: SyncProvider;
|
|
1134
|
+
dueAt: number;
|
|
1135
|
+
}
|
|
1136
|
+
>();
|
|
1137
|
+
/** Consecutive transient refusals per slug — the backoff exponent. Emptied
|
|
1138
|
+
* by `clearRejection`, i.e. by a handshake the hub completed. */
|
|
1139
|
+
const transientStrikes = new Map<string, number>();
|
|
1140
|
+
/** When each transient re-auth of the last window went out — the sliding
|
|
1141
|
+
* budget. Never longer than `transientBatch`. */
|
|
1142
|
+
const transientSpent: number[] = [];
|
|
1143
|
+
let transientRetryTimer: TimerHandle | null = null;
|
|
1144
|
+
/**
|
|
1145
|
+
* First-connect setup still OWED to a pulled canvas whose first handshake
|
|
1146
|
+
* has not landed (`connectCanvas` defers it — the body path is unknown until
|
|
1147
|
+
* the document arrives). Every reconnect passes it back in: without it a
|
|
1148
|
+
* refusal at that first handshake stranded the setup, and the hub's eventual
|
|
1149
|
+
* yes synced the document into memory while nothing ever wrote it to disk.
|
|
1150
|
+
*/
|
|
1151
|
+
const owedSetups = new Map<string, (provider: SyncProvider) => void>();
|
|
881
1152
|
let authWarnTimer: TimerHandle | null = null;
|
|
882
1153
|
let reprobeTimer: TimerHandle | null = null;
|
|
883
1154
|
let renewTimer: TimerHandle | null = null;
|
|
@@ -894,6 +1165,10 @@ export function createSyncRuntime(
|
|
|
894
1165
|
const renewNow = opts.auth?.now ?? (() => Date.now());
|
|
895
1166
|
const renewMinIntervalMs = opts.auth?.renewMinIntervalMs ?? RENEW_MIN_INTERVAL_MS;
|
|
896
1167
|
const renewMaxWithoutProgress = opts.auth?.renewMaxWithoutProgress ?? RENEW_MAX_WITHOUT_PROGRESS;
|
|
1168
|
+
const transientRetryMs = opts.auth?.transientRetryMs ?? AUTH_TRANSIENT_RETRY_MS;
|
|
1169
|
+
const transientJitterMs = opts.auth?.transientJitterMs ?? AUTH_TRANSIENT_JITTER_MS;
|
|
1170
|
+
const transientBatch = Math.max(1, opts.auth?.transientBatch ?? AUTH_TRANSIENT_BATCH);
|
|
1171
|
+
const authRandom = opts.auth?.random ?? Math.random;
|
|
897
1172
|
/** Wall-clock of the last renewal attempt (0 = never). The floor. */
|
|
898
1173
|
let lastRenewAt = 0;
|
|
899
1174
|
/** Successful renewals since the last completed handshake. The cap: a
|
|
@@ -1002,6 +1277,8 @@ export function createSyncRuntime(
|
|
|
1002
1277
|
let pokesSeen = 0;
|
|
1003
1278
|
/** Assigned by `start()`; the seam `pullRemoteNow()` and tests reach. */
|
|
1004
1279
|
let remotePull: (() => Promise<void>) | null = null;
|
|
1280
|
+
let documentDiscovery: ReturnType<typeof createDocumentDiscovery> | null = null;
|
|
1281
|
+
let documentDiscoveryUnsub: (() => void) | null = null;
|
|
1005
1282
|
/**
|
|
1006
1283
|
* Run a file-plane pass shortly, coalesced.
|
|
1007
1284
|
*
|
|
@@ -1037,6 +1314,7 @@ export function createSyncRuntime(
|
|
|
1037
1314
|
let planePassInFlight = false;
|
|
1038
1315
|
let lastPlanePassAt = 0;
|
|
1039
1316
|
let planePassWaiter: Promise<void> | null = null;
|
|
1317
|
+
let lastPlaneResult: import('./file-plane.ts').FilePlaneResult | null = null;
|
|
1040
1318
|
async function runPlanePass(opts?: { floor?: boolean }): Promise<void> {
|
|
1041
1319
|
// Captured, not re-read: `stop()` clears `filePlane`, and a pass that has
|
|
1042
1320
|
// already decided to run must not dereference the field it was cleared to.
|
|
@@ -1061,6 +1339,7 @@ export function createSyncRuntime(
|
|
|
1061
1339
|
const run = (async () => {
|
|
1062
1340
|
try {
|
|
1063
1341
|
const r = await lane.reconcile();
|
|
1342
|
+
lastPlaneResult = r;
|
|
1064
1343
|
planeResultSink?.(r);
|
|
1065
1344
|
} catch (err) {
|
|
1066
1345
|
console.error('[sync/files] pass failed:', err);
|
|
@@ -1106,6 +1385,23 @@ export function createSyncRuntime(
|
|
|
1106
1385
|
*/
|
|
1107
1386
|
function pollRemoteSoon(opts: { cooled?: boolean } = {}): void {
|
|
1108
1387
|
if (stopped || remotePollSoonTimer !== null) return;
|
|
1388
|
+
// ACCEPTED REVISIONS: a poke means "a revision was accepted", and the
|
|
1389
|
+
// canvas it created, moved or deleted — or the folder — must reach this
|
|
1390
|
+
// peer now, not on the next 20 s tick. Still bounded (one pass per
|
|
1391
|
+
// second, with a trailing pass so the last poke of a burst is honoured),
|
|
1392
|
+
// so a hub that pokes in a loop costs one cheap request a second.
|
|
1393
|
+
if (opts.cooled && acceptedOn()) {
|
|
1394
|
+
const since = Date.now() - lastPokePassAt;
|
|
1395
|
+
const wait = Math.max(ACCEPTED_POKE_DELAY_MS, ACCEPTED_POKE_COOLDOWN_MS - since);
|
|
1396
|
+
remotePollSoonTimer = setTimeout(() => {
|
|
1397
|
+
remotePollSoonTimer = null;
|
|
1398
|
+
if (stopped) return;
|
|
1399
|
+
lastPokePassAt = Date.now();
|
|
1400
|
+
void remotePull?.().catch(() => {});
|
|
1401
|
+
}, wait);
|
|
1402
|
+
remotePollSoonTimer.unref?.();
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1109
1405
|
if (opts.cooled) {
|
|
1110
1406
|
const since = Date.now() - lastPokePassAt;
|
|
1111
1407
|
if (since < POKE_COOLDOWN_MS) {
|
|
@@ -1383,7 +1679,17 @@ export function createSyncRuntime(
|
|
|
1383
1679
|
console.log(
|
|
1384
1680
|
`[sync/${slug}] canvas was moved on another machine — stale local copy parked in _trash/ (recoverable).`
|
|
1385
1681
|
);
|
|
1386
|
-
|
|
1682
|
+
}
|
|
1683
|
+
// The destination is materialized and the old body is gone. Receivers
|
|
1684
|
+
// need the semantic move to retarget their open canvas, even if a local
|
|
1685
|
+
// watcher already noticed the two filesystem changes.
|
|
1686
|
+
if (movedTo && !existsSync(desc.html)) {
|
|
1687
|
+
ctx.bus.emit('canvas-list-update', {
|
|
1688
|
+
action: 'moved',
|
|
1689
|
+
fromRel: path.relative(ctx.paths.designRoot, desc.html).split(path.sep).join('/'),
|
|
1690
|
+
rel: movedTo.replace(/\\/g, '/'),
|
|
1691
|
+
fromSlug: slug,
|
|
1692
|
+
});
|
|
1387
1693
|
}
|
|
1388
1694
|
} catch (err) {
|
|
1389
1695
|
// Quarantine is best-effort: the doc guards already made the file
|
|
@@ -1452,6 +1758,9 @@ export function createSyncRuntime(
|
|
|
1452
1758
|
// payload would be a permanent "still syncing" the user can never clear.
|
|
1453
1759
|
monitor?.forgetDoc(slug);
|
|
1454
1760
|
rejectedPermanent.delete(slug);
|
|
1761
|
+
rejectedTransient.delete(slug);
|
|
1762
|
+
transientStrikes.delete(slug);
|
|
1763
|
+
owedSetups.delete(slug);
|
|
1455
1764
|
rejectedReasons.delete(slug);
|
|
1456
1765
|
return true;
|
|
1457
1766
|
}
|
|
@@ -1468,6 +1777,28 @@ export function createSyncRuntime(
|
|
|
1468
1777
|
async function retireForMove(fromSlug: string, toRel: string): Promise<boolean> {
|
|
1469
1778
|
const provider = providers.get(fromSlug);
|
|
1470
1779
|
if (!provider) return false;
|
|
1780
|
+
if (acceptedOn() && acceptedLink) {
|
|
1781
|
+
// The move is a PROJECT action: the hub retires the old document (with
|
|
1782
|
+
// the same `movedTo` stamp receivers already follow) and opens the new
|
|
1783
|
+
// one with the same content. Answered or queued, the local rename then
|
|
1784
|
+
// proceeds; a refusal (path taken, outside every group) keeps it.
|
|
1785
|
+
movingLocally.add(fromSlug);
|
|
1786
|
+
try {
|
|
1787
|
+
if (!coveredByFolderAction.delete(fromSlug)) {
|
|
1788
|
+
const r = await acceptedLink.moveDoc(fromSlug, toRel);
|
|
1789
|
+
if (r.status === 'rejected') {
|
|
1790
|
+
console.warn(`[sync/${fromSlug}] the project did not accept the move (${r.code})`);
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
retiredDocs.add(fromSlug);
|
|
1795
|
+
await releaseOne(fromSlug);
|
|
1796
|
+
console.log(`[sync/${fromSlug}] moved in the project → ${toRel}`);
|
|
1797
|
+
return true;
|
|
1798
|
+
} finally {
|
|
1799
|
+
movingLocally.delete(fromSlug);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1471
1802
|
movingLocally.add(fromSlug);
|
|
1472
1803
|
try {
|
|
1473
1804
|
stampMovedTo(provider.document, toRel, ORIGINS.DISK_PROJECTION);
|
|
@@ -1493,6 +1824,243 @@ export function createSyncRuntime(
|
|
|
1493
1824
|
}
|
|
1494
1825
|
}
|
|
1495
1826
|
|
|
1827
|
+
// ---- project folders (accepted revisions) --------------------------------
|
|
1828
|
+
//
|
|
1829
|
+
// A folder is a manifest entry, so an EMPTY folder a peer created exists in
|
|
1830
|
+
// the project and appears here — no placeholder canvas. The hub is
|
|
1831
|
+
// untrusted (DDR-054): every path is re-checked against the declared canvas
|
|
1832
|
+
// groups and the real design root before a directory is made, and removal
|
|
1833
|
+
// only ever takes a folder that holds nothing but its `.gitkeep`.
|
|
1834
|
+
const projectDirsFile = path.join(ctx.paths.designRoot, '_state', 'project-dirs.json');
|
|
1835
|
+
try {
|
|
1836
|
+
const saved = JSON.parse(readFileSync(projectDirsFile, 'utf8')) as unknown;
|
|
1837
|
+
if (Array.isArray(saved))
|
|
1838
|
+
for (const d of saved) if (typeof d === 'string') knownProjectDirs.add(d);
|
|
1839
|
+
} catch {
|
|
1840
|
+
/* first run — nothing materialized yet */
|
|
1841
|
+
}
|
|
1842
|
+
const saveProjectDirs = (): void => {
|
|
1843
|
+
try {
|
|
1844
|
+
mkdirSync(path.dirname(projectDirsFile), { recursive: true });
|
|
1845
|
+
writeFileSync(projectDirsFile, JSON.stringify([...knownProjectDirs].sort()));
|
|
1846
|
+
} catch {
|
|
1847
|
+
/* best-effort — a lost file only delays one removal */
|
|
1848
|
+
}
|
|
1849
|
+
};
|
|
1850
|
+
const projectDirAbs = (rel: string): string | null => {
|
|
1851
|
+
if (typeof rel !== 'string' || !rel || rel.length > 512) return null;
|
|
1852
|
+
const parts = rel.split('/');
|
|
1853
|
+
if (parts.length > 16) return null;
|
|
1854
|
+
for (const part of parts) {
|
|
1855
|
+
if (!part || part === '.' || part === '..' || part.startsWith('_') || part.startsWith('.'))
|
|
1856
|
+
return null;
|
|
1857
|
+
if (/[\\\0:*?"<>|]/.test(part) || part.length > 255) return null;
|
|
1858
|
+
}
|
|
1859
|
+
const groups = (ctx.cfg.canvasGroups ?? []).map((g) => g.path.replace(/^\/+|\/+$/g, ''));
|
|
1860
|
+
if (!groups.some((g) => g && (rel === g || rel.startsWith(`${g}/`)))) return null;
|
|
1861
|
+
const abs = path.resolve(ctx.paths.designRoot, ...parts);
|
|
1862
|
+
const root = path.resolve(ctx.paths.designRoot);
|
|
1863
|
+
if (!abs.startsWith(`${root}${path.sep}`)) return null;
|
|
1864
|
+
const real = realpathOfDeepestExisting(abs);
|
|
1865
|
+
const realRoot = realpathOfDeepestExisting(root);
|
|
1866
|
+
if (real !== realRoot && !real.startsWith(`${realRoot}${path.sep}`)) return null;
|
|
1867
|
+
return abs;
|
|
1868
|
+
};
|
|
1869
|
+
const designRelPosix = ctx.paths.designRel.replace(/^\.\//, '').replace(/^\/+|\/+$/g, '');
|
|
1870
|
+
function applyProjectDirs(dirs: readonly string[]): void {
|
|
1871
|
+
const next = new Set(dirs);
|
|
1872
|
+
let changed = false;
|
|
1873
|
+
for (const rel of next) {
|
|
1874
|
+
if (knownProjectDirs.has(rel)) continue;
|
|
1875
|
+
const abs = projectDirAbs(rel);
|
|
1876
|
+
if (!abs) continue;
|
|
1877
|
+
try {
|
|
1878
|
+
if (!existsSync(abs)) {
|
|
1879
|
+
mkdirSync(abs, { recursive: true });
|
|
1880
|
+
writeFileSync(path.join(abs, '.gitkeep'), '');
|
|
1881
|
+
ctx.bus.emit('canvas-list-update', {
|
|
1882
|
+
action: 'mkdir',
|
|
1883
|
+
dir: path.posix.join(designRelPosix, rel),
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
knownProjectDirs.add(rel);
|
|
1887
|
+
changed = true;
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
console.warn(`[sync] could not create project folder ${rel}:`, err);
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
// Deepest first, so a removed parent is attempted after its children.
|
|
1893
|
+
const gone = [...knownProjectDirs]
|
|
1894
|
+
.filter((d) => !next.has(d))
|
|
1895
|
+
.sort((a, b) => b.length - a.length);
|
|
1896
|
+
for (const rel of gone) {
|
|
1897
|
+
const abs = projectDirAbs(rel);
|
|
1898
|
+
if (abs && existsSync(abs)) {
|
|
1899
|
+
let entries: string[] = [];
|
|
1900
|
+
try {
|
|
1901
|
+
entries = readdirSync(abs).filter((e) => e !== '.DS_Store');
|
|
1902
|
+
} catch {
|
|
1903
|
+
continue;
|
|
1904
|
+
}
|
|
1905
|
+
// Canvases inside leave through their own deletion/move first; until
|
|
1906
|
+
// then the folder stays and the next poll tries again.
|
|
1907
|
+
if (entries.some((e) => e !== '.gitkeep')) continue;
|
|
1908
|
+
try {
|
|
1909
|
+
for (const e of readdirSync(abs)) rmSync(path.join(abs, e), { force: true });
|
|
1910
|
+
rmdirSync(abs);
|
|
1911
|
+
ctx.bus.emit('canvas-list-update', {
|
|
1912
|
+
action: 'removed-folder',
|
|
1913
|
+
dir: path.posix.join(designRelPosix, rel),
|
|
1914
|
+
});
|
|
1915
|
+
} catch {
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
knownProjectDirs.delete(rel);
|
|
1920
|
+
changed = true;
|
|
1921
|
+
}
|
|
1922
|
+
if (changed) saveProjectDirs();
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
/**
|
|
1926
|
+
* The document listing is the hub's STORAGE view: a row appears after
|
|
1927
|
+
* Hocuspocus persists the document, seconds after it exists. Under accepted
|
|
1928
|
+
* revisions the manifest is the project's own statement of what exists, so a
|
|
1929
|
+
* canvas accepted a moment ago is pulled on the next poll, not the one after
|
|
1930
|
+
* its row is written.
|
|
1931
|
+
*/
|
|
1932
|
+
function withAcceptedDocs(
|
|
1933
|
+
listing: Awaited<ReturnType<typeof fetchRemoteListing>>
|
|
1934
|
+
): Awaited<ReturnType<typeof fetchRemoteListing>> {
|
|
1935
|
+
const manifest = acceptedOn() ? acceptedLink?.manifest : null;
|
|
1936
|
+
if (!manifest) return listing;
|
|
1937
|
+
// A document the project RETIRED (moved away) is not a canvas to fetch,
|
|
1938
|
+
// even while its storage row lingers — its successor is in the manifest.
|
|
1939
|
+
const retired = new Set(manifest.docs.filter((d) => d.retired).map((d) => d.doc));
|
|
1940
|
+
const documents = (listing?.documents ?? []).filter((d) => !retired.has(d.name));
|
|
1941
|
+
const names = new Set(documents.map((d) => d.name));
|
|
1942
|
+
for (const d of manifest.docs) {
|
|
1943
|
+
if (d.retired || names.has(d.doc)) continue;
|
|
1944
|
+
documents.push({ name: d.doc, bytes: 1 });
|
|
1945
|
+
names.add(d.doc);
|
|
1946
|
+
}
|
|
1947
|
+
return { ...(listing ?? { tombstones: [] }), documents, tombstones: listing?.tombstones ?? [] };
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/**
|
|
1951
|
+
* Re-ask the save mode, and hand every held write back to its projection —
|
|
1952
|
+
* as a proposal when the project turned out to be in transactions mode, as
|
|
1953
|
+
* an ordinary import when the connection simply became writable.
|
|
1954
|
+
*/
|
|
1955
|
+
let modeRefresh: Promise<void> | null = null;
|
|
1956
|
+
function refreshAcceptedMode(): Promise<void> {
|
|
1957
|
+
if (!acceptedLink) return Promise.resolve();
|
|
1958
|
+
if (modeRefresh) return modeRefresh;
|
|
1959
|
+
modeRefresh = acceptedLink
|
|
1960
|
+
.refresh()
|
|
1961
|
+
.then(() => {
|
|
1962
|
+
if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
1963
|
+
for (const p of projections.values()) p.retryDeferred();
|
|
1964
|
+
})
|
|
1965
|
+
.finally(() => {
|
|
1966
|
+
modeRefresh = null;
|
|
1967
|
+
});
|
|
1968
|
+
return modeRefresh;
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
/**
|
|
1972
|
+
* A folder the Maude UI made on this machine (it carries the `.gitkeep`
|
|
1973
|
+
* `createFolder` writes) that the project does not list yet — made while
|
|
1974
|
+
* the project was legacy, or while this machine was offline. It joins the
|
|
1975
|
+
* project as ONE action at cold start. Only additive: a folder the manifest
|
|
1976
|
+
* lists and this disk lacks is never proposed as a deletion from here.
|
|
1977
|
+
*/
|
|
1978
|
+
function proposeLocalFolders(): void {
|
|
1979
|
+
if (!acceptedLink) return;
|
|
1980
|
+
const listed = new Set(acceptedLink.manifest?.dirs ?? []);
|
|
1981
|
+
const found: string[] = [];
|
|
1982
|
+
const walk = (abs: string, rel: string, depth: number) => {
|
|
1983
|
+
if (depth > 16 || found.length >= 200) return;
|
|
1984
|
+
let entries: import('node:fs').Dirent[];
|
|
1985
|
+
try {
|
|
1986
|
+
entries = readdirSync(abs, { withFileTypes: true });
|
|
1987
|
+
} catch {
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
for (const e of entries) {
|
|
1991
|
+
if (!e.isDirectory() || e.name.startsWith('_') || e.name.startsWith('.')) continue;
|
|
1992
|
+
const childRel = `${rel}/${e.name}`;
|
|
1993
|
+
const childAbs = path.join(abs, e.name);
|
|
1994
|
+
if (
|
|
1995
|
+
!listed.has(childRel) &&
|
|
1996
|
+
existsSync(path.join(childAbs, '.gitkeep')) &&
|
|
1997
|
+
projectDirAbs(childRel)
|
|
1998
|
+
) {
|
|
1999
|
+
found.push(childRel);
|
|
2000
|
+
}
|
|
2001
|
+
walk(childAbs, childRel, depth + 1);
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
for (const g of ctx.cfg.canvasGroups ?? []) {
|
|
2005
|
+
const rel = g.path.replace(/^\/+|\/+$/g, '');
|
|
2006
|
+
if (rel) walk(path.join(ctx.paths.designRoot, ...rel.split('/')), rel, 1);
|
|
2007
|
+
}
|
|
2008
|
+
if (found.length === 0) return;
|
|
2009
|
+
void acceptedLink.dirsCreate(found).then((r) => {
|
|
2010
|
+
if (r.status === 'accepted') {
|
|
2011
|
+
for (const d of found) knownProjectDirs.add(d);
|
|
2012
|
+
saveProjectDirs();
|
|
2013
|
+
console.log(`[sync] added ${found.length} local folder(s) to the project.`);
|
|
2014
|
+
}
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
function proposeFolder(
|
|
2019
|
+
op:
|
|
2020
|
+
| { op: 'dir.create'; path: string }
|
|
2021
|
+
| { op: 'dir.delete'; path: string }
|
|
2022
|
+
| {
|
|
2023
|
+
op: 'dir.move';
|
|
2024
|
+
from: string;
|
|
2025
|
+
to: string;
|
|
2026
|
+
}
|
|
2027
|
+
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null {
|
|
2028
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
2029
|
+
const root = op.op === 'dir.move' ? op.from : op.path;
|
|
2030
|
+
const covered: string[] = [];
|
|
2031
|
+
if (op.op !== 'dir.create') {
|
|
2032
|
+
for (const [slug, d] of descriptors) {
|
|
2033
|
+
const rel = path.relative(ctx.paths.designRoot, d.html).split(path.sep).join('/');
|
|
2034
|
+
if (rel === root || rel.startsWith(`${root}/`)) covered.push(slug);
|
|
2035
|
+
}
|
|
2036
|
+
for (const slug of covered) coveredByFolderAction.add(slug);
|
|
2037
|
+
}
|
|
2038
|
+
const answer =
|
|
2039
|
+
op.op === 'dir.create'
|
|
2040
|
+
? acceptedLink.dirCreate(op.path)
|
|
2041
|
+
: op.op === 'dir.delete'
|
|
2042
|
+
? acceptedLink.dirDelete(op.path)
|
|
2043
|
+
: acceptedLink.dirMove(op.from, op.to);
|
|
2044
|
+
return answer.then((r) => {
|
|
2045
|
+
if (r.status === 'rejected') {
|
|
2046
|
+
for (const slug of covered) coveredByFolderAction.delete(slug);
|
|
2047
|
+
return r;
|
|
2048
|
+
}
|
|
2049
|
+
const under = (d: string) => d === root || d.startsWith(`${root}/`);
|
|
2050
|
+
if (op.op === 'dir.create') knownProjectDirs.add(op.path);
|
|
2051
|
+
else {
|
|
2052
|
+
for (const d of [...knownProjectDirs]) {
|
|
2053
|
+
if (!under(d)) continue;
|
|
2054
|
+
knownProjectDirs.delete(d);
|
|
2055
|
+
if (op.op === 'dir.move') knownProjectDirs.add(`${op.to}${d.slice(op.from.length)}`);
|
|
2056
|
+
}
|
|
2057
|
+
if (op.op === 'dir.move') knownProjectDirs.add(op.to);
|
|
2058
|
+
}
|
|
2059
|
+
saveProjectDirs();
|
|
2060
|
+
return r;
|
|
2061
|
+
});
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1496
2064
|
async function start(): Promise<void> {
|
|
1497
2065
|
if (started || stopped) return;
|
|
1498
2066
|
started = true;
|
|
@@ -1547,7 +2115,24 @@ export function createSyncRuntime(
|
|
|
1547
2115
|
// credential in place, so reading it at call time is what every other hub
|
|
1548
2116
|
// call here does. Identical at boot; correct if start() ever re-runs after
|
|
1549
2117
|
// a renewal (and it types, which `resolvedToken`'s `string | null` did not).
|
|
1550
|
-
const
|
|
2118
|
+
const remoteListingRaw = await fetchRemoteListing(linkedHub.url, token);
|
|
2119
|
+
if (acceptedLink) {
|
|
2120
|
+
await acceptedLink.refresh();
|
|
2121
|
+
if (acceptedOn()) {
|
|
2122
|
+
console.log(
|
|
2123
|
+
'[sync] this project saves through accepted revisions — local changes are proposed, never written into the shared document.'
|
|
2124
|
+
);
|
|
2125
|
+
// Work a previous run left unanswered goes first, in creation order —
|
|
2126
|
+
// before any cold start can propose something built on top of it.
|
|
2127
|
+
void acceptedLink.client.drainOutbox().then((results) => {
|
|
2128
|
+
if (results.length)
|
|
2129
|
+
console.log(`[sync/tx] resent ${results.length} unanswered change(s).`);
|
|
2130
|
+
});
|
|
2131
|
+
applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
2132
|
+
proposeLocalFolders();
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
const remoteListing = withAcceptedDocs(remoteListingRaw);
|
|
1551
2136
|
// BOOT LEARNS THE DELETIONS BEFORE IT PULLS ANYTHING. The peer-side apply
|
|
1552
2137
|
// lives further down (it needs the live descriptor map), so this boot pass
|
|
1553
2138
|
// only has to make sure the pull does not fetch a canvas the project has
|
|
@@ -1798,6 +2383,9 @@ export function createSyncRuntime(
|
|
|
1798
2383
|
broadcast: (payload) => ctx.bus.emit('sync:status', payload),
|
|
1799
2384
|
});
|
|
1800
2385
|
const store = statusStore;
|
|
2386
|
+
// An unfinished AI action restored from the last session (T16).
|
|
2387
|
+
const restoredStage = acceptedLink?.stage.summary();
|
|
2388
|
+
if (restoredStage) store.updateAiAction?.(restoredStage);
|
|
1801
2389
|
|
|
1802
2390
|
// DDR-079 — TSX sync defaults ON, so every linked non-loopback project that
|
|
1803
2391
|
// ships .tsx broadcasts the WebRTC/self-nav exfil residual (the sandbox
|
|
@@ -1856,6 +2444,28 @@ export function createSyncRuntime(
|
|
|
1856
2444
|
});
|
|
1857
2445
|
fsReader = reader;
|
|
1858
2446
|
|
|
2447
|
+
// Accepted revisions: an API source write announces itself before it
|
|
2448
|
+
// lands (`activity:suppress`, the same signal the activity rim uses).
|
|
2449
|
+
const projectionForRel = (rel: unknown): DocProjection | undefined => {
|
|
2450
|
+
if (typeof rel !== 'string' || !rel) return undefined;
|
|
2451
|
+
const abs = path.join(ctx.paths.designRoot, rel);
|
|
2452
|
+
for (const [slug, d] of descriptors) if (d.html === abs) return projections.get(slug);
|
|
2453
|
+
return undefined;
|
|
2454
|
+
};
|
|
2455
|
+
const unsubSuppress = ctx.bus.on('activity:suppress', (rel: unknown) => {
|
|
2456
|
+
if (acceptedOn()) projectionForRel(rel)?.noteLocalWrite();
|
|
2457
|
+
});
|
|
2458
|
+
// T24 — the UI operation behind that write (sync/source-ops).
|
|
2459
|
+
const unsubSourceOp = ctx.bus.on('source-op', (payload: unknown) => {
|
|
2460
|
+
const p = payload as { rel?: unknown; op?: SourceOp } | null;
|
|
2461
|
+
if (acceptedOn() && p?.op) projectionForRel(p.rel)?.noteSourceOp(p.op);
|
|
2462
|
+
});
|
|
2463
|
+
activityUnsubs.push(unsubSourceOp);
|
|
2464
|
+
const unsubUnsuppress = ctx.bus.on('activity:unsuppress', (rel: unknown) => {
|
|
2465
|
+
projectionForRel(rel)?.cancelLocalWrite();
|
|
2466
|
+
});
|
|
2467
|
+
activityUnsubs.push(unsubSuppress, unsubUnsuppress);
|
|
2468
|
+
|
|
1859
2469
|
busUnsub = ctx.bus.on('fs:any', (rel: string) => {
|
|
1860
2470
|
reader.notify(rel);
|
|
1861
2471
|
// AN ASSET THAT APPEARS AFTER BOOT HAS TO GO UP NOW, NOT NEXT LAUNCH.
|
|
@@ -1968,7 +2578,12 @@ export function createSyncRuntime(
|
|
|
1968
2578
|
rejectedPermanent.clear();
|
|
1969
2579
|
for (const entry of entries) {
|
|
1970
2580
|
mon.noteDocState(entry.canvas.slug, 'pending');
|
|
1971
|
-
void connectCanvas(
|
|
2581
|
+
void connectCanvas(
|
|
2582
|
+
entry.canvas,
|
|
2583
|
+
entry.canvasPaths,
|
|
2584
|
+
entry.doc,
|
|
2585
|
+
owedSetups.get(entry.canvas.slug)
|
|
2586
|
+
).catch((err) => {
|
|
1972
2587
|
console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
|
|
1973
2588
|
});
|
|
1974
2589
|
}
|
|
@@ -1982,6 +2597,72 @@ export function createSyncRuntime(
|
|
|
1982
2597
|
}, reprobeMs);
|
|
1983
2598
|
};
|
|
1984
2599
|
|
|
2600
|
+
/**
|
|
2601
|
+
* Arm the transient lane's ONE timer (single-flight) for the earliest due
|
|
2602
|
+
* document — or, when the sliding budget is spent, for the moment its
|
|
2603
|
+
* oldest re-auth leaves the window — plus jitter.
|
|
2604
|
+
*/
|
|
2605
|
+
const armTransientRetry = (): void => {
|
|
2606
|
+
if (transientRetryTimer !== null || stopped || rejectedTransient.size === 0) return;
|
|
2607
|
+
let at = Number.POSITIVE_INFINITY;
|
|
2608
|
+
for (const e of rejectedTransient.values()) at = Math.min(at, e.dueAt);
|
|
2609
|
+
const oldestSpend = transientSpent[0];
|
|
2610
|
+
if (transientSpent.length >= transientBatch && oldestSpend !== undefined) {
|
|
2611
|
+
at = Math.max(at, oldestSpend + transientRetryMs);
|
|
2612
|
+
}
|
|
2613
|
+
const delay = Math.max(0, at - renewNow()) + Math.floor(authRandom() * transientJitterMs);
|
|
2614
|
+
transientRetryTimer = authSetTimer(
|
|
2615
|
+
() => {
|
|
2616
|
+
transientRetryTimer = null;
|
|
2617
|
+
retryTransientNow();
|
|
2618
|
+
},
|
|
2619
|
+
Math.min(MAX_TIMER_DELAY_MS, delay)
|
|
2620
|
+
);
|
|
2621
|
+
};
|
|
2622
|
+
|
|
2623
|
+
/**
|
|
2624
|
+
* Re-authenticate the due transiently-refused documents — only those, never
|
|
2625
|
+
* the shared socket — up to the budget, oldest first; the rest wait for the
|
|
2626
|
+
* next arm. The swap is the re-probe's: destroy the refused provider and
|
|
2627
|
+
* reconnect on the SAME doc, so the agent and projection wiring carry over.
|
|
2628
|
+
* A provider attached to an already-open socket sends its token at once
|
|
2629
|
+
* (`HocuspocusProviderWebsocket.attach` → `provider.onOpen`).
|
|
2630
|
+
*/
|
|
2631
|
+
const retryTransientNow = (): void => {
|
|
2632
|
+
if (stopped) return;
|
|
2633
|
+
const now = renewNow();
|
|
2634
|
+
while (transientSpent.length > 0 && now - (transientSpent[0] ?? now) >= transientRetryMs) {
|
|
2635
|
+
transientSpent.shift();
|
|
2636
|
+
}
|
|
2637
|
+
const due = [...rejectedTransient.entries()]
|
|
2638
|
+
.filter(([, e]) => e.dueAt <= now)
|
|
2639
|
+
.sort((x, y) => x[1].dueAt - y[1].dueAt)
|
|
2640
|
+
.slice(0, Math.max(0, transientBatch - transientSpent.length));
|
|
2641
|
+
for (const [slug, entry] of due) {
|
|
2642
|
+
rejectedTransient.delete(slug);
|
|
2643
|
+
if (providers.get(slug) !== entry.provider) continue;
|
|
2644
|
+
transientSpent.push(now);
|
|
2645
|
+
providers.delete(slug);
|
|
2646
|
+
try {
|
|
2647
|
+
entry.provider.destroy();
|
|
2648
|
+
} catch {
|
|
2649
|
+
/* best-effort */
|
|
2650
|
+
}
|
|
2651
|
+
mon.noteDocState(slug, 'pending');
|
|
2652
|
+
// Not a boot connect — a retry must never join the one-shot boot summary.
|
|
2653
|
+
void connectCanvas(
|
|
2654
|
+
entry.canvas,
|
|
2655
|
+
entry.canvasPaths,
|
|
2656
|
+
entry.doc,
|
|
2657
|
+
owedSetups.get(slug),
|
|
2658
|
+
false
|
|
2659
|
+
).catch((err) => {
|
|
2660
|
+
console.error(`[sync/${slug}] transient re-auth failed:`, err);
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
armTransientRetry();
|
|
2664
|
+
};
|
|
2665
|
+
|
|
1985
2666
|
/**
|
|
1986
2667
|
* Renew the hub credential in place — single-flight, silent, and never
|
|
1987
2668
|
* worse than failure: an unrenewable credential leaves the stored one
|
|
@@ -2084,8 +2765,9 @@ export function createSyncRuntime(
|
|
|
2084
2765
|
if (authWarnTimer === null) authWarnTimer = authSetTimer(flushAuthWarn, warnDebounceMs);
|
|
2085
2766
|
// Permanent classes: retrying only spams the hub (and its rate bucket) —
|
|
2086
2767
|
// destroy the provider and re-probe on a slow timer. Transient classes
|
|
2087
|
-
// (rate-limit / generic)
|
|
2768
|
+
// (rate-limit / generic) get the paced retry below.
|
|
2088
2769
|
if (reasonClass === 'not-authorized' || reasonClass === 'invalid-token') {
|
|
2770
|
+
rejectedTransient.delete(canvas.slug);
|
|
2089
2771
|
if (!rejectedPermanent.has(canvas.slug)) {
|
|
2090
2772
|
rejectedPermanent.set(canvas.slug, { canvas, canvasPaths, doc: provider.document });
|
|
2091
2773
|
providers.delete(canvas.slug);
|
|
@@ -2107,6 +2789,31 @@ export function createSyncRuntime(
|
|
|
2107
2789
|
if (renewed) reprobeNow();
|
|
2108
2790
|
});
|
|
2109
2791
|
}
|
|
2792
|
+
} else if (!rejectedTransient.has(canvas.slug)) {
|
|
2793
|
+
// THE HUB MAY WELL SAY YES NEXT WINDOW — BUT NOTHING WOULD ASK IT.
|
|
2794
|
+
//
|
|
2795
|
+
// These classes used to be left to "the provider's built-in backoff".
|
|
2796
|
+
// Under DDR-102 multiplexing that backoff is the SOCKET's reconnect
|
|
2797
|
+
// loop, and a partial refusal never closes a socket the other
|
|
2798
|
+
// documents keep healthy — so a refused document stayed refused until
|
|
2799
|
+
// the process ended. Queue it for the runtime's own retry instead: one
|
|
2800
|
+
// hub window out, doubling on each repeat (capped at the re-probe
|
|
2801
|
+
// interval), reset by a handshake that lands. The `has` guard keeps a
|
|
2802
|
+
// repeated event for one refusal from counting as a second strike.
|
|
2803
|
+
const strikes = (transientStrikes.get(canvas.slug) ?? 0) + 1;
|
|
2804
|
+
transientStrikes.set(canvas.slug, strikes);
|
|
2805
|
+
const wait = Math.min(
|
|
2806
|
+
Math.max(transientRetryMs, reprobeMs),
|
|
2807
|
+
transientRetryMs * 2 ** (strikes - 1)
|
|
2808
|
+
);
|
|
2809
|
+
rejectedTransient.set(canvas.slug, {
|
|
2810
|
+
canvas,
|
|
2811
|
+
canvasPaths,
|
|
2812
|
+
doc: provider.document,
|
|
2813
|
+
provider,
|
|
2814
|
+
dueAt: renewNow() + wait,
|
|
2815
|
+
});
|
|
2816
|
+
armTransientRetry();
|
|
2110
2817
|
}
|
|
2111
2818
|
};
|
|
2112
2819
|
|
|
@@ -2126,6 +2833,8 @@ export function createSyncRuntime(
|
|
|
2126
2833
|
*/
|
|
2127
2834
|
const clearRejection = (slug: string): void => {
|
|
2128
2835
|
rejectedPermanent.delete(slug);
|
|
2836
|
+
rejectedTransient.delete(slug);
|
|
2837
|
+
transientStrikes.delete(slug);
|
|
2129
2838
|
rejectedAny.delete(slug);
|
|
2130
2839
|
if (!rejectedReasons.delete(slug)) return;
|
|
2131
2840
|
console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
|
|
@@ -2138,6 +2847,8 @@ export function createSyncRuntime(
|
|
|
2138
2847
|
* the canvas its sync.
|
|
2139
2848
|
*/
|
|
2140
2849
|
const stampFromLocalFile = (doc: Y.Doc, htmlAbs: string): void => {
|
|
2850
|
+
// The accepted replica carries the path the PROJECT gave the document.
|
|
2851
|
+
if (acceptedOn()) return;
|
|
2141
2852
|
try {
|
|
2142
2853
|
const rel = path.relative(ctx.paths.designRoot, htmlAbs).split(path.sep).join('/');
|
|
2143
2854
|
if (rel && !rel.startsWith('..')) stampCanvasPath(doc, rel, ORIGINS.DISK_PROJECTION);
|
|
@@ -2190,7 +2901,7 @@ export function createSyncRuntime(
|
|
|
2190
2901
|
canvas.html &&
|
|
2191
2902
|
path.resolve(canvas.html) === movedAbs
|
|
2192
2903
|
) {
|
|
2193
|
-
if (clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
|
|
2904
|
+
if (!acceptedOn() && clearMovedTo(provider.document, ORIGINS.MIGRATION)) {
|
|
2194
2905
|
console.log(
|
|
2195
2906
|
`[sync/${canvas.slug}] this document is stamped as moved to its OWN path — clearing the stale retirement and keeping the canvas.`
|
|
2196
2907
|
);
|
|
@@ -2214,7 +2925,28 @@ export function createSyncRuntime(
|
|
|
2214
2925
|
mon.noteDocState(canvas.slug, 'pending');
|
|
2215
2926
|
const projection = projections.get(canvas.slug);
|
|
2216
2927
|
const agent = agents.get(canvas.slug);
|
|
2217
|
-
if (projection) {
|
|
2928
|
+
if (projection && acceptedOn() && acceptedLink) {
|
|
2929
|
+
const docName = docNameFor(canvas.slug);
|
|
2930
|
+
let inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
|
|
2931
|
+
if (!inProject) {
|
|
2932
|
+
// A canvas adopted after boot: ask again before creating anything.
|
|
2933
|
+
await acceptedLink.refresh();
|
|
2934
|
+
inProject = acceptedLink.manifest?.docs.some((d) => d.doc === docName && !d.retired);
|
|
2935
|
+
}
|
|
2936
|
+
const rel = path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/');
|
|
2937
|
+
await acceptedColdStart({
|
|
2938
|
+
slug: canvas.slug,
|
|
2939
|
+
doc: provider.document,
|
|
2940
|
+
paths: canvasPaths,
|
|
2941
|
+
rel,
|
|
2942
|
+
inProject: inProject === true,
|
|
2943
|
+
projection,
|
|
2944
|
+
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
2945
|
+
journal: journal ?? undefined,
|
|
2946
|
+
createDoc: (lanes) => acceptedLink.createDoc(canvas.slug, rel, lanes),
|
|
2947
|
+
});
|
|
2948
|
+
projection.reconcile();
|
|
2949
|
+
} else if (projection) {
|
|
2218
2950
|
// Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
|
|
2219
2951
|
// materializing: escapes the duplication trap by picking ONE source
|
|
2220
2952
|
// inside a MIGRATION transaction. DDR-102: body divergence now takes
|
|
@@ -2237,6 +2969,9 @@ export function createSyncRuntime(
|
|
|
2237
2969
|
}
|
|
2238
2970
|
},
|
|
2239
2971
|
onConflict: (info) => store.addConflict(info),
|
|
2972
|
+
// An unmergeable local candidate: keep both, and let the projection
|
|
2973
|
+
// report and hold it from the shared base (T2).
|
|
2974
|
+
onHold: (base) => projection.adoptBase(base),
|
|
2240
2975
|
hubHasState: (slug) => hubHolds(hubDocIndex, docNameFor(slug)),
|
|
2241
2976
|
});
|
|
2242
2977
|
if (result === 'local-adopt') {
|
|
@@ -2246,6 +2981,16 @@ export function createSyncRuntime(
|
|
|
2246
2981
|
`[sync/${canvas.slug}] shared-doc: not seeding — the hub already holds this ` +
|
|
2247
2982
|
'document; waiting for its state to arrive.'
|
|
2248
2983
|
);
|
|
2984
|
+
} else if (result === 'conflict-merged') {
|
|
2985
|
+
console.log(
|
|
2986
|
+
`[sync/${canvas.slug}] shared-doc: merged a local edit with the project's newer ` +
|
|
2987
|
+
`version (both sides were in _history/${canvas.slug}/ first).`
|
|
2988
|
+
);
|
|
2989
|
+
} else if (result === 'conflict-held') {
|
|
2990
|
+
console.warn(
|
|
2991
|
+
`[sync/${canvas.slug}] shared-doc: a local edit overlaps the project's newer version — ` +
|
|
2992
|
+
'both kept; the canvas waits for a resolving save.'
|
|
2993
|
+
);
|
|
2249
2994
|
} else if (result === 'conflict-local-wins' || result === 'conflict-hub-wins') {
|
|
2250
2995
|
console.warn(
|
|
2251
2996
|
`[sync/${canvas.slug}] shared-doc: diverged — kept the ${
|
|
@@ -2344,8 +3089,8 @@ export function createSyncRuntime(
|
|
|
2344
3089
|
// REFUSED IS REFUSED, WHATEVER THE CLASS.
|
|
2345
3090
|
//
|
|
2346
3091
|
// This guard read `rejectedPermanent`, and that map holds only the two
|
|
2347
|
-
// PERMANENT classes — `
|
|
2348
|
-
//
|
|
3092
|
+
// PERMANENT classes — `generic` and `rate-limit` keep their provider
|
|
3093
|
+
// until the transient retry swaps it, and never enter it. `generic` is what
|
|
2349
3094
|
// EVERY pre-DDR-102 hub sends. So a refusal in a transient class passed
|
|
2350
3095
|
// straight through this guard and got overwritten with `connected`
|
|
2351
3096
|
// (attacker review 2026-09-03, F3): the hub was dropping this document's
|
|
@@ -2594,6 +3339,7 @@ export function createSyncRuntime(
|
|
|
2594
3339
|
// the same order relative to each other.
|
|
2595
3340
|
const deferSetup = !!setup && pulledSlugs.has(canvas.slug);
|
|
2596
3341
|
if (!deferSetup) setup?.(provider);
|
|
3342
|
+
else if (setup) owedSetups.set(canvas.slug, setup);
|
|
2597
3343
|
|
|
2598
3344
|
// Task 8 — feed this provider's WS status into the offline monitor.
|
|
2599
3345
|
// Per-provider, so a socket that never dropped never triggers a poll.
|
|
@@ -2663,6 +3409,8 @@ export function createSyncRuntime(
|
|
|
2663
3409
|
// Cold-start reconcile fires once the provider has hub state.
|
|
2664
3410
|
const synced = provider.onceSynced().then(() => {
|
|
2665
3411
|
if (deferSetup) {
|
|
3412
|
+
// Settled either way below — run, or the canvas is released.
|
|
3413
|
+
owedSetups.delete(canvas.slug);
|
|
2666
3414
|
// Abandon before `setup?.()`, so no projection and no agent is ever
|
|
2667
3415
|
// built for a canvas we are not going to place — nothing exists that
|
|
2668
3416
|
// could flush the document onto the provisional path on the way out.
|
|
@@ -2737,6 +3485,50 @@ export function createSyncRuntime(
|
|
|
2737
3485
|
paths: canvasPaths,
|
|
2738
3486
|
echoGuard,
|
|
2739
3487
|
journal: journal ?? undefined,
|
|
3488
|
+
historyDir: path.join(ctx.paths.historyDir, canvas.slug),
|
|
3489
|
+
waitForReconcile: true,
|
|
3490
|
+
...(acceptedLink
|
|
3491
|
+
? {
|
|
3492
|
+
accepted: acceptedLink.laneLink(canvas.slug),
|
|
3493
|
+
revisionBarrier,
|
|
3494
|
+
replayOp: (op: SourceOp, head: string) =>
|
|
3495
|
+
replaySourceOp(canvas.html, op, head),
|
|
3496
|
+
onAccepted: ({
|
|
3497
|
+
lane,
|
|
3498
|
+
value,
|
|
3499
|
+
actionId,
|
|
3500
|
+
}: {
|
|
3501
|
+
lane: string;
|
|
3502
|
+
value: string;
|
|
3503
|
+
actionId: string;
|
|
3504
|
+
}) => noteAcceptedContent(canvas.slug, lane, value, actionId),
|
|
3505
|
+
onRevisionApplied: (rev: number) => store.noteAppliedRevision?.(rev),
|
|
3506
|
+
}
|
|
3507
|
+
: {}),
|
|
3508
|
+
// A write the hub would drop is held, never made (see isWritable).
|
|
3509
|
+
...(provider.isWritable
|
|
3510
|
+
? {
|
|
3511
|
+
canWriteDoc: () => provider.isWritable?.() !== false,
|
|
3512
|
+
onWriteBlocked: () => void refreshAcceptedMode(),
|
|
3513
|
+
}
|
|
3514
|
+
: {}),
|
|
3515
|
+
onRecovered: () => store.clearSourceConflict(canvas.slug),
|
|
3516
|
+
onConflict: (info) => {
|
|
3517
|
+
store.addConflict(info);
|
|
3518
|
+
store.notice({
|
|
3519
|
+
id: `source-conflict-${canvas.slug}`,
|
|
3520
|
+
severity: 'warn',
|
|
3521
|
+
text: `Source sync blocked for ${canvas.slug}. The local file was kept because ${
|
|
3522
|
+
info.reason === 'invalid-source'
|
|
3523
|
+
? 'the source has syntax errors or duplicate declarations'
|
|
3524
|
+
: info.reason === 'local-edit'
|
|
3525
|
+
? 'a local edit overlaps an incoming change'
|
|
3526
|
+
: info.reason === 'merge-budget'
|
|
3527
|
+
? 'the edit is too large to merge safely'
|
|
3528
|
+
: 'a recovery copy could not be saved'
|
|
3529
|
+
}. ${info.snapshotFailed ? 'Recovery could not be saved; check disk space and history write access.' : `Recovery copies are in _history/${canvas.slug}/sync-recovery/.`}`,
|
|
3530
|
+
});
|
|
3531
|
+
},
|
|
2740
3532
|
// Cell pairing only — see the DocProjectionOptions.onWrote doc.
|
|
2741
3533
|
// The synthetic event is delayed by the same margin the container
|
|
2742
3534
|
// write bridge uses, so a watcher that DOES fire wins the race and
|
|
@@ -2776,6 +3568,55 @@ export function createSyncRuntime(
|
|
|
2776
3568
|
// cleans up.)
|
|
2777
3569
|
watchForRetirement(canvas.slug, provider.document);
|
|
2778
3570
|
|
|
3571
|
+
// THE HUB SAYS THE SAVE MODE CHANGED — on this document's own
|
|
3572
|
+
// socket, ahead of closing it, so the switch is learned before any
|
|
3573
|
+
// further local change is made (DDR-241 §7 switch ordering).
|
|
3574
|
+
if (acceptedLink && provider.onStateless) {
|
|
3575
|
+
noteDetach(
|
|
3576
|
+
statusDetaches,
|
|
3577
|
+
canvas.slug,
|
|
3578
|
+
provider.onStateless((payload) => {
|
|
3579
|
+
let msg: { type?: unknown; mode?: unknown } | null = null;
|
|
3580
|
+
try {
|
|
3581
|
+
msg = JSON.parse(payload);
|
|
3582
|
+
} catch {
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3585
|
+
if (msg?.type !== 'maude.mode') return;
|
|
3586
|
+
if (msg.mode === 'transactions' || msg.mode === 'legacy') {
|
|
3587
|
+
acceptedLink.noteMode(msg.mode);
|
|
3588
|
+
void refreshAcceptedMode();
|
|
3589
|
+
}
|
|
3590
|
+
})
|
|
3591
|
+
);
|
|
3592
|
+
}
|
|
3593
|
+
if (provider.onAuthenticated) {
|
|
3594
|
+
const slug = canvas.slug;
|
|
3595
|
+
noteDetach(
|
|
3596
|
+
statusDetaches,
|
|
3597
|
+
slug,
|
|
3598
|
+
provider.onAuthenticated(() => projections.get(slug)?.retryDeferred())
|
|
3599
|
+
);
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
// ACCEPTED-REPLICA TRIPWIRE (DDR-241 §7, plan T6/T12). In
|
|
3603
|
+
// transactions mode the hub drops every update this connection
|
|
3604
|
+
// sends, so a local write here never reaches the project and the
|
|
3605
|
+
// replica silently diverges. Nothing is supposed to do it; this is
|
|
3606
|
+
// how we find out if something does.
|
|
3607
|
+
if (acceptedLink && provider.isRemoteOrigin) {
|
|
3608
|
+
const slug = canvas.slug;
|
|
3609
|
+
const onAny = (_u: Uint8Array, origin: unknown) => {
|
|
3610
|
+
if (!acceptedOn() || provider.isRemoteOrigin?.(origin)) return;
|
|
3611
|
+
acceptedWriteViolations += 1;
|
|
3612
|
+
console.error(
|
|
3613
|
+
`[sync/${slug}] a local write reached the accepted replica (origin: ${describeOrigin(origin)}) — it cannot reach the project.`
|
|
3614
|
+
);
|
|
3615
|
+
};
|
|
3616
|
+
provider.document.on('update', onAny);
|
|
3617
|
+
noteDetach(statusDetaches, slug, () => provider.document.off('update', onAny));
|
|
3618
|
+
}
|
|
3619
|
+
|
|
2779
3620
|
// Count local edits (agent-origin doc updates) toward queuedOps while
|
|
2780
3621
|
// the hub is unreachable — the banner's "N edits queued" figure. Under
|
|
2781
3622
|
// sharedDoc there is no agent origin to key off (browser edits carry a
|
|
@@ -2986,6 +3827,17 @@ export function createSyncRuntime(
|
|
|
2986
3827
|
if (typeof slug !== 'string' || !slug) return;
|
|
2987
3828
|
if (revive) tombstoned.delete(slug);
|
|
2988
3829
|
else tombstoned.add(slug);
|
|
3830
|
+
if (acceptedOn() && acceptedLink) {
|
|
3831
|
+
// A re-created canvas is proposed by its cold start (`doc.create`); a
|
|
3832
|
+
// deletion is ONE project action unless a folder action covered it.
|
|
3833
|
+
if (revive || coveredByFolderAction.delete(slug)) return;
|
|
3834
|
+
void acceptedLink.deleteDoc(slug).then((r) => {
|
|
3835
|
+
if (r.status === 'rejected' && r.code !== 'dependency-missing') {
|
|
3836
|
+
console.warn(`[sync] the project did not accept deleting ${slug} (${r.code})`);
|
|
3837
|
+
}
|
|
3838
|
+
});
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
2989
3841
|
void stateDocumentGone(linkedHub.url, token, docNameFor(slug), { revive }).then((ok) => {
|
|
2990
3842
|
if (!ok) {
|
|
2991
3843
|
console.warn(
|
|
@@ -3038,6 +3890,13 @@ export function createSyncRuntime(
|
|
|
3038
3890
|
annotations: canvas.annotations,
|
|
3039
3891
|
},
|
|
3040
3892
|
});
|
|
3893
|
+
if (!existsSync(canvas.html)) {
|
|
3894
|
+
ctx.bus.emit('canvas-list-update', {
|
|
3895
|
+
action: 'removed',
|
|
3896
|
+
rel: path.relative(ctx.paths.designRoot, canvas.html).split(path.sep).join('/'),
|
|
3897
|
+
slug,
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3041
3900
|
}
|
|
3042
3901
|
descriptors.delete(slug);
|
|
3043
3902
|
}
|
|
@@ -3064,11 +3923,18 @@ export function createSyncRuntime(
|
|
|
3064
3923
|
// not told the document exists.
|
|
3065
3924
|
const pullRemoteOnce = async (): Promise<void> => {
|
|
3066
3925
|
if (stopped) return;
|
|
3926
|
+
// Accepted revisions: the save mode can change under a running peer (the
|
|
3927
|
+
// hub fences every socket when it does), and folders are manifest
|
|
3928
|
+
// entries — neither is in the document listing.
|
|
3929
|
+
if (acceptedLink) {
|
|
3930
|
+
await acceptedLink.refresh();
|
|
3931
|
+
if (acceptedOn()) applyProjectDirs(acceptedLink.manifest?.dirs ?? []);
|
|
3932
|
+
}
|
|
3067
3933
|
// Read `token` at call time: a silent renewal swaps it in place.
|
|
3068
|
-
const listing = await fetchRemoteListing(linkedHub.url, token);
|
|
3934
|
+
const listing = withAcceptedDocs(await fetchRemoteListing(linkedHub.url, token));
|
|
3069
3935
|
// null = unreachable, refused, or a hub without the route. Not an error
|
|
3070
3936
|
// here any more than it is at boot — sync continues, we ask again later.
|
|
3071
|
-
if (listing === null) return;
|
|
3937
|
+
if (stopped || listing === null) return;
|
|
3072
3938
|
// ABSENCE BEFORE PRESENCE. A canvas the project deleted must leave before
|
|
3073
3939
|
// the pull runs, or a slug that is tombstoned AND still listed (the window
|
|
3074
3940
|
// between the tombstone and the row actually going) would be trashed and
|
|
@@ -3402,13 +4268,20 @@ export function createSyncRuntime(
|
|
|
3402
4268
|
});
|
|
3403
4269
|
noteFilePull(result);
|
|
3404
4270
|
};
|
|
4271
|
+
documentDiscovery = createDocumentDiscovery({
|
|
4272
|
+
run: pullRemoteOnce,
|
|
4273
|
+
onError: (error) => console.error('[sync] document discovery failed:', error),
|
|
4274
|
+
});
|
|
4275
|
+
const discovery = documentDiscovery;
|
|
4276
|
+
documentDiscoveryUnsub = ctx.bus.on('sync:documents-changed', () => discovery.schedule());
|
|
3405
4277
|
const pollRemote = (): void => {
|
|
3406
|
-
void
|
|
4278
|
+
void discovery
|
|
4279
|
+
.flush()
|
|
3407
4280
|
.then(() => pullFilesOnce())
|
|
3408
4281
|
.catch((err) => console.error('[sync] remote poll failed:', err));
|
|
3409
4282
|
};
|
|
3410
4283
|
remotePull = async () => {
|
|
3411
|
-
await
|
|
4284
|
+
await discovery.flush();
|
|
3412
4285
|
await pullFilesOnce();
|
|
3413
4286
|
};
|
|
3414
4287
|
remotePollTimer = setInterval(pollRemote, REMOTE_POLL_MS);
|
|
@@ -3441,7 +4314,11 @@ export function createSyncRuntime(
|
|
|
3441
4314
|
// • `state === 'online'` — offline already has its own recovery path.
|
|
3442
4315
|
// • `synced === 0 && pending > 0` — some progress means it is working,
|
|
3443
4316
|
// just slowly; this is only for the total stall.
|
|
3444
|
-
// • `rejected === 0` — a refusal is the auth lane's
|
|
4317
|
+
// • `rejected === 0` — a refusal is the auth lane's: renew + re-probe for
|
|
4318
|
+
// the permanent classes, the paced per-document retry for the
|
|
4319
|
+
// transient ones. Cycling the socket would re-authenticate EVERY
|
|
4320
|
+
// document to recover a few, and a volume refusal is exactly when
|
|
4321
|
+
// that burst hurts most.
|
|
3445
4322
|
// • a floor between forced reconnects, so a hub that is simply down
|
|
3446
4323
|
// cannot be turned into a reconnect storm by its own silence.
|
|
3447
4324
|
// The socket-cycling capability, resolved once.
|
|
@@ -3574,6 +4451,7 @@ export function createSyncRuntime(
|
|
|
3574
4451
|
fileEventsCtl = createCtlProvider({
|
|
3575
4452
|
url: linkedHub.url,
|
|
3576
4453
|
token,
|
|
4454
|
+
onDocuments: () => documentDiscovery?.schedule(),
|
|
3577
4455
|
onPoke: () => {
|
|
3578
4456
|
// Reuses `pollRemoteSoon` rather than calling the file lanes
|
|
3579
4457
|
// directly, for two reasons: it already coalesces a burst into
|
|
@@ -3614,6 +4492,8 @@ export function createSyncRuntime(
|
|
|
3614
4492
|
async function stop(): Promise<void> {
|
|
3615
4493
|
if (stopped) return;
|
|
3616
4494
|
stopped = true;
|
|
4495
|
+
acceptedLink?.stop();
|
|
4496
|
+
revisionBarrier.stop();
|
|
3617
4497
|
fileEventsProbe?.abort();
|
|
3618
4498
|
fileEventsProbe = null;
|
|
3619
4499
|
if (filePassTimer !== null) clearTimeout(filePassTimer);
|
|
@@ -3650,6 +4530,10 @@ export function createSyncRuntime(
|
|
|
3650
4530
|
stallTimer = null;
|
|
3651
4531
|
if (remotePollSoonTimer !== null) clearTimeout(remotePollSoonTimer);
|
|
3652
4532
|
remotePollSoonTimer = null;
|
|
4533
|
+
documentDiscoveryUnsub?.();
|
|
4534
|
+
documentDiscoveryUnsub = null;
|
|
4535
|
+
documentDiscovery?.stop();
|
|
4536
|
+
documentDiscovery = null;
|
|
3653
4537
|
remotePull = null;
|
|
3654
4538
|
// A push pass that outlives its runtime keeps uploading a project the
|
|
3655
4539
|
// person just closed — and `restart()` (the Resync button) calls stop() on
|
|
@@ -3688,6 +4572,10 @@ export function createSyncRuntime(
|
|
|
3688
4572
|
authClearTimer(reprobeTimer);
|
|
3689
4573
|
reprobeTimer = null;
|
|
3690
4574
|
}
|
|
4575
|
+
if (transientRetryTimer !== null) {
|
|
4576
|
+
authClearTimer(transientRetryTimer);
|
|
4577
|
+
transientRetryTimer = null;
|
|
4578
|
+
}
|
|
3691
4579
|
if (renewTimer !== null) {
|
|
3692
4580
|
authClearTimer(renewTimer);
|
|
3693
4581
|
renewTimer = null;
|
|
@@ -3697,8 +4585,13 @@ export function createSyncRuntime(
|
|
|
3697
4585
|
for (const h of announceTimers.values()) clearTimeout(h);
|
|
3698
4586
|
announceTimers.clear();
|
|
3699
4587
|
rejectedPermanent.clear();
|
|
4588
|
+
rejectedTransient.clear();
|
|
4589
|
+
transientStrikes.clear();
|
|
4590
|
+
transientSpent.length = 0;
|
|
4591
|
+
owedSetups.clear();
|
|
3700
4592
|
busUnsub?.();
|
|
3701
4593
|
busUnsub = null;
|
|
4594
|
+
for (const u of activityUnsubs.splice(0)) u();
|
|
3702
4595
|
fsReader?.stop();
|
|
3703
4596
|
fsReader = null;
|
|
3704
4597
|
for (const agent of agents.values()) {
|
|
@@ -3783,6 +4676,145 @@ export function createSyncRuntime(
|
|
|
3783
4676
|
return true;
|
|
3784
4677
|
},
|
|
3785
4678
|
retireForMove: (fromSlug, toRel) => serializeMembership(() => retireForMove(fromSlug, toRel)),
|
|
4679
|
+
proposeLane: (slug, lane, text, o) => {
|
|
4680
|
+
if (!acceptedOn()) return null;
|
|
4681
|
+
const projection = projections.get(slug);
|
|
4682
|
+
if (!projection) return null;
|
|
4683
|
+
const value = laneValueFromFile(lane, text);
|
|
4684
|
+
if (value === null) return null;
|
|
4685
|
+
const base = o?.baseText === undefined ? undefined : laneValueFromFile(lane, o.baseText);
|
|
4686
|
+
return projection.proposeLane(lane, value, {
|
|
4687
|
+
...(base !== undefined && base !== null ? { baseContent: base } : {}),
|
|
4688
|
+
...(o?.writeId ? { writeId: o.writeId } : {}),
|
|
4689
|
+
});
|
|
4690
|
+
},
|
|
4691
|
+
proposeFolder,
|
|
4692
|
+
acceptedMode: acceptedOn,
|
|
4693
|
+
acceptedWriteViolations: () => acceptedWriteViolations,
|
|
4694
|
+
acceptedHistory: async (q) => {
|
|
4695
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
4696
|
+
// Fresh, not the cached manifest: a canvas created since the last
|
|
4697
|
+
// bootstrap has history too, and a stale map named no entry for it.
|
|
4698
|
+
const manifest = (await acceptedLink.refresh().catch(() => null)) ?? acceptedLink.manifest;
|
|
4699
|
+
if (!manifest) return null;
|
|
4700
|
+
const pathOf = new Map(manifest.docs.map((d) => [d.doc, d.path]));
|
|
4701
|
+
let entry: string | null = null;
|
|
4702
|
+
if (q.path) {
|
|
4703
|
+
const slug = slugForRepoRel(q.path);
|
|
4704
|
+
const doc = slug ? docNameFor(slug) : null;
|
|
4705
|
+
entry = manifest.docs.find((d) => d.doc === doc)?.entry ?? null;
|
|
4706
|
+
if (!entry) return [];
|
|
4707
|
+
}
|
|
4708
|
+
const { history } = await acceptedLink.history({
|
|
4709
|
+
limit: Math.min(Math.max(q.limit ?? 50, 1), 200),
|
|
4710
|
+
before: q.before ?? null,
|
|
4711
|
+
entry,
|
|
4712
|
+
});
|
|
4713
|
+
const you = manifest.you?.actor ?? null;
|
|
4714
|
+
return history.map((a) => {
|
|
4715
|
+
const canvases = new Set<string>();
|
|
4716
|
+
for (const e of a.effects) {
|
|
4717
|
+
const p = e.afterPath ?? (e.doc ? pathOf.get(e.doc) : null) ?? e.beforePath;
|
|
4718
|
+
if (p && /\.(tsx|html)$/i.test(p)) canvases.add(p);
|
|
4719
|
+
}
|
|
4720
|
+
return {
|
|
4721
|
+
revision: a.revision,
|
|
4722
|
+
actionId: a.actionId,
|
|
4723
|
+
actor: a.actor,
|
|
4724
|
+
mine: you !== null && a.actor === you,
|
|
4725
|
+
kind: a.kind,
|
|
4726
|
+
label: a.label,
|
|
4727
|
+
committedAt: a.committedAt,
|
|
4728
|
+
undoes: a.undoes,
|
|
4729
|
+
canvases: [...canvases].sort(),
|
|
4730
|
+
};
|
|
4731
|
+
});
|
|
4732
|
+
},
|
|
4733
|
+
acceptedVersion: async (repoRel, revision) => {
|
|
4734
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
4735
|
+
const slug = slugForRepoRel(repoRel);
|
|
4736
|
+
if (!slug) return null;
|
|
4737
|
+
const r = await acceptedLink.laneAt(slug, 'html', revision);
|
|
4738
|
+
return r.body || null;
|
|
4739
|
+
},
|
|
4740
|
+
acceptedRestore: async (repoRel, revision) => {
|
|
4741
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
4742
|
+
const slug = slugForRepoRel(repoRel);
|
|
4743
|
+
if (!slug) return { status: 'rejected', code: 'unknown-canvas' };
|
|
4744
|
+
const name = path.basename(repoRel).replace(/\.(tsx|html)$/i, '');
|
|
4745
|
+
return acceptedLink.restore([slug], revision, `Restore ${name} to version ${revision}`);
|
|
4746
|
+
},
|
|
4747
|
+
acceptedUndo: async (actionId, redo = false) => {
|
|
4748
|
+
if (!acceptedOn() || !acceptedLink) return null;
|
|
4749
|
+
return acceptedLink.undo(actionId, redo);
|
|
4750
|
+
},
|
|
4751
|
+
prepareOffline: async () => {
|
|
4752
|
+
if (!filePlane) return null;
|
|
4753
|
+
let pulled = 0;
|
|
4754
|
+
let passes = 0;
|
|
4755
|
+
let failed = 0;
|
|
4756
|
+
for (; passes < 40 && !stopped; passes += 1) {
|
|
4757
|
+
lastPlaneResult = null;
|
|
4758
|
+
await runPlanePass({ floor: false });
|
|
4759
|
+
const r = lastPlaneResult as import('./file-plane.ts').FilePlaneResult | null;
|
|
4760
|
+
if (!r) break;
|
|
4761
|
+
pulled += r.pulled.length;
|
|
4762
|
+
failed = r.failed.length;
|
|
4763
|
+
if (r.pulled.length === 0 && !r.budgetExhausted) {
|
|
4764
|
+
return { complete: failed === 0, pulled, passes: passes + 1, failed };
|
|
4765
|
+
}
|
|
4766
|
+
}
|
|
4767
|
+
return { complete: false, pulled, passes, failed };
|
|
4768
|
+
},
|
|
4769
|
+
acceptedActionForContent: (repoRel, content) => {
|
|
4770
|
+
const slug = slugForRepoRel(repoRel);
|
|
4771
|
+
return slug ? (acceptedByContent.get(`${slug}|html|${hashBytes(content)}`) ?? null) : null;
|
|
4772
|
+
},
|
|
4773
|
+
conflictVersions: (repoRel) => {
|
|
4774
|
+
// A slug (the Sync panel's notice id) or a repo-relative canvas path.
|
|
4775
|
+
const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
|
|
4776
|
+
const sides = slug ? projections.get(slug)?.conflictSides() : null;
|
|
4777
|
+
return slug && sides ? { slug, ...sides } : null;
|
|
4778
|
+
},
|
|
4779
|
+
resolveConflict: async (repoRel, choice) => {
|
|
4780
|
+
const slug = projections.has(repoRel) ? repoRel : slugForRepoRel(repoRel);
|
|
4781
|
+
const projection = slug ? projections.get(slug) : undefined;
|
|
4782
|
+
if (!slug || !projection) return null;
|
|
4783
|
+
const sides = projection.conflictSides();
|
|
4784
|
+
if (!sides) return null;
|
|
4785
|
+
if (choice === 'theirs') {
|
|
4786
|
+
projection.takeAccepted();
|
|
4787
|
+
return { status: 'taken' };
|
|
4788
|
+
}
|
|
4789
|
+
if (sides.mine === null) return { status: 'rejected', code: 'no-local-version' };
|
|
4790
|
+
// Mine, on top of the version that won — a new action, never a rewind.
|
|
4791
|
+
const r = await projection.proposeLane('html', sides.mine, { baseContent: sides.theirs });
|
|
4792
|
+
if (!r) return null;
|
|
4793
|
+
if (r.status === 'accepted') statusStore?.clearSourceConflict?.(slug);
|
|
4794
|
+
return { status: r.status, ...(r.code ? { code: r.code } : {}) };
|
|
4795
|
+
},
|
|
4796
|
+
beginAiAction: (key, label) => {
|
|
4797
|
+
if (!acceptedOn() || !acceptedLink) return;
|
|
4798
|
+
acceptedLink.stage.begin(key, label);
|
|
4799
|
+
},
|
|
4800
|
+
endAiAction: async (key, outcome) => {
|
|
4801
|
+
if (!acceptedLink) return null;
|
|
4802
|
+
// The watcher delivers the last tool write a beat after the tool
|
|
4803
|
+
// returns; let it land in the stage before the stage closes.
|
|
4804
|
+
await new Promise((r) => setTimeout(r, AI_ACTION_SETTLE_MS));
|
|
4805
|
+
await (discoveryRescan?.flush() ?? Promise.resolve()).catch(() => {});
|
|
4806
|
+
const r = await acceptedLink.stage.end(key, outcome);
|
|
4807
|
+
return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
|
|
4808
|
+
},
|
|
4809
|
+
resolveAiAction: async (choice) => {
|
|
4810
|
+
if (!acceptedLink || acceptedLink.stage.state !== 'held') return null;
|
|
4811
|
+
if (choice === 'discard') {
|
|
4812
|
+
const canvases = acceptedLink.stage.discard();
|
|
4813
|
+
return { status: 'discarded', canvases };
|
|
4814
|
+
}
|
|
4815
|
+
const r = await acceptedLink.stage.publish();
|
|
4816
|
+
return r ? { status: r.status, ...(r.code ? { code: r.code } : {}) } : null;
|
|
4817
|
+
},
|
|
3786
4818
|
};
|
|
3787
4819
|
}
|
|
3788
4820
|
|
|
@@ -4468,11 +5500,37 @@ export function createDefaultProviderFactory(
|
|
|
4468
5500
|
};
|
|
4469
5501
|
socket.on('status', resetSyncedOnDrop);
|
|
4470
5502
|
|
|
5503
|
+
let authedThisConnection = false;
|
|
5504
|
+
provider.on('authenticated', () => {
|
|
5505
|
+
authedThisConnection = true;
|
|
5506
|
+
});
|
|
5507
|
+
const forgetAuthOnDrop = (evt: { status?: string }) => {
|
|
5508
|
+
if (evt?.status !== 'connected') authedThisConnection = false;
|
|
5509
|
+
};
|
|
5510
|
+
socket.on('status', forgetAuthOnDrop);
|
|
4471
5511
|
return {
|
|
4472
5512
|
document,
|
|
4473
5513
|
// HocuspocusProvider creates a hub-synced Awareness by default; expose it
|
|
4474
5514
|
// so the runtime can bridge it to the collab Room (Task 5).
|
|
4475
5515
|
awareness: provider.awareness as Awareness | undefined,
|
|
5516
|
+
isRemoteOrigin: (origin: unknown) => origin === provider,
|
|
5517
|
+
// Writable = authenticated read-write ON THIS CONNECTION. A scope left
|
|
5518
|
+
// over from before a drop says nothing about the hub now: a project
|
|
5519
|
+
// switched to accepted revisions while this peer was away re-admits it
|
|
5520
|
+
// read-only, and anything written in between would be dropped there.
|
|
5521
|
+
isWritable: () => authedThisConnection && provider.authorizedScope === 'read-write',
|
|
5522
|
+
onAuthenticated(cb: (scope: string) => void): () => void {
|
|
5523
|
+
const handler = (evt: { scope?: string }) => cb(String(evt?.scope ?? ''));
|
|
5524
|
+
provider.on('authenticated', handler);
|
|
5525
|
+
return () => provider.off('authenticated', handler);
|
|
5526
|
+
},
|
|
5527
|
+
onStateless(cb: (payload: string) => void): () => void {
|
|
5528
|
+
const handler = (evt: { payload?: string }) => {
|
|
5529
|
+
if (typeof evt?.payload === 'string') cb(evt.payload);
|
|
5530
|
+
};
|
|
5531
|
+
provider.on('stateless', handler);
|
|
5532
|
+
return () => provider.off('stateless', handler);
|
|
5533
|
+
},
|
|
4476
5534
|
onStatus(cb: (status: ProviderStatus) => void): () => void {
|
|
4477
5535
|
// The shared socket emits 'status' on every WS transition and every
|
|
4478
5536
|
// ATTACHED provider re-emits it (forwardStatus), so per-provider
|
|
@@ -4523,6 +5581,7 @@ export function createDefaultProviderFactory(
|
|
|
4523
5581
|
},
|
|
4524
5582
|
destroy() {
|
|
4525
5583
|
socket.off('status', resetSyncedOnDrop);
|
|
5584
|
+
socket.off('status', forgetAuthOnDrop);
|
|
4526
5585
|
// Detaches from the shared socket (sends a per-document Close); the
|
|
4527
5586
|
// socket itself is destroyed by dispose() after all providers.
|
|
4528
5587
|
provider.destroy();
|