@1agh/maude 0.56.0 → 0.58.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 +5 -1
- package/apps/studio/acp/bridge.ts +385 -26
- package/apps/studio/acp/index.ts +498 -102
- package/apps/studio/acp/running.ts +97 -0
- package/apps/studio/acp/transcript.ts +64 -0
- package/apps/studio/acp/write-scope.ts +459 -0
- package/apps/studio/api.ts +69 -4
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +187 -39
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +72 -13
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/acp-runtime.js +96 -5
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/styles/3-shell-maude.css +56 -2
- package/apps/studio/client/styles/6-acp-chat.css +79 -0
- package/apps/studio/dist/client.bundle.js +1436 -1436
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +59 -2
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +50 -1
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/test/acp-activity-endpoint.test.ts +183 -0
- package/apps/studio/test/acp-branch-guard.test.ts +123 -0
- package/apps/studio/test/acp-bridge-lifetime.test.ts +369 -0
- package/apps/studio/test/acp-caps-bridge.test.ts +5 -0
- package/apps/studio/test/acp-commands.test.ts +5 -0
- package/apps/studio/test/acp-elicitation-bridge.test.ts +5 -0
- package/apps/studio/test/acp-permission-prompt.test.ts +175 -1
- package/apps/studio/test/acp-permission.test.ts +18 -2
- package/apps/studio/test/acp-session-allowed-tools.test.ts +62 -14
- package/apps/studio/test/acp-usage-bridge.test.ts +5 -0
- package/apps/studio/test/acp-write-gate.test.ts +323 -0
- package/apps/studio/test/acp-write-scope.test.ts +533 -0
- package/apps/studio/test/bundle-smoke.test.ts +35 -18
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/cloud-connect-note.test.ts +135 -0
- package/apps/studio/test/cloud-endpoints.test.ts +11 -4
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/sync-connect-honesty.test.ts +114 -0
- package/apps/studio/test/sync-connection-state.test.ts +45 -1
- package/apps/studio/test/sync-presentation.test.ts +185 -0
- package/apps/studio/test/sync-remote-docs.test.ts +171 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
package/apps/studio/http.ts
CHANGED
|
@@ -17,7 +17,14 @@ import {
|
|
|
17
17
|
startSignin,
|
|
18
18
|
} from './acp/login-state.ts';
|
|
19
19
|
import { probeAcpAvailabilityAuthed } from './acp/probe.ts';
|
|
20
|
-
import {
|
|
20
|
+
import { activitySnapshot, runningChats } from './acp/running.ts';
|
|
21
|
+
import {
|
|
22
|
+
chatTranscriptSeq,
|
|
23
|
+
deleteChat,
|
|
24
|
+
listChats,
|
|
25
|
+
readChatMessages,
|
|
26
|
+
writeChatMeta,
|
|
27
|
+
} from './acp/transcript.ts';
|
|
21
28
|
import { type Api, ASSET_MAX_BYTES, ASSET_MAX_VIDEO_BYTES } from './api.ts';
|
|
22
29
|
import { ImportAssetError, importSvg, SVG_MAX_BYTES } from './bin/_import-asset.mjs';
|
|
23
30
|
import { ImportBrandError, importBrand } from './bin/_import-brand.mjs';
|
|
@@ -1319,6 +1326,40 @@ export function createHttp(
|
|
|
1319
1326
|
return Response.json({ ok: true }, { headers: { 'Cache-Control': 'no-store' } });
|
|
1320
1327
|
},
|
|
1321
1328
|
|
|
1329
|
+
// Addendum Task 9 — "is a chat mid-turn right now?", asked by
|
|
1330
|
+
// RepoBranchSwitcher.jsx before any of its three `window.location.reload()`
|
|
1331
|
+
// call sites. A `git checkout` moves the worktree under a running agent
|
|
1332
|
+
// (read on `draft-a`, written back after the checkout to `main` — a silent
|
|
1333
|
+
// cross-branch clobber that `_history/`, being per-canvas-slug with no
|
|
1334
|
+
// branch awareness, cannot undo), so this gates a confirm rather than
|
|
1335
|
+
// reloading silently. MAIN-ORIGIN ONLY, read-only, no chat CONTENT — just
|
|
1336
|
+
// how many are busy, so it says nothing the canvas origin could want.
|
|
1337
|
+
'/_api/acp/running': (req: Request) => {
|
|
1338
|
+
if (!isTrustedRequestHost(req))
|
|
1339
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1340
|
+
const running = runningChats();
|
|
1341
|
+
return Response.json(
|
|
1342
|
+
{ running: running.length, chats: running },
|
|
1343
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
1344
|
+
);
|
|
1345
|
+
},
|
|
1346
|
+
|
|
1347
|
+
// feature-acp-turn-notifications Task 3 — the native shell's poller reads
|
|
1348
|
+
// this to decide whether to fire an OS notification for a NON-visible
|
|
1349
|
+
// project (Decision C). A sibling of `/_api/acp/running` above rather than
|
|
1350
|
+
// an extension of it — that route stays exactly as-is because the reaper's
|
|
1351
|
+
// `has_running_chat` (sidecar.rs) already depends on its `{running,chats}`
|
|
1352
|
+
// shape and has no reason to grow one. MAIN-ORIGIN ONLY, same gate, and —
|
|
1353
|
+
// load-bearing (Decision D) — NEVER a chat title, message text, or any
|
|
1354
|
+
// transcript content: just chat ids (opaque, already exposed by `running`
|
|
1355
|
+
// above) and a three-value state. Anyone adding a field here should ask
|
|
1356
|
+
// what it would look like rendered on a lock screen.
|
|
1357
|
+
'/_api/acp/activity': (req: Request) => {
|
|
1358
|
+
if (!isTrustedRequestHost(req))
|
|
1359
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1360
|
+
return Response.json(activitySnapshot(), { headers: { 'Cache-Control': 'no-store' } });
|
|
1361
|
+
},
|
|
1362
|
+
|
|
1322
1363
|
// Phase 31 — repo-level chat list + history (for the chat switcher +
|
|
1323
1364
|
// hydration). MAIN-ORIGIN ONLY. Read-only; ids are sanitized before disk.
|
|
1324
1365
|
'/_api/acp/chats': () =>
|
|
@@ -1365,8 +1406,16 @@ export function createHttp(
|
|
|
1365
1406
|
return Response.json(meta, { headers: { 'Cache-Control': 'no-store' } });
|
|
1366
1407
|
}
|
|
1367
1408
|
if (!id) return Response.json([], { headers: { 'Cache-Control': 'no-store' } });
|
|
1409
|
+
// Addendum Task 8 — the re-attach seam. Read the sequence marker BEFORE
|
|
1410
|
+
// the messages: a line appended between the two reads then shows up in
|
|
1411
|
+
// neither, and the client's `attach` will replay it. Reading it after
|
|
1412
|
+
// would risk the opposite (a line counted but not returned), which the
|
|
1413
|
+
// client would silently drop as "already hydrated" — a hole in the feed.
|
|
1414
|
+
// Shipped as a header rather than a body field so the response SHAPE
|
|
1415
|
+
// stays the plain message array every existing consumer expects.
|
|
1416
|
+
const seq = chatTranscriptSeq(ctx.paths.designRoot, id);
|
|
1368
1417
|
return Response.json(readChatMessages(ctx.paths.designRoot, id), {
|
|
1369
|
-
headers: { 'Cache-Control': 'no-store' },
|
|
1418
|
+
headers: { 'Cache-Control': 'no-store', 'X-Maude-Chat-Seq': String(seq) },
|
|
1370
1419
|
});
|
|
1371
1420
|
},
|
|
1372
1421
|
|
|
@@ -1458,6 +1507,14 @@ export function createHttp(
|
|
|
1458
1507
|
...ctx.cfg,
|
|
1459
1508
|
canvasOrigin: ctx.canvasOrigin,
|
|
1460
1509
|
readOnly: projectReadOnly(req),
|
|
1510
|
+
// WHICH RELEASE THIS IS. `/_config` reaches the browser in cloud mode,
|
|
1511
|
+
// so the bar for adding a field here is "would I publish it" — and a
|
|
1512
|
+
// version is the git tag, which is public. Nothing else rides along.
|
|
1513
|
+
//
|
|
1514
|
+
// Resolved through `resolveMaudeVersion`, the same function the What's
|
|
1515
|
+
// New feed uses: one answer, so the chip in the status bar and the feed
|
|
1516
|
+
// can never name different versions.
|
|
1517
|
+
version: resolveMaudeVersion(),
|
|
1461
1518
|
// Cloud Phase 27 (DDR-209) — the capability that opens the cookieless
|
|
1462
1519
|
// canvas origin, minted per session by the proxy. Absent on a desktop,
|
|
1463
1520
|
// where the canvas origin is loopback and needs none. `canvasUrl()`
|
|
@@ -446,6 +446,34 @@ export function yieldsToArtboardDrag(
|
|
|
446
446
|
);
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
+
/**
|
|
450
|
+
* Click-phase counterpart to `yieldsToArtboardDrag` (issue-78). The onClick
|
|
451
|
+
* handler below suppresses the browser's native `click` for every gesture it
|
|
452
|
+
* already routed as `select` on the matching pointerdown, so a just-selected
|
|
453
|
+
* element's own click handler doesn't also fire. But an artboard-chrome
|
|
454
|
+
* control — e.g. the video-timeline badge (`.dc-artboard-video-badge`,
|
|
455
|
+
* DDR-148) rendered as a sibling of `.dc-artboard-body` — never goes through
|
|
456
|
+
* `select`; it's an interactive button that needs its own `onClick` to reach
|
|
457
|
+
* React. Without this exemption a bare click on it while in Move (or a
|
|
458
|
+
* Cmd/Ctrl+click from Browse/an annotation tool) is preventDefault +
|
|
459
|
+
* stopImmediatePropagation'd in the capture phase before React's delegated
|
|
460
|
+
* listener ever runs, so the button silently does nothing. Mirrors
|
|
461
|
+
* `yieldsToArtboardDrag`'s modifier gate: a held Cmd/Ctrl is reserved for
|
|
462
|
+
* deep-select and stays unexempted.
|
|
463
|
+
*/
|
|
464
|
+
export function yieldsClickToArtboardChrome(
|
|
465
|
+
wouldRouteKind: RouterAction['kind'] | null,
|
|
466
|
+
target: EventTarget | null,
|
|
467
|
+
modifiers: { metaKey: boolean; ctrlKey: boolean }
|
|
468
|
+
): boolean {
|
|
469
|
+
return (
|
|
470
|
+
wouldRouteKind === 'select' &&
|
|
471
|
+
!modifiers.metaKey &&
|
|
472
|
+
!modifiers.ctrlKey &&
|
|
473
|
+
isArtboardDragChrome(target)
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
449
477
|
export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
450
478
|
const { hostRef, getActiveTool, isSpaceHeld, callbacks, enabled = true, claimableActions } = opts;
|
|
451
479
|
|
|
@@ -631,8 +659,18 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
631
659
|
? 'context-menu'
|
|
632
660
|
: null;
|
|
633
661
|
if (wouldRouteKind && (!claimableActions || claimableActions.has(wouldRouteKind))) {
|
|
662
|
+
// issue-78 — a bare click on an artboard-chrome control (the video-
|
|
663
|
+
// timeline badge) must still reach its own onClick; see
|
|
664
|
+
// yieldsClickToArtboardChrome for why. Mirrors the onPointerDown/
|
|
665
|
+
// onMouseDown pattern above: preventDefault always runs (so a forged
|
|
666
|
+
// chrome-alike element inside untrusted canvas content can't use this
|
|
667
|
+
// exemption to let its own native click default action — e.g. an
|
|
668
|
+
// <a>'s navigation or an <input type="file">'s picker — fire), but
|
|
669
|
+
// stopImmediatePropagation is skipped so the real chrome button's own
|
|
670
|
+
// React onClick still gets the (now-inert-by-default) event.
|
|
671
|
+
const yields = yieldsClickToArtboardChrome(wouldRouteKind, e.target, e);
|
|
634
672
|
e.preventDefault();
|
|
635
|
-
e.stopImmediatePropagation();
|
|
673
|
+
if (!yields) e.stopImmediatePropagation();
|
|
636
674
|
}
|
|
637
675
|
};
|
|
638
676
|
|
package/apps/studio/server.ts
CHANGED
|
@@ -735,6 +735,17 @@ async function shutdown() {
|
|
|
735
735
|
// previously-unanswered "how does app-quit reach the grandchild" question.
|
|
736
736
|
cancelSignin();
|
|
737
737
|
cancelInstall();
|
|
738
|
+
// Addendum Task 8 — bridges now outlive their WebSocket, so socket-close is
|
|
739
|
+
// no longer the reaper it used to be. App quit / dev-server shutdown is the
|
|
740
|
+
// ONE lifetime boundary the detached model deliberately does NOT survive
|
|
741
|
+
// (DDR-166's SIGTERM-first path): extending a session across a project or
|
|
742
|
+
// branch *switch* is the point; extending it across a quit is not, and a
|
|
743
|
+
// surviving `claude` subprocess after quit would be an orphan.
|
|
744
|
+
try {
|
|
745
|
+
acp.stopAll();
|
|
746
|
+
} catch {
|
|
747
|
+
/* best-effort — the process is exiting either way */
|
|
748
|
+
}
|
|
738
749
|
fsWatch.stop();
|
|
739
750
|
try {
|
|
740
751
|
gitWatch.stop();
|
|
@@ -47,6 +47,22 @@ export interface SyncStatusSnapshot {
|
|
|
47
47
|
/** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
|
|
48
48
|
* for the true count). Treat as text, never HTML. */
|
|
49
49
|
rejectedSlugs?: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Canvases this run brought DOWN from the project — documents that existed
|
|
52
|
+
* only on the hub and are now real local files.
|
|
53
|
+
*
|
|
54
|
+
* This field was briefly `remoteGap`, "what the project has that this machine
|
|
55
|
+
* does not". That name stopped being true the moment the pull landed: the
|
|
56
|
+
* diff is computed before providers are built and recorded after, so it
|
|
57
|
+
* enumerated exactly the documents that had just arrived. Observed live —
|
|
58
|
+
* `_sync.json` naming two canvases that were sitting on disk.
|
|
59
|
+
*
|
|
60
|
+
* "What arrived this run" is both true and the more useful fact: it is what
|
|
61
|
+
* the Synced state tells the user to go and open. Absent when the hub could
|
|
62
|
+
* not be asked (old hub, offline) or when nothing was pulled. Names are
|
|
63
|
+
* hub-controlled — treat as text, never HTML, and see the cap in `notePulled`.
|
|
64
|
+
*/
|
|
65
|
+
pulled?: { names: string[]; count: number };
|
|
50
66
|
}
|
|
51
67
|
|
|
52
68
|
type TimerHandle = ReturnType<typeof setTimeout>;
|
|
@@ -75,6 +91,8 @@ export interface ConnectionMonitor {
|
|
|
75
91
|
/** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
|
|
76
92
|
* applied): bumps `lastSyncAt` to now. */
|
|
77
93
|
noteSyncActivity(slug: string): void;
|
|
94
|
+
/** Record the canvases this run pulled down from the project (see `pulled`). */
|
|
95
|
+
notePulled(slugs: readonly string[]): void;
|
|
78
96
|
/** Current snapshot (defensive copy). */
|
|
79
97
|
snapshot(): SyncStatusSnapshot;
|
|
80
98
|
/** Tear down timers. */
|
|
@@ -100,7 +118,14 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
100
118
|
// DDR-102 — per-doc states (pending/connected/auth-rejected).
|
|
101
119
|
const docStates = new Map<string, DocSyncState>();
|
|
102
120
|
|
|
103
|
-
|
|
121
|
+
// NOT born connected. The monitor used to start `online`, so from the instant
|
|
122
|
+
// a link was created — before a socket, before a token was accepted, before a
|
|
123
|
+
// byte moved — every surface reading `state` reported success. On a healthy
|
|
124
|
+
// fast connect that was harmless (the truth arrived milliseconds later); on a
|
|
125
|
+
// refused or unreachable one it was the whole bug: the user was shown the
|
|
126
|
+
// intention and read it as the result. `connecting` is the honest seed, and
|
|
127
|
+
// it is the state a link genuinely occupies until a provider says otherwise.
|
|
128
|
+
let state: SyncState = 'connecting';
|
|
104
129
|
let queuedOps = 0;
|
|
105
130
|
let lastSyncAt: number | null = null;
|
|
106
131
|
let offlineSince: number | null = null;
|
|
@@ -111,6 +136,25 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
111
136
|
let flashTimer: TimerHandle | null = null;
|
|
112
137
|
let stopped = false;
|
|
113
138
|
|
|
139
|
+
/** Canvases pulled down from the project this run. */
|
|
140
|
+
let pulled: SyncStatusSnapshot['pulled'];
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Start the clock at construction, not at the first provider event.
|
|
144
|
+
*
|
|
145
|
+
* `enterGrace` is reachable ONLY from `noteProviderStatus`, so a link where
|
|
146
|
+
* no provider ever reports — every provider rejected during boot, a hub that
|
|
147
|
+
* never completes an upgrade — would have sat in `connecting` forever and
|
|
148
|
+
* never reached the offline banner. Seeding `state` honestly is not enough on
|
|
149
|
+
* its own; something has to be counting from the moment the link exists.
|
|
150
|
+
*/
|
|
151
|
+
function armInitialGrace(): void {
|
|
152
|
+
graceTimer = setTimer(() => {
|
|
153
|
+
graceTimer = null;
|
|
154
|
+
goOffline();
|
|
155
|
+
}, graceMs);
|
|
156
|
+
}
|
|
157
|
+
|
|
114
158
|
function snapshot(): SyncStatusSnapshot {
|
|
115
159
|
const docs = { synced: 0, pending: 0, rejected: 0 };
|
|
116
160
|
const rejectedSlugs: string[] = [];
|
|
@@ -130,9 +174,31 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
130
174
|
updatedAt: now(),
|
|
131
175
|
docs,
|
|
132
176
|
rejectedSlugs,
|
|
177
|
+
...(pulled ? { pulled } : {}),
|
|
133
178
|
};
|
|
134
179
|
}
|
|
135
180
|
|
|
181
|
+
/**
|
|
182
|
+
* Record the canvases this run pulled down from the project.
|
|
183
|
+
*
|
|
184
|
+
* `names` is capped like `rejectedSlugs` — this reaches a UI and a JSON file,
|
|
185
|
+
* and an unbounded list of hub-chosen names is a hub-controlled payload size.
|
|
186
|
+
* `count` keeps the true total, so the cap never falsifies the number the
|
|
187
|
+
* user is shown. An empty list clears the field rather than recording a zero:
|
|
188
|
+
* a run that pulled nothing has nothing to say.
|
|
189
|
+
*/
|
|
190
|
+
function notePulled(slugs: readonly string[]): void {
|
|
191
|
+
// Every other mutator carries this guard; without it a late call could
|
|
192
|
+
// write `_sync.json` and broadcast for a monitor that has been torn down.
|
|
193
|
+
// Unreachable today (the sole caller checks `stopped` too) — kept so the
|
|
194
|
+
// invariant does not depend on the caller remembering it.
|
|
195
|
+
if (stopped) return;
|
|
196
|
+
pulled = slugs.length
|
|
197
|
+
? { names: slugs.slice(0, MAX_REJECTED_SLUGS), count: slugs.length }
|
|
198
|
+
: undefined;
|
|
199
|
+
emit();
|
|
200
|
+
}
|
|
201
|
+
|
|
136
202
|
function emit(): void {
|
|
137
203
|
onChange?.(snapshot());
|
|
138
204
|
}
|
|
@@ -181,15 +247,20 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
181
247
|
}
|
|
182
248
|
|
|
183
249
|
function enterGrace(): void {
|
|
184
|
-
// Already
|
|
185
|
-
if (state
|
|
250
|
+
// Already offline — stay there until a provider reports 'connected'.
|
|
251
|
+
if (state === 'offline' || state === 'offline-long') return;
|
|
252
|
+
// Already counting down — don't restart the clock. The guard used to be
|
|
253
|
+
// `state !== 'online'`; the timer, not the state, is what says whether a
|
|
254
|
+
// countdown is running, and with the `connecting` seed those are no longer
|
|
255
|
+
// the same question.
|
|
256
|
+
if (graceTimer !== null) return;
|
|
186
257
|
state = 'connecting';
|
|
187
|
-
if (graceTimer !== null) clearTimer(graceTimer);
|
|
188
258
|
graceTimer = setTimer(() => {
|
|
189
259
|
graceTimer = null;
|
|
190
260
|
goOffline();
|
|
191
261
|
}, graceMs);
|
|
192
|
-
emit
|
|
262
|
+
// No emit here — the sole caller emits once for the whole update, so a
|
|
263
|
+
// demotion that does not change `state` still reaches the readers.
|
|
193
264
|
}
|
|
194
265
|
|
|
195
266
|
function goOffline(): void {
|
|
@@ -204,19 +275,56 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
204
275
|
emit();
|
|
205
276
|
}
|
|
206
277
|
|
|
278
|
+
armInitialGrace();
|
|
279
|
+
|
|
207
280
|
return {
|
|
208
281
|
noteProviderStatus(providerId, status) {
|
|
209
282
|
if (stopped) return;
|
|
283
|
+
|
|
284
|
+
// EMIT ONLY ON A REAL CHANGE.
|
|
285
|
+
//
|
|
286
|
+
// Every emit is a synchronous `_sync.json` write plus a WS broadcast to
|
|
287
|
+
// every open tab. Provider status is driven by the socket lifecycle,
|
|
288
|
+
// which the hub controls, and there is one provider per canvas — 75+ in
|
|
289
|
+
// the reported project. An unconditional emit here turns a flapping (or
|
|
290
|
+
// deliberately hostile) hub into a sustained disk-write and fan-out loop
|
|
291
|
+
// on the dev-server's main thread. The pre-existing code emitted only on
|
|
292
|
+
// a transition; adding the demotion below must not cost that property.
|
|
293
|
+
const prevStatus = providerStatuses.get(providerId);
|
|
210
294
|
providerStatuses.set(providerId, status);
|
|
295
|
+
let changed = prevStatus !== status;
|
|
296
|
+
|
|
297
|
+
// A document whose socket is gone is not a synced document.
|
|
298
|
+
//
|
|
299
|
+
// `docs.synced` used to only ever RISE — nothing demoted a `connected`
|
|
300
|
+
// doc, so a hub that died left the last count frozen on screen, which is
|
|
301
|
+
// the most convincing form of the lie: it was true a moment ago. The
|
|
302
|
+
// provider id IS the slug (`index.ts` passes `canvas.slug`), so the
|
|
303
|
+
// monitor already knows which document just lost its socket.
|
|
304
|
+
//
|
|
305
|
+
// `auth-rejected` is deliberately NOT demoted. The hub gave an answer;
|
|
306
|
+
// losing the socket afterwards does not turn that answer back into
|
|
307
|
+
// "still trying", and letting it would hide a rotated credential behind
|
|
308
|
+
// a spinner.
|
|
309
|
+
if (status !== 'connected' && docStates.get(providerId) === 'connected') {
|
|
310
|
+
docStates.set(providerId, 'pending');
|
|
311
|
+
changed = true;
|
|
312
|
+
}
|
|
313
|
+
|
|
211
314
|
const agg = aggregate();
|
|
212
315
|
if (agg === 'connected') {
|
|
316
|
+
// goOnline() emits for the transition; otherwise only a real change
|
|
317
|
+
// (this provider's status moved, or a document was demoted) is news.
|
|
213
318
|
if (state !== 'online') goOnline();
|
|
319
|
+
else if (changed) emit();
|
|
214
320
|
return;
|
|
215
321
|
}
|
|
216
322
|
// Aggregate is connecting or disconnected → start (or continue) the
|
|
217
323
|
// grace countdown. Once offline/offline-long we stay there until a
|
|
218
324
|
// provider reports 'connected' again.
|
|
219
|
-
|
|
325
|
+
const before = state;
|
|
326
|
+
enterGrace();
|
|
327
|
+
if (changed || state !== before) emit();
|
|
220
328
|
},
|
|
221
329
|
|
|
222
330
|
noteLocalEdit() {
|
|
@@ -245,6 +353,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
|
|
|
245
353
|
emit();
|
|
246
354
|
},
|
|
247
355
|
|
|
356
|
+
notePulled,
|
|
357
|
+
|
|
248
358
|
snapshot,
|
|
249
359
|
|
|
250
360
|
stop() {
|
|
@@ -45,6 +45,7 @@ import { loadJournal, type SyncJournal } from './journal.ts';
|
|
|
45
45
|
import { isLoopbackHost } from './loopback.ts';
|
|
46
46
|
import { migrateSeed } from './migrate-seed.ts';
|
|
47
47
|
import { createDocProjection, type DocProjection } from './projection.ts';
|
|
48
|
+
import { describeRemoteDiff, diffRemoteDocs, fetchRemoteDocs, pullTargets } from './remote-docs.ts';
|
|
48
49
|
import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
|
|
49
50
|
import { writeUntrustedMarkers } from './untrusted.ts';
|
|
50
51
|
|
|
@@ -424,7 +425,46 @@ export function createSyncRuntime(
|
|
|
424
425
|
const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
|
|
425
426
|
// DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
|
|
426
427
|
// the pinned set must be bounded. See `admitCanvases`.
|
|
427
|
-
const
|
|
428
|
+
const localCanvases = admitCanvases(scan.canvases, useSharedDoc);
|
|
429
|
+
|
|
430
|
+
// PULL THE REST OF THE PROJECT DOWN.
|
|
431
|
+
//
|
|
432
|
+
// The scan above only sees this machine's disk, and Yjs cannot enumerate —
|
|
433
|
+
// so before this, a document that existed only on the hub was invisible to
|
|
434
|
+
// a peer forever. A desktop carrying 72 of a project's 75 canvases synced
|
|
435
|
+
// 72, reported "72/72 synced", and was accurate about the wrong universe.
|
|
436
|
+
// That is what "Open in Maude does nothing" was.
|
|
437
|
+
//
|
|
438
|
+
// A project you have access to is a project you get, in full and in both
|
|
439
|
+
// directions. Hub-only documents become real local files (flat under the
|
|
440
|
+
// design root — see `pullTargets` for why flat); local-only canvases go up
|
|
441
|
+
// as they always did. Best-effort: an older hub without the listing route,
|
|
442
|
+
// or an unreachable one, syncs exactly as before.
|
|
443
|
+
const remoteDocs = await fetchRemoteDocs(linkedHub.url, resolvedToken);
|
|
444
|
+
const remoteDiff = diffRemoteDocs(
|
|
445
|
+
localCanvases.map((c) => docNameFor(c.slug)),
|
|
446
|
+
remoteDocs
|
|
447
|
+
);
|
|
448
|
+
const pulled = pullTargets(
|
|
449
|
+
remoteDiff.hubOnly,
|
|
450
|
+
ctx.paths.designRoot,
|
|
451
|
+
path.join,
|
|
452
|
+
path.resolve,
|
|
453
|
+
path.sep
|
|
454
|
+
);
|
|
455
|
+
const pullNote = describeRemoteDiff(remoteDiff);
|
|
456
|
+
if (pullNote) console.log(`[sync] ${pullNote}`);
|
|
457
|
+
const canvases = [
|
|
458
|
+
...localCanvases,
|
|
459
|
+
...pulled.map((t) => ({
|
|
460
|
+
slug: t.slug,
|
|
461
|
+
html: t.bodyAbs,
|
|
462
|
+
comments: path.join(ctx.paths.commentsDir, `${t.slug}.json`),
|
|
463
|
+
annotations: path.join(ctx.paths.designRoot, `${t.slug}.annotations.svg`),
|
|
464
|
+
meta: t.bodyAbs.replace(/\.tsx$/i, '.meta.json'),
|
|
465
|
+
css: t.bodyAbs.replace(/\.tsx$/i, '.css'),
|
|
466
|
+
})),
|
|
467
|
+
];
|
|
428
468
|
// T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
|
|
429
469
|
// content, so the whole set is untrusted Claude-context. Mark it (writes
|
|
430
470
|
// `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
|
|
@@ -981,6 +1021,15 @@ export function createSyncRuntime(
|
|
|
981
1021
|
console.log(
|
|
982
1022
|
`[sync] ${linkedHub.url}: ${parts.join(' · ')} · shared-doc:${useSharedDoc ? 'on' : 'off'}`
|
|
983
1023
|
);
|
|
1024
|
+
// What this run brought DOWN, recorded for `_sync.json` and the UI.
|
|
1025
|
+
//
|
|
1026
|
+
// This used to record `remoteDiff` under the name `remoteGap` — "what the
|
|
1027
|
+
// project has that this machine does not". By the time it ran, that was
|
|
1028
|
+
// false: the diff is taken BEFORE providers are built and recorded after
|
|
1029
|
+
// the pull, so it named exactly the canvases that had just arrived and
|
|
1030
|
+
// were sitting on disk. `pulled` is the same list under the name that is
|
|
1031
|
+
// true, and it is the fact the user is told to act on.
|
|
1032
|
+
mon.notePulled(pulled.map((t) => t.slug));
|
|
984
1033
|
});
|
|
985
1034
|
}
|
|
986
1035
|
|