@1agh/maude 0.58.2 → 0.59.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/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -23,6 +23,20 @@ export interface HubRecord {
|
|
|
23
23
|
* write-capable, and nobody is demoted by an upgrade.
|
|
24
24
|
*/
|
|
25
25
|
role?: string;
|
|
26
|
+
/**
|
|
27
|
+
* When this credential dies (ms epoch), as the workspace reported at mint.
|
|
28
|
+
*
|
|
29
|
+
* A cell session expires WITH the 12 h project token that minted it (Phase
|
|
30
|
+
* 23 B2 — that cap is the revocation window and must stay). What was
|
|
31
|
+
* missing is anyone LOOKING at the deadline: the token was saved, the
|
|
32
|
+
* expiry discarded, and ≤ 12 h later all sync died into a permanent
|
|
33
|
+
* `connecting…`. Stored so the runtime can renew BEFORE it, silently.
|
|
34
|
+
*
|
|
35
|
+
* Absent on self-hosted hubs (their tokens may not expire) and on every
|
|
36
|
+
* credential written before this shipped — both mean "no scheduled
|
|
37
|
+
* renewal", which is exactly the old behaviour.
|
|
38
|
+
*/
|
|
39
|
+
expiresAt?: number;
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
export interface HubsConfig {
|
|
@@ -40,6 +54,16 @@ export function hubsConfigPath(): string {
|
|
|
40
54
|
/** Normalize a hub URL — trim trailing slash, lower-case scheme + host. */
|
|
41
55
|
export function normalizeUrl(url: string): string {
|
|
42
56
|
const u = new URL(url);
|
|
57
|
+
// Reject embedded credentials outright (2026-08-10 review, claim-a residual).
|
|
58
|
+
// `URL.toString()` PRESERVES `user:pass@` — the renewal lane is safe only
|
|
59
|
+
// because it fetches the normalized string and requires it to equal a URL the
|
|
60
|
+
// cloud itself listed. A `https://evil@proj.cloud.maude.sh` config would
|
|
61
|
+
// survive normalization intact; refusing it here means such a value can never
|
|
62
|
+
// be stored or matched, so a later "fetch hubUrl directly" edit can't become
|
|
63
|
+
// credential exfiltration. A real hub URL never carries userinfo.
|
|
64
|
+
if (u.username || u.password) {
|
|
65
|
+
throw new Error('hub URL must not contain embedded credentials');
|
|
66
|
+
}
|
|
43
67
|
u.protocol = u.protocol.toLowerCase();
|
|
44
68
|
u.hostname = u.hostname.toLowerCase();
|
|
45
69
|
let str = u.toString();
|
|
@@ -108,11 +132,15 @@ export function isHubReadOnly(url: string): boolean {
|
|
|
108
132
|
|
|
109
133
|
/** Look up a token for `url`. Returns null when no entry exists. */
|
|
110
134
|
export function getHubToken(url: string): string | null {
|
|
135
|
+
return getHubRecord(url)?.token ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The whole stored record for `url` (token + role + expiresAt), or null. */
|
|
139
|
+
export function getHubRecord(url: string): HubRecord | null {
|
|
111
140
|
try {
|
|
112
141
|
const norm = normalizeUrl(url);
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
return record ? record.token : null;
|
|
142
|
+
const record = loadHubsConfig().hubs[norm];
|
|
143
|
+
return record && typeof record.token === 'string' ? record : null;
|
|
116
144
|
} catch {
|
|
117
145
|
return null;
|
|
118
146
|
}
|
|
@@ -23,12 +23,13 @@ import path from 'node:path';
|
|
|
23
23
|
|
|
24
24
|
import type { Awareness } from 'y-protocols/awareness';
|
|
25
25
|
import * as Y from 'yjs';
|
|
26
|
-
|
|
26
|
+
import { renewHubCredential } from '../cloud/renew.ts';
|
|
27
27
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
28
28
|
import type { Context, LinkedHub } from '../context.ts';
|
|
29
29
|
import { createHistory } from '../history.ts';
|
|
30
30
|
import { SYNTHETIC_FS_DELAY_MS } from '../hmr-broadcast.ts';
|
|
31
31
|
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
32
|
+
import { pushAssets } from './asset-push.ts';
|
|
32
33
|
import { atomicWrite } from './atomic-write.ts';
|
|
33
34
|
import { createAutoCommit } from './autocommit.ts';
|
|
34
35
|
import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
|
|
@@ -41,9 +42,10 @@ import {
|
|
|
41
42
|
import { createDocNameResolver } from './doc-name.ts';
|
|
42
43
|
import { createEchoGuard } from './echo-guard.ts';
|
|
43
44
|
import { createFsReader, type FsReader } from './fs-mirror.ts';
|
|
44
|
-
import {
|
|
45
|
+
import { getHubRecord } from './hubs-config.ts';
|
|
45
46
|
import { loadJournal, type SyncJournal } from './journal.ts';
|
|
46
47
|
import { isLoopbackHost } from './loopback.ts';
|
|
48
|
+
import { migrateFlatFallback } from './migrate-flat-fallback.ts';
|
|
47
49
|
import { migrateSeed } from './migrate-seed.ts';
|
|
48
50
|
import { ORIGINS } from './origins.ts';
|
|
49
51
|
import { createDocProjection, type DocProjection } from './projection.ts';
|
|
@@ -96,15 +98,50 @@ export type AuthFailureClass = 'rate-limit' | 'not-authorized' | 'invalid-token'
|
|
|
96
98
|
* → `generic` (interop-safe degradation). */
|
|
97
99
|
export function classifyAuthFailure(raw: string): AuthFailureClass {
|
|
98
100
|
const s = raw.toLowerCase();
|
|
99
|
-
|
|
100
|
-
|
|
101
|
+
// Permanent classes FIRST. The hub's invalid-token bucket refuses with
|
|
102
|
+
// "invalid token — rate limited, retry in up to 60s": both substrings are
|
|
103
|
+
// present, and testing 'rate limit' first filed an expired credential under
|
|
104
|
+
// the transient class — so the runtime retried into the very bucket
|
|
105
|
+
// refusing it, and the one cause that needed a person never surfaced.
|
|
101
106
|
if (s.includes('invalid token')) return 'invalid-token';
|
|
107
|
+
if (s.includes('not authorized')) return 'not-authorized';
|
|
108
|
+
if (s.includes('rate limit')) return 'rate-limit';
|
|
102
109
|
return 'generic';
|
|
103
110
|
}
|
|
104
111
|
|
|
105
112
|
export const AUTH_WARN_DEBOUNCE_MS = 2_000;
|
|
106
113
|
export const AUTH_REPROBE_MS = 5 * 60 * 1000;
|
|
107
114
|
export const BOOT_SETTLE_TIMEOUT_MS = 15_000;
|
|
115
|
+
/** F1 — minimum wall-clock between renewal attempts. A burst of rejections
|
|
116
|
+
* collapses to one renewal (single-flight); the NEXT burst waits this out. */
|
|
117
|
+
export const RENEW_MIN_INTERVAL_MS = 60_000;
|
|
118
|
+
/** F1 — consecutive successful renewals with no completed handshake in between
|
|
119
|
+
* before the runtime stops renewing and lets the link surface as
|
|
120
|
+
* refused/stalled. Small: a renewal that fixed the credential clears at least
|
|
121
|
+
* one doc, so >0 useless renewals means the credential was never the cause. */
|
|
122
|
+
export const RENEW_MAX_WITHOUT_PROGRESS = 3;
|
|
123
|
+
/** F2 — setTimeout clamps delays above this (2^31-1) to 1 ms, turning a
|
|
124
|
+
* far-future expiry into a tight renewal loop. Clamp before we hand it over. */
|
|
125
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
126
|
+
/** F2 — a hub-reported `expiresAt` further out than this is not believed for
|
|
127
|
+
* scheduling (cloud cells issue ≤ 12 h; a month is already implausible). */
|
|
128
|
+
export const MAX_CREDENTIAL_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A hub-reported `expiresAt` sane enough to schedule against, or null.
|
|
132
|
+
*
|
|
133
|
+
* F2: the value crosses the DDR-054 trust boundary (a hub picks it) and is fed
|
|
134
|
+
* to `setTimeout`. A non-integer, a past stamp, or one absurdly far out (skew,
|
|
135
|
+
* or a hostile hub planting a far-future value to arm a tight loop) must not
|
|
136
|
+
* schedule a renewal — null means "no timer", the safe pre-existing behaviour.
|
|
137
|
+
* A credential already expired simply falls to the invalid-token path instead.
|
|
138
|
+
*/
|
|
139
|
+
export function validExpiry(raw: unknown, nowMs: number = Date.now()): number | null {
|
|
140
|
+
if (typeof raw !== 'number' || !Number.isInteger(raw)) return null;
|
|
141
|
+
if (raw <= nowMs) return null;
|
|
142
|
+
if (raw - nowMs > MAX_CREDENTIAL_LIFETIME_MS) return null;
|
|
143
|
+
return raw;
|
|
144
|
+
}
|
|
108
145
|
|
|
109
146
|
const AUTH_CLASS_HINT: Record<AuthFailureClass, string> = {
|
|
110
147
|
'rate-limit':
|
|
@@ -202,6 +239,22 @@ export interface CreateSyncRuntimeOptions {
|
|
|
202
239
|
settleTimeoutMs?: number;
|
|
203
240
|
setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
204
241
|
clearTimer?: (h: ReturnType<typeof setTimeout>) => void;
|
|
242
|
+
/**
|
|
243
|
+
* Silent credential renewal (cloud workspaces). Called single-flight at
|
|
244
|
+
* ~80 % of the stored credential's remaining life and on any
|
|
245
|
+
* `invalid-token` rejection; a non-null return swaps the runtime's token
|
|
246
|
+
* in place and re-probes rejected docs immediately. Default: the Maude
|
|
247
|
+
* Cloud renewal lane (`cloud/renew.ts`); it answers null for a machine
|
|
248
|
+
* that is not signed in, so self-hosted hubs keep today's behaviour.
|
|
249
|
+
* Never called under cell pairing (the cell's token comes from its env).
|
|
250
|
+
*/
|
|
251
|
+
renewCredential?: () => Promise<{ token: string; expiresAt: number | null } | null>;
|
|
252
|
+
/** F1 — clock for the renewal frequency floor (test injection). Default Date.now. */
|
|
253
|
+
now?: () => number;
|
|
254
|
+
/** F1 — minimum ms between renewal attempts. Default RENEW_MIN_INTERVAL_MS. */
|
|
255
|
+
renewMinIntervalMs?: number;
|
|
256
|
+
/** F1 — consecutive no-progress renewals before giving up. Default RENEW_MAX_WITHOUT_PROGRESS. */
|
|
257
|
+
renewMaxWithoutProgress?: number;
|
|
205
258
|
};
|
|
206
259
|
}
|
|
207
260
|
|
|
@@ -355,14 +408,21 @@ export function createSyncRuntime(
|
|
|
355
408
|
// this process in its environment. `~/.config/maude/hubs.json` is a PERSON's
|
|
356
409
|
// credential store and does not exist in a cell (HOME=/tmp) — which is exactly
|
|
357
410
|
// why the old guard was unreachable by accident rather than by design.
|
|
358
|
-
const
|
|
411
|
+
const storedRecord = cellPairing ? null : getHubRecord(linkedHub.url);
|
|
412
|
+
const resolvedToken = cellPairing ? cellPairing.token : (storedRecord?.token ?? null);
|
|
359
413
|
if (!resolvedToken) {
|
|
360
414
|
console.warn(
|
|
361
415
|
`[sync] linked to ${linkedHub.url} but no token in ~/.config/maude/hubs.json. Re-run 'maude design link' on this machine. Solo mode for now.`
|
|
362
416
|
);
|
|
363
417
|
return null;
|
|
364
418
|
}
|
|
365
|
-
|
|
419
|
+
// MUTABLE on purpose: silent renewal swaps it in place, and connectCanvas
|
|
420
|
+
// reads it at call time — so every provider (re)created after a renewal
|
|
421
|
+
// carries the fresh credential without a runtime restart.
|
|
422
|
+
let token: string = resolvedToken;
|
|
423
|
+
// Through validExpiry (F2) — the stored value came off disk written from a
|
|
424
|
+
// hub body, so a bogus/far-future stamp must not schedule a renewal at boot.
|
|
425
|
+
let tokenExpiresAt: number | null = validExpiry(storedRecord?.expiresAt);
|
|
366
426
|
|
|
367
427
|
// DDR-102 — the default factory multiplexes every provider over ONE shared
|
|
368
428
|
// WebSocket per hub URL; the runtime owns its disposal (stop(), after the
|
|
@@ -418,6 +478,36 @@ export function createSyncRuntime(
|
|
|
418
478
|
>();
|
|
419
479
|
let authWarnTimer: TimerHandle | null = null;
|
|
420
480
|
let reprobeTimer: TimerHandle | null = null;
|
|
481
|
+
let renewTimer: TimerHandle | null = null;
|
|
482
|
+
/** Single-flight guard: 73 rejected docs must trigger ONE renewal, not 73. */
|
|
483
|
+
let renewInFlight: Promise<boolean> | null = null;
|
|
484
|
+
// F1 (2026-08-10 security review) — RATE DISCIPLINE on the renew↔reprobe
|
|
485
|
+
// cycle. Single-flight bounds concurrency; these bound FREQUENCY. Without
|
|
486
|
+
// them a legitimate cell refusing on VOLUME (its invalid-token bucket, which
|
|
487
|
+
// now answers "invalid token — rate limited") classifies permanent →
|
|
488
|
+
// triggers renewal → renewal succeeds (the token was never the problem) →
|
|
489
|
+
// reprobeNow reconnects all N docs → the bucket refuses them again → loop.
|
|
490
|
+
// Reproduced at 2342 renewals/s. This is the exact retry-storm class the
|
|
491
|
+
// whole change exists to end, one level up — so it gets a floor AND a cap.
|
|
492
|
+
const renewNow = opts.auth?.now ?? (() => Date.now());
|
|
493
|
+
const renewMinIntervalMs = opts.auth?.renewMinIntervalMs ?? RENEW_MIN_INTERVAL_MS;
|
|
494
|
+
const renewMaxWithoutProgress = opts.auth?.renewMaxWithoutProgress ?? RENEW_MAX_WITHOUT_PROGRESS;
|
|
495
|
+
/** Wall-clock of the last renewal attempt (0 = never). The floor. */
|
|
496
|
+
let lastRenewAt = 0;
|
|
497
|
+
/** Successful renewals since the last completed handshake. The cap: a
|
|
498
|
+
* renewal that keeps succeeding while nothing connects is not fixing
|
|
499
|
+
* anything — stop, and let the link surface as refused/stalled. Reset to 0
|
|
500
|
+
* at every `connected` doc (the handshake-success point). */
|
|
501
|
+
let renewalsSinceProgress = 0;
|
|
502
|
+
// Silent credential renewal (cloud). Absent under cell pairing — the cell's
|
|
503
|
+
// token arrives via its environment and is the hub's own to rotate.
|
|
504
|
+
const renewCredential = cellPairing
|
|
505
|
+
? null
|
|
506
|
+
: (opts.auth?.renewCredential ??
|
|
507
|
+
(async () => {
|
|
508
|
+
const r = await renewHubCredential(linkedHub.url);
|
|
509
|
+
return r.ok ? { token: r.token, expiresAt: r.expiresAt } : null;
|
|
510
|
+
}));
|
|
421
511
|
const settleTimers = new Set<TimerHandle>();
|
|
422
512
|
/** Pending synthetic `fs:any` emissions (cell pairing only), keyed by the
|
|
423
513
|
* design-root-relative path so a second write to the same file inside the
|
|
@@ -446,6 +536,18 @@ export function createSyncRuntime(
|
|
|
446
536
|
// honest seed (`connecting`, nothing known) the moment the runtime starts.
|
|
447
537
|
resetPersistedStatus(ctx, linkedHub.url);
|
|
448
538
|
|
|
539
|
+
// Fix 4 (sync RCA 2026-08-10) — one-shot quarantine of the pre-fix-5 flat
|
|
540
|
+
// fallback twins, BEFORE the scan: a flat twin that survived into the scan
|
|
541
|
+
// would sync as its own document and re-seed the duplicate on every peer.
|
|
542
|
+
// Skipped under cell pairing — a cell's checkout is the hub's to manage,
|
|
543
|
+
// and quarantining there would dirty a tree the hub commits.
|
|
544
|
+
if (!cellPairing) {
|
|
545
|
+
migrateFlatFallback({
|
|
546
|
+
designRoot: ctx.paths.designRoot,
|
|
547
|
+
designRel: ctx.paths.designRel,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
|
|
449
551
|
const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
|
|
450
552
|
// DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
|
|
451
553
|
// the pinned set must be bounded. See `admitCanvases`.
|
|
@@ -753,22 +855,112 @@ export function createSyncRuntime(
|
|
|
753
855
|
console.warn(`[sync] hub auth rejections (${linkedHub.url}):\n${lines.join('\n')}`);
|
|
754
856
|
};
|
|
755
857
|
|
|
858
|
+
/** Reconnect every permanently-rejected doc now. Idempotent — the map is
|
|
859
|
+
* drained, so a second call during the same burst is a no-op. */
|
|
860
|
+
const reprobeNow = (): void => {
|
|
861
|
+
if (stopped) return;
|
|
862
|
+
if (reprobeTimer !== null) {
|
|
863
|
+
authClearTimer(reprobeTimer);
|
|
864
|
+
reprobeTimer = null;
|
|
865
|
+
}
|
|
866
|
+
const entries = [...rejectedPermanent.values()];
|
|
867
|
+
rejectedPermanent.clear();
|
|
868
|
+
for (const entry of entries) {
|
|
869
|
+
mon.noteDocState(entry.canvas.slug, 'pending');
|
|
870
|
+
void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
|
|
871
|
+
console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
|
|
756
876
|
const scheduleReprobe = (): void => {
|
|
757
877
|
if (reprobeTimer !== null || stopped) return;
|
|
758
878
|
reprobeTimer = authSetTimer(() => {
|
|
759
879
|
reprobeTimer = null;
|
|
760
|
-
|
|
761
|
-
const entries = [...rejectedPermanent.values()];
|
|
762
|
-
rejectedPermanent.clear();
|
|
763
|
-
for (const entry of entries) {
|
|
764
|
-
mon.noteDocState(entry.canvas.slug, 'pending');
|
|
765
|
-
void connectCanvas(entry.canvas, entry.canvasPaths, entry.doc).catch((err) => {
|
|
766
|
-
console.error(`[sync/${entry.canvas.slug}] re-probe failed:`, err);
|
|
767
|
-
});
|
|
768
|
-
}
|
|
880
|
+
reprobeNow();
|
|
769
881
|
}, reprobeMs);
|
|
770
882
|
};
|
|
771
883
|
|
|
884
|
+
/**
|
|
885
|
+
* Renew the hub credential in place — single-flight, silent, and never
|
|
886
|
+
* worse than failure: an unrenewable credential leaves the stored one
|
|
887
|
+
* untouched and every existing behaviour (slow re-probe, refused status)
|
|
888
|
+
* exactly as it was.
|
|
889
|
+
*/
|
|
890
|
+
const renewCredentialNow = (): Promise<boolean> => {
|
|
891
|
+
if (!renewCredential || stopped) return Promise.resolve(false);
|
|
892
|
+
if (renewInFlight) return renewInFlight;
|
|
893
|
+
// F1 — the cap: renewals that keep succeeding without a handshake landing
|
|
894
|
+
// are not fixing anything (a real fix clears at least one doc, which
|
|
895
|
+
// resets this to 0). Stop, and let the docs' own rejected state surface
|
|
896
|
+
// as refused/stalled — both name "reconnect the workspace".
|
|
897
|
+
if (renewalsSinceProgress >= renewMaxWithoutProgress) return Promise.resolve(false);
|
|
898
|
+
// F1 — the floor: one renewal per interval, whatever the outcome. Stamped
|
|
899
|
+
// here (commit point), not on success, so a failing renewal also waits.
|
|
900
|
+
const sinceLast = renewNow() - lastRenewAt;
|
|
901
|
+
if (lastRenewAt !== 0 && sinceLast < renewMinIntervalMs) return Promise.resolve(false);
|
|
902
|
+
lastRenewAt = renewNow();
|
|
903
|
+
renewInFlight = (async () => {
|
|
904
|
+
try {
|
|
905
|
+
const fresh = await renewCredential();
|
|
906
|
+
if (stopped || !fresh || typeof fresh.token !== 'string' || !fresh.token) return false;
|
|
907
|
+
token = fresh.token;
|
|
908
|
+
tokenExpiresAt = validExpiry(fresh.expiresAt);
|
|
909
|
+
renewalsSinceProgress++;
|
|
910
|
+
console.log(`[sync] hub credential renewed for ${linkedHub.url}`);
|
|
911
|
+
scheduleRenewal();
|
|
912
|
+
return true;
|
|
913
|
+
} catch (err) {
|
|
914
|
+
console.warn(`[sync] hub credential renewal failed: ${(err as Error).message}`);
|
|
915
|
+
return false;
|
|
916
|
+
} finally {
|
|
917
|
+
renewInFlight = null;
|
|
918
|
+
}
|
|
919
|
+
})();
|
|
920
|
+
return renewInFlight;
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Arm the pre-expiry renewal at ~80 % of the credential's REMAINING life
|
|
925
|
+
* (never sooner than a minute out). No stored expiry — a self-hosted hub,
|
|
926
|
+
* or a credential written before expiry was persisted — means no timer:
|
|
927
|
+
* exactly the old behaviour. A failed renewal retries on the re-probe
|
|
928
|
+
* cadence; once the credential actually dies, the invalid-token path
|
|
929
|
+
* below triggers renewal anyway, so the timer is an optimization, not the
|
|
930
|
+
* safety net.
|
|
931
|
+
*/
|
|
932
|
+
const scheduleRenewal = (): void => {
|
|
933
|
+
if (renewTimer !== null) {
|
|
934
|
+
authClearTimer(renewTimer);
|
|
935
|
+
renewTimer = null;
|
|
936
|
+
}
|
|
937
|
+
if (!renewCredential || stopped || tokenExpiresAt === null) return;
|
|
938
|
+
// F2 — clamp both ends. The lower floor keeps a near-expiry credential
|
|
939
|
+
// from arming an immediate timer; the upper clamp stops a far-future
|
|
940
|
+
// `expiresAt` (validExpiry already rejects > 30 d, but a peer's clock
|
|
941
|
+
// skew can still land the *delay* past setTimeout's int32 ceiling, where
|
|
942
|
+
// it silently fires at 1 ms) from becoming a tight loop.
|
|
943
|
+
const raw = (tokenExpiresAt - renewNow()) * 0.8;
|
|
944
|
+
const delay = Math.min(MAX_TIMER_DELAY_MS, Math.max(60_000, raw));
|
|
945
|
+
renewTimer = authSetTimer(() => {
|
|
946
|
+
renewTimer = null;
|
|
947
|
+
void renewCredentialNow().then((ok) => {
|
|
948
|
+
// F4 — a failed pre-expiry renewal RE-ARMS on the slow cadence
|
|
949
|
+
// (through scheduleRenewal, so the frequency floor + cap still
|
|
950
|
+
// apply) instead of leaving the process with no scheduled renewal
|
|
951
|
+
// for the rest of its life.
|
|
952
|
+
if (!ok && !stopped && tokenExpiresAt !== null) {
|
|
953
|
+
renewTimer = authSetTimer(() => {
|
|
954
|
+
renewTimer = null;
|
|
955
|
+
void renewCredentialNow().then((ok2) => {
|
|
956
|
+
if (!ok2) scheduleRenewal();
|
|
957
|
+
});
|
|
958
|
+
}, reprobeMs);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
}, delay);
|
|
962
|
+
};
|
|
963
|
+
|
|
772
964
|
const handleAuthFailure = (
|
|
773
965
|
canvas: CanvasDescriptor,
|
|
774
966
|
canvasPaths: import('./agent.ts').CanvasSyncPaths,
|
|
@@ -777,7 +969,10 @@ export function createSyncRuntime(
|
|
|
777
969
|
): void => {
|
|
778
970
|
if (stopped) return;
|
|
779
971
|
const reasonClass = classifyAuthFailure(rawReason);
|
|
780
|
-
|
|
972
|
+
// feature-sync-progress-modal — the class rides into the per-item list so
|
|
973
|
+
// a rejected row in the Sync panel can say WHY (our vocabulary, not the
|
|
974
|
+
// hub's raw message).
|
|
975
|
+
mon.noteDocState(canvas.slug, 'auth-rejected', reasonClass);
|
|
781
976
|
rejectedReasons.set(canvas.slug, reasonClass);
|
|
782
977
|
// Aggregate console output: ONE debounced warn for the whole burst.
|
|
783
978
|
if (!pendingAuthWarn.has(reasonClass)) pendingAuthWarn.set(reasonClass, new Set());
|
|
@@ -797,6 +992,17 @@ export function createSyncRuntime(
|
|
|
797
992
|
}
|
|
798
993
|
scheduleReprobe();
|
|
799
994
|
}
|
|
995
|
+
// An invalid token is the one refusal the runtime can FIX: the stored
|
|
996
|
+
// credential expired (≤ 12 h cell sessions, Phase 23 B2) while the
|
|
997
|
+
// account next to it is still signed in. Renew silently — single-flight
|
|
998
|
+
// across the whole burst — and on success reconnect the rejected docs
|
|
999
|
+
// NOW instead of making the user wait out the slow re-probe (or press
|
|
1000
|
+
// Connect again, which is all this ever needed).
|
|
1001
|
+
if (reasonClass === 'invalid-token') {
|
|
1002
|
+
void renewCredentialNow().then((renewed) => {
|
|
1003
|
+
if (renewed) reprobeNow();
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
800
1006
|
}
|
|
801
1007
|
};
|
|
802
1008
|
|
|
@@ -820,6 +1026,21 @@ export function createSyncRuntime(
|
|
|
820
1026
|
console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
|
|
821
1027
|
};
|
|
822
1028
|
|
|
1029
|
+
/**
|
|
1030
|
+
* Stamp `syncMeta.path` from where this canvas actually is on THIS disk —
|
|
1031
|
+
* never echoed from the wire, so a value some receiver refused is never
|
|
1032
|
+
* laundered onward by being re-sent. Best-effort: a failure never costs
|
|
1033
|
+
* the canvas its sync.
|
|
1034
|
+
*/
|
|
1035
|
+
const stampFromLocalFile = (doc: Y.Doc, htmlAbs: string): void => {
|
|
1036
|
+
try {
|
|
1037
|
+
const rel = path.relative(ctx.paths.designRoot, htmlAbs).split(path.sep).join('/');
|
|
1038
|
+
if (rel && !rel.startsWith('..')) stampCanvasPath(doc, rel, ORIGINS.DISK_PROJECTION);
|
|
1039
|
+
} catch {
|
|
1040
|
+
/* best-effort bookkeeping — never costs the canvas its sync */
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
|
|
823
1044
|
/** Post-handshake reconcile — shared by first connect and re-probe. */
|
|
824
1045
|
const handleSynced = async (
|
|
825
1046
|
canvas: CanvasDescriptor,
|
|
@@ -881,21 +1102,18 @@ export function createSyncRuntime(
|
|
|
881
1102
|
}
|
|
882
1103
|
}
|
|
883
1104
|
}
|
|
884
|
-
// The path travels back OUT
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
|
|
888
|
-
try {
|
|
889
|
-
const rel = path.relative(ctx.paths.designRoot, canvasPaths.html).split(path.sep).join('/');
|
|
890
|
-
if (rel && !rel.startsWith('..'))
|
|
891
|
-
stampCanvasPath(provider.document, rel, ORIGINS.DISK_PROJECTION);
|
|
892
|
-
} catch {
|
|
893
|
-
/* best-effort bookkeeping — never costs the canvas its sync */
|
|
894
|
-
}
|
|
1105
|
+
// The path travels back OUT — re-stamped post-reconcile as the belt to
|
|
1106
|
+
// connectCanvas's pre-handshake braces (fix 5): this also covers a PULLED
|
|
1107
|
+
// canvas, whose real local path exists only after relocatePulled ran.
|
|
1108
|
+
stampFromLocalFile(provider.document, canvasPaths.html);
|
|
895
1109
|
|
|
896
1110
|
// DDR-102 — honest status: the handshake + reconcile completed.
|
|
897
1111
|
mon.noteDocState(canvas.slug, 'connected');
|
|
898
1112
|
mon.noteSyncActivity(canvas.slug);
|
|
1113
|
+
// F1 — a completed handshake IS progress: a renewal actually helped, so
|
|
1114
|
+
// the no-progress cap resets. Without this a healthy link that renews
|
|
1115
|
+
// legitimately every 12 h would burn one cap slot per renewal forever.
|
|
1116
|
+
renewalsSinceProgress = 0;
|
|
899
1117
|
};
|
|
900
1118
|
|
|
901
1119
|
/**
|
|
@@ -1070,6 +1288,17 @@ export function createSyncRuntime(
|
|
|
1070
1288
|
document,
|
|
1071
1289
|
});
|
|
1072
1290
|
providers.set(canvas.slug, provider);
|
|
1291
|
+
// Fix 5 (sync RCA 2026-08-10): stamp the canvas path BEFORE the
|
|
1292
|
+
// handshake, not only after reconcile. The path derives from this peer's
|
|
1293
|
+
// real local file, so it is known NOW — and the hub's FIRST
|
|
1294
|
+
// onDocumentStored must see it, or it memoises a flat fallback in its
|
|
1295
|
+
// pathIndex and a stub is born. Pulled canvases are the one exception:
|
|
1296
|
+
// their local path is a guess until the document arrives, and a guessed
|
|
1297
|
+
// stamp would be laundered into every other peer (handleSynced stamps
|
|
1298
|
+
// them after relocatePulled instead).
|
|
1299
|
+
if (!pulledSlugs.has(canvas.slug)) {
|
|
1300
|
+
stampFromLocalFile(provider.document, canvasPaths.html);
|
|
1301
|
+
}
|
|
1073
1302
|
// First-connect setup (agent/projection creation + doc-scoped wiring)
|
|
1074
1303
|
// MUST run before handleSynced — that function resolves the
|
|
1075
1304
|
// agent/projection from the maps, and a test stub's onceSynced can
|
|
@@ -1311,7 +1540,33 @@ export function createSyncRuntime(
|
|
|
1311
1540
|
// place after each handshake, and the markers are the one consumer that
|
|
1312
1541
|
// read them before that and would otherwise never read them again.
|
|
1313
1542
|
markUntrusted();
|
|
1543
|
+
|
|
1544
|
+
// DDR-217 (fix 6) — mirror local assets up AFTER the handshakes settle
|
|
1545
|
+
// (they carry the canvases; assets ride behind, never in front). Not
|
|
1546
|
+
// under pairing: a cell's assets are already on the cell. Fire-and-forget
|
|
1547
|
+
// — a miss is retried on the next boot for free.
|
|
1548
|
+
if (!cellPairing) {
|
|
1549
|
+
void pushAssets({
|
|
1550
|
+
designRoot: ctx.paths.designRoot,
|
|
1551
|
+
hubUrl: linkedHub.url,
|
|
1552
|
+
token: () => token,
|
|
1553
|
+
// feature-sync-progress-modal — ride the same `sync:status` payload
|
|
1554
|
+
// the doc counts use, so the Sync panel has one source. Guarded on
|
|
1555
|
+
// `stopped`: a late emit must not write `_sync.json` post-teardown.
|
|
1556
|
+
onProgress: (p) => {
|
|
1557
|
+
if (!stopped) store.updateAssets?.(p);
|
|
1558
|
+
},
|
|
1559
|
+
}).catch((err) => {
|
|
1560
|
+
console.warn(`[sync/assets] asset push failed: ${(err as Error).message}`);
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1314
1563
|
});
|
|
1564
|
+
|
|
1565
|
+
// Arm the pre-expiry renewal from the credential that just booted. Placed
|
|
1566
|
+
// last — the timer needs nothing from boot, and boot needs nothing from it
|
|
1567
|
+
// (a credential that dies mid-boot lands in the invalid-token path, which
|
|
1568
|
+
// triggers renewal on its own).
|
|
1569
|
+
scheduleRenewal();
|
|
1315
1570
|
}
|
|
1316
1571
|
|
|
1317
1572
|
async function stop(): Promise<void> {
|
|
@@ -1367,6 +1622,10 @@ export function createSyncRuntime(
|
|
|
1367
1622
|
authClearTimer(reprobeTimer);
|
|
1368
1623
|
reprobeTimer = null;
|
|
1369
1624
|
}
|
|
1625
|
+
if (renewTimer !== null) {
|
|
1626
|
+
authClearTimer(renewTimer);
|
|
1627
|
+
renewTimer = null;
|
|
1628
|
+
}
|
|
1370
1629
|
for (const h of settleTimers) authClearTimer(h);
|
|
1371
1630
|
settleTimers.clear();
|
|
1372
1631
|
for (const h of announceTimers.values()) clearTimeout(h);
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// One-shot quarantine of the pre-fix-5 flat fallback twins — fix 4 of the
|
|
2
|
+
// 2026-08-10 sync RCA (v0.58.2's `f2a38c46` changed the fallback rule with no
|
|
3
|
+
// migration of what the old rule had already written).
|
|
4
|
+
//
|
|
5
|
+
// Until then the no-path fallback wrote a pulled canvas FLAT at the design root
|
|
6
|
+
// (`ui-welcome.tsx`); the group-aware fallback plus the stamp-race fix (phase
|
|
7
|
+
// sync-2) stopped producing new ones, but whatever the old rule wrote is still
|
|
8
|
+
// on disk NEXT TO the real grouped file — two files, one slug, one document
|
|
9
|
+
// (DDR-064 A4's collision class), and the tree lists both.
|
|
10
|
+
//
|
|
11
|
+
// The rules, in migrate-seed.ts's posture (idempotent, best-effort, never
|
|
12
|
+
// throws into boot):
|
|
13
|
+
//
|
|
14
|
+
// - Only a flat file WITH a grouped twin of the same slug moves — the flat
|
|
15
|
+
// copy is provably the redundant one. A flat file with NO twin is a
|
|
16
|
+
// genuinely-flat canvas somebody authored; moving it would lose work, so
|
|
17
|
+
// it stays (fix 5 + a future re-sync relocates it).
|
|
18
|
+
// - Quarantine, never delete: the loser goes to `_trash/<slug>-flat-<ts>/`
|
|
19
|
+
// (the established runtime quarantine, DDR-115), `.meta.json`/`.css`
|
|
20
|
+
// siblings riding along. The `<slug>.annotations.svg` sidecar does NOT
|
|
21
|
+
// move — annotations are keyed by the flat slug at the design root and
|
|
22
|
+
// serve the surviving grouped twin.
|
|
23
|
+
// - One log line per move, so a tenant can find their bytes.
|
|
24
|
+
|
|
25
|
+
import { type Dirent, existsSync, mkdirSync, readdirSync, renameSync } from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { canvasSlugFromRel } from '../canvas-slug.ts';
|
|
29
|
+
|
|
30
|
+
export interface FlatFallbackMove {
|
|
31
|
+
slug: string;
|
|
32
|
+
/** Design-root-relative flat body that moved. */
|
|
33
|
+
from: string;
|
|
34
|
+
/** Design-root-relative grouped body that made it redundant. */
|
|
35
|
+
keptTwin: string;
|
|
36
|
+
/** Design-root-relative quarantine dir the flat set landed in. */
|
|
37
|
+
trashedTo: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Scan the design root, quarantine every flat body whose slug a grouped file
|
|
42
|
+
* also claims. Returns what moved (empty on a clean tree — the idempotent
|
|
43
|
+
* steady state). Never throws.
|
|
44
|
+
*/
|
|
45
|
+
export function migrateFlatFallback(opts: {
|
|
46
|
+
designRoot: string;
|
|
47
|
+
/** Design root relative to the repo root (`.design`) — for slug derivation. */
|
|
48
|
+
designRel: string;
|
|
49
|
+
log?: (line: string) => void;
|
|
50
|
+
}): FlatFallbackMove[] {
|
|
51
|
+
const { designRoot, designRel } = opts;
|
|
52
|
+
const log = opts.log ?? ((line: string) => console.log(line));
|
|
53
|
+
const moves: FlatFallbackMove[] = [];
|
|
54
|
+
try {
|
|
55
|
+
// slug → every body path claiming it, design-root-relative.
|
|
56
|
+
const claims = new Map<string, string[]>();
|
|
57
|
+
for (const rel of walk(designRoot)) {
|
|
58
|
+
if (!/\.tsx$/i.test(rel)) continue;
|
|
59
|
+
const slug = canvasSlugFromRel(rel, designRel);
|
|
60
|
+
const list = claims.get(slug);
|
|
61
|
+
if (list) list.push(rel);
|
|
62
|
+
else claims.set(slug, [rel]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const [slug, rels] of claims) {
|
|
66
|
+
const flat = rels.find((rel) => !rel.includes('/'));
|
|
67
|
+
const twin = rels.find((rel) => rel.includes('/'));
|
|
68
|
+
if (!flat || !twin) continue;
|
|
69
|
+
|
|
70
|
+
const trashedTo = `_trash/${slug}-flat-${Date.now()}`;
|
|
71
|
+
const base = flat.replace(/\.tsx$/i, '');
|
|
72
|
+
const movedLanes: string[] = [];
|
|
73
|
+
try {
|
|
74
|
+
mkdirSync(path.join(designRoot, trashedTo), { recursive: true });
|
|
75
|
+
for (const lane of [flat, `${base}.meta.json`, `${base}.css`]) {
|
|
76
|
+
const src = path.join(designRoot, lane);
|
|
77
|
+
if (!existsSync(src)) continue;
|
|
78
|
+
renameSync(src, path.join(designRoot, trashedTo, path.basename(lane)));
|
|
79
|
+
movedLanes.push(lane);
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
log(
|
|
83
|
+
`[sync/migrate-flat-fallback] could not quarantine ${flat}: ${(err as Error).message} — leaving it in place`
|
|
84
|
+
);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
moves.push({ slug, from: flat, keptTwin: twin, trashedTo });
|
|
88
|
+
log(
|
|
89
|
+
`[sync/migrate-flat-fallback] ${flat} duplicated ${twin} (slug ${slug}) — moved ${movedLanes.join(
|
|
90
|
+
', '
|
|
91
|
+
)} to ${trashedTo}/`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
/* best-effort — a scan failure must never cost the boot */
|
|
96
|
+
}
|
|
97
|
+
return moves;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Files under `dir`, relative to it, skipping runtime-state (`_*`) and repo
|
|
101
|
+
* internals — the same exclusions the hub's checkout scan applies. */
|
|
102
|
+
function walk(dir: string, prefix = '', out: string[] = []): string[] {
|
|
103
|
+
let entries: Dirent[];
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
106
|
+
} catch {
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
111
|
+
if (entry.isDirectory()) {
|
|
112
|
+
if (entry.name.startsWith('_') || entry.name === '.git' || entry.name === 'node_modules') {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
walk(path.join(dir, entry.name), rel, out);
|
|
116
|
+
} else if (entry.isFile()) {
|
|
117
|
+
out.push(rel);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|