@1agh/maude 0.56.0 → 0.57.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/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/build.ts +28 -1
- package/apps/studio/client/app.jsx +47 -30
- 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/styles/3-shell-maude.css +11 -2
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/dist/client.bundle.js +1278 -1278
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +51 -2
- 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/fixtures/mock-acp-agent-slow.mjs +45 -0
- package/apps/studio/test/fixtures/mock-acp-agent-write.mjs +118 -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/whats-new.json +18 -0
- 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
|
|
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
|
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// One rule for "what is the hub link doing", shared by every surface.
|
|
2
|
+
//
|
|
3
|
+
// There were two answers to that question and they disagreed. The status bar
|
|
4
|
+
// keyed off `state` alone — it referenced `docs` zero times, so a link whose
|
|
5
|
+
// every document the hub had refused still showed a green dot and the word
|
|
6
|
+
// "synced". The connect note in the cloud rail keyed off the ATTACH RESPONSE,
|
|
7
|
+
// a value that means "the runtime started", and never updated again. A user
|
|
8
|
+
// who distrusted one was sent to the other, which was wrong in a different way.
|
|
9
|
+
//
|
|
10
|
+
// So the rule lives here, once, as a pure function over the payload both
|
|
11
|
+
// surfaces already receive. Agreement between them is then structural rather
|
|
12
|
+
// than a thing two files have to remember.
|
|
13
|
+
//
|
|
14
|
+
// DDR-054 — document names and project names originate at the hub. Counts come
|
|
15
|
+
// first in every sentence so a hostile name cannot dominate it, names are
|
|
16
|
+
// capped, and nothing here produces markup.
|
|
17
|
+
|
|
18
|
+
import type { SyncStatusSnapshot } from './connection-state.ts';
|
|
19
|
+
|
|
20
|
+
/** Where a link is, in the terms a person cares about. */
|
|
21
|
+
export type SyncPhase =
|
|
22
|
+
| 'connecting'
|
|
23
|
+
| 'syncing'
|
|
24
|
+
| 'synced'
|
|
25
|
+
| 'refused'
|
|
26
|
+
| 'offline'
|
|
27
|
+
| 'nothing-syncable';
|
|
28
|
+
|
|
29
|
+
export interface SyncPresentation {
|
|
30
|
+
phase: SyncPhase;
|
|
31
|
+
/** Green dot — reserved for a link that is genuinely carrying edits. */
|
|
32
|
+
online: boolean;
|
|
33
|
+
/** Status-bar slot text. Terse; never contains a hub-supplied name. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Full sentence — the rail note and the status-bar hover. */
|
|
36
|
+
title: string;
|
|
37
|
+
/**
|
|
38
|
+
* What the person should do now, or null when the honest answer is "nothing,
|
|
39
|
+
* it is working". Every non-null phase names one — being told a state without
|
|
40
|
+
* being told the move is the complaint this whole change exists for.
|
|
41
|
+
*/
|
|
42
|
+
next: string | null;
|
|
43
|
+
/** Up to 3 hub-supplied names relevant to the phase (refused / pulled). */
|
|
44
|
+
names: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The `/_sync-status` payload, in its three historical shapes:
|
|
49
|
+
* - `{ linked: false }` — solo project, no link
|
|
50
|
+
* - `{ notSyncable, tsxCount, reason }` — DDR-060, linked but 0 syncable
|
|
51
|
+
* - the connection-state snapshot + url/canvases (the common case)
|
|
52
|
+
*
|
|
53
|
+
* Every field is optional on purpose: pre-DDR-102 payloads have no `docs`, and
|
|
54
|
+
* the browser and the CLI read the same JSON.
|
|
55
|
+
*/
|
|
56
|
+
export interface SyncStatusLike extends Partial<SyncStatusSnapshot> {
|
|
57
|
+
linked?: boolean;
|
|
58
|
+
notSyncable?: boolean;
|
|
59
|
+
tsxCount?: number;
|
|
60
|
+
reason?: string;
|
|
61
|
+
canvases?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Hub-supplied text that reaches a UI. Bounded, never markup. */
|
|
65
|
+
const MAX_NAME_LEN = 60;
|
|
66
|
+
const SHOWN_NAMES = 3;
|
|
67
|
+
|
|
68
|
+
export function safeName(raw: unknown, fallback: string): string {
|
|
69
|
+
// Length alone was not enough. A name is hub-supplied (cell mode sets it from
|
|
70
|
+
// `MAUDE_PROJECT_NAME`; `.design/config.json` is a committed file anyone with
|
|
71
|
+
// repo access can author), and it lands in trusted app chrome — including a
|
|
72
|
+
// `title=` tooltip, where a newline RENDERS and can push the true clause out
|
|
73
|
+
// of view. `All 75 canvases synced.\n\n\n` fits inside 60 characters and
|
|
74
|
+
// reads as a reassuring sentence of ours.
|
|
75
|
+
//
|
|
76
|
+
// So: strip control and format characters (which covers the bidi overrides
|
|
77
|
+
// U+202A–U+202E / U+2066–U+2069 — a name that reverses the rest of the line
|
|
78
|
+
// is the same attack by a different mechanism), then collapse whitespace to
|
|
79
|
+
// single spaces so no name can ever contain a line break.
|
|
80
|
+
const s = String(raw ?? '')
|
|
81
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, '')
|
|
82
|
+
.replace(/\s+/g, ' ')
|
|
83
|
+
.trim();
|
|
84
|
+
if (!s) return fallback;
|
|
85
|
+
return s.length > MAX_NAME_LEN ? `${s.slice(0, MAX_NAME_LEN)}…` : s;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shownNames(list: readonly string[] | undefined): string[] {
|
|
89
|
+
return (list ?? []).slice(0, SHOWN_NAMES).map((n) => safeName(n, '(unnamed)'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The per-document counts, or null if they cannot be trusted.
|
|
94
|
+
*
|
|
95
|
+
* FAIL CLOSED. `/_sync-status` returns `JSON.parse` of `_sync.json` with no
|
|
96
|
+
* schema, so this function's input is whatever is on disk — a partial write, an
|
|
97
|
+
* older producer, a newer one. `synced` was the FALL-THROUGH branch, which made
|
|
98
|
+
* the reassuring answer the only reachable one for any shape not recognised:
|
|
99
|
+
* `docs: {}` gave `total === NaN`, failed both `> 0` and `=== 0`, and rendered
|
|
100
|
+
* "Synced — all NaN canvases", green. For a module whose entire premise is that
|
|
101
|
+
* it does not overstate, the unreadable case must land in `connecting`, never
|
|
102
|
+
* in `synced`.
|
|
103
|
+
*/
|
|
104
|
+
function readCounts(
|
|
105
|
+
docs: SyncStatusLike['docs']
|
|
106
|
+
): { synced: number; pending: number; rejected: number } | null {
|
|
107
|
+
if (!docs) return null;
|
|
108
|
+
const ok = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0;
|
|
109
|
+
if (!ok(docs.synced) || !ok(docs.pending) || !ok(docs.rejected)) return null;
|
|
110
|
+
return { synced: docs.synced, pending: docs.pending, rejected: docs.rejected };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Read a sync payload the way a person would.
|
|
115
|
+
*
|
|
116
|
+
* Returns null when there is nothing to say — an unlinked project has no hub
|
|
117
|
+
* status, and inventing one would put a slot on screen that can only ever be
|
|
118
|
+
* empty.
|
|
119
|
+
*/
|
|
120
|
+
export function syncPresentation(
|
|
121
|
+
status: SyncStatusLike | null | undefined,
|
|
122
|
+
opts: { project?: string | null } = {}
|
|
123
|
+
): SyncPresentation | null {
|
|
124
|
+
if (!status || status.linked === false) return null;
|
|
125
|
+
const project = safeName(opts.project, 'the workspace');
|
|
126
|
+
|
|
127
|
+
if (status.notSyncable) {
|
|
128
|
+
const tsx = status.tsxCount ?? 0;
|
|
129
|
+
return {
|
|
130
|
+
phase: 'nothing-syncable',
|
|
131
|
+
online: false,
|
|
132
|
+
label: `0 syncable${tsx > 0 ? ` · ${tsx} tsx` : ''}`,
|
|
133
|
+
title: status.reason
|
|
134
|
+
? safeName(status.reason, 'No canvases in this project are syncable yet.')
|
|
135
|
+
: `Connected to ${project} — no canvases here are syncable yet.`,
|
|
136
|
+
next: 'Create a canvas — it will start syncing on its own.',
|
|
137
|
+
names: [],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const queued = status.queuedOps ?? 0;
|
|
142
|
+
const queueNote = queued > 0 ? ` ${queued} local edit${queued === 1 ? '' : 's'} are queued.` : '';
|
|
143
|
+
const unreachable = status.state === 'offline' || status.state === 'offline-long';
|
|
144
|
+
// Validated, not taken on trust — see `readCounts`. `null` means "unreadable",
|
|
145
|
+
// which is deliberately NOT the same as "absent" (an old payload, handled
|
|
146
|
+
// below) and must never reach the synced branch.
|
|
147
|
+
const docs = readCounts(status.docs);
|
|
148
|
+
const unreadable = status.docs !== undefined && docs === null;
|
|
149
|
+
|
|
150
|
+
// A REFUSAL OUTRANKS EVERYTHING, including an unreachable hub.
|
|
151
|
+
//
|
|
152
|
+
// The offline branch used to sit above this one, and that was the bug from
|
|
153
|
+
// one branch up: a hub can refuse auth on the documents it wants silenced and
|
|
154
|
+
// then drop the sockets, and the user would be told "your edits are safe and
|
|
155
|
+
// queued — nothing to do". For an auth-rejected document that is false and
|
|
156
|
+
// never self-heals. Rejections are deliberately sticky (a dropped socket does
|
|
157
|
+
// not launder them), so if one is on record it is still true while offline —
|
|
158
|
+
// and it is the only part of the picture that needs a person.
|
|
159
|
+
if (docs && docs.rejected > 0) {
|
|
160
|
+
const total = docs.synced + docs.pending + docs.rejected;
|
|
161
|
+
return {
|
|
162
|
+
phase: 'refused',
|
|
163
|
+
online: false,
|
|
164
|
+
label: `${docs.rejected} refused`,
|
|
165
|
+
title:
|
|
166
|
+
`${docs.rejected} of ${total} canvas${total === 1 ? '' : 'es'} were refused by ${project} — their edits are not syncing.` +
|
|
167
|
+
(unreachable ? ' The hub is also unreachable right now.' : ''),
|
|
168
|
+
next: 'Reconnect the workspace — the credential may have been rotated.',
|
|
169
|
+
names: shownNames(status.rejectedSlugs),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Otherwise an unreachable hub outranks every count. Whatever the documents
|
|
174
|
+
// last said, nothing is moving — and "72 synced" over a dead socket is the
|
|
175
|
+
// exact shape of lie this module exists to stop.
|
|
176
|
+
//
|
|
177
|
+
// Count first, name second: `project` is hub-supplied, and a leading name is
|
|
178
|
+
// a name that gets to set the tone of our own sentence.
|
|
179
|
+
if (unreachable) {
|
|
180
|
+
return {
|
|
181
|
+
phase: 'offline',
|
|
182
|
+
online: false,
|
|
183
|
+
label: queued > 0 ? `offline · ${queued} ↑` : 'offline',
|
|
184
|
+
title: `Not reachable: ${project}. Your edits are safe on this machine and queued.${queueNote}`,
|
|
185
|
+
next: 'Nothing to do — syncing resumes by itself when the hub is back.',
|
|
186
|
+
names: [],
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (unreadable) {
|
|
191
|
+
// A payload we cannot read is not a payload that says everything is fine.
|
|
192
|
+
return {
|
|
193
|
+
phase: 'connecting',
|
|
194
|
+
online: false,
|
|
195
|
+
label: 'status unreadable',
|
|
196
|
+
title: `Cannot read the sync status for ${project} — the counts on disk are malformed.`,
|
|
197
|
+
next: 'Reconnect the workspace; if it persists, restart Maude.',
|
|
198
|
+
names: [],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!docs) {
|
|
203
|
+
// Pre-DDR-102 payload: `state` is all there is. Report it as the weaker
|
|
204
|
+
// evidence it is, rather than promoting it to a document-level claim.
|
|
205
|
+
const online = status.state === 'online' || status.flash === 'synced';
|
|
206
|
+
return {
|
|
207
|
+
phase: online ? 'synced' : 'connecting',
|
|
208
|
+
online,
|
|
209
|
+
label: online ? (queued > 0 ? `${queued} ↑` : 'synced') : 'connecting…',
|
|
210
|
+
title: online ? `Syncing with ${project}.${queueNote}` : `Connecting to ${project}…`,
|
|
211
|
+
next: online ? null : 'Nothing to do — this usually takes a moment.',
|
|
212
|
+
names: [],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Refusals were already handled above — they outrank an unreachable hub, so
|
|
217
|
+
// that branch has to come first. Everything from here on has `rejected === 0`.
|
|
218
|
+
const total = docs.synced + docs.pending + docs.rejected;
|
|
219
|
+
|
|
220
|
+
if (total === 0) {
|
|
221
|
+
return {
|
|
222
|
+
phase: 'connecting',
|
|
223
|
+
online: false,
|
|
224
|
+
label: 'connecting…',
|
|
225
|
+
title: `Connecting to ${project}…`,
|
|
226
|
+
next: 'Nothing to do — this usually takes a moment.',
|
|
227
|
+
names: [],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (docs.pending > 0) {
|
|
232
|
+
// Zero settled yet is a different fact from some settled: one is a
|
|
233
|
+
// handshake in flight, the other is visible progress.
|
|
234
|
+
const started = docs.synced > 0;
|
|
235
|
+
return {
|
|
236
|
+
phase: started ? 'syncing' : 'connecting',
|
|
237
|
+
online: false,
|
|
238
|
+
label: started ? `${docs.synced}/${total}` : 'connecting…',
|
|
239
|
+
title: started
|
|
240
|
+
? `Syncing with ${project} — ${docs.synced} of ${total} canvas${total === 1 ? '' : 'es'} so far.`
|
|
241
|
+
: `Connecting to ${project}…`,
|
|
242
|
+
next: 'Nothing to do — this usually takes a moment.',
|
|
243
|
+
names: [],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Clamped and validated for the same reason as the doc counts: `count` is
|
|
248
|
+
// read off disk, and "1000000000 came down from the project" is not a
|
|
249
|
+
// sentence this module should be capable of producing. The ceiling is `total`
|
|
250
|
+
// — a pulled document becomes one of the canvases being counted, so more
|
|
251
|
+
// pulled than exist is not a big number, it is a broken payload.
|
|
252
|
+
const rawPulled = status.pulled?.count;
|
|
253
|
+
const pulledCount =
|
|
254
|
+
typeof rawPulled === 'number' && Number.isInteger(rawPulled) && rawPulled >= 0
|
|
255
|
+
? Math.min(rawPulled, total)
|
|
256
|
+
: 0;
|
|
257
|
+
return {
|
|
258
|
+
phase: 'synced',
|
|
259
|
+
online: true,
|
|
260
|
+
label: queued > 0 ? `${queued} ↑` : 'synced',
|
|
261
|
+
title:
|
|
262
|
+
`Synced with ${project} — all ${total} canvas${total === 1 ? '' : 'es'}.` +
|
|
263
|
+
(pulledCount > 0 ? ` ${pulledCount} came down from the project on this connect.` : '') +
|
|
264
|
+
queueNote,
|
|
265
|
+
// Deliberately NOT "open one of the canvases that just arrived". A pulled
|
|
266
|
+
// canvas is hub-authored TSX (DDR-054, and the DDR-079 residual it carries),
|
|
267
|
+
// and an imperative in our own green success sentence is the strongest
|
|
268
|
+
// possible endorsement of content we do not vouch for. State the fact; the
|
|
269
|
+
// decision to open stays the person's.
|
|
270
|
+
next: pulledCount > 0 ? 'They are new to this machine — look them over before editing.' : null,
|
|
271
|
+
names: pulledCount > 0 ? shownNames(status.pulled?.names) : [],
|
|
272
|
+
};
|
|
273
|
+
}
|