@1agh/maude 0.55.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/bin/_smart-frames.mjs +187 -20
- package/apps/studio/bin/_smart-frames.test.mjs +59 -4
- package/apps/studio/bin/_transcribe.mjs +40 -3
- package/apps/studio/bin/smoke.sh +7 -1
- package/apps/studio/build.ts +28 -1
- package/apps/studio/client/app.jsx +78 -33
- package/apps/studio/client/panels/ChatPanel.jsx +19 -1
- package/apps/studio/client/panels/CloudBar.jsx +104 -6
- package/apps/studio/client/panels/GitPanel.jsx +38 -25
- package/apps/studio/client/panels/PermissionPrompt.jsx +146 -6
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +145 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +239 -27
- 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/4-components.css +72 -0
- package/apps/studio/client/styles/6-acp-chat.css +51 -0
- package/apps/studio/cloud/endpoints.ts +56 -3
- package/apps/studio/collab/persistence.ts +29 -2
- package/apps/studio/config.schema.json +3 -3
- package/apps/studio/context.ts +41 -0
- package/apps/studio/dist/client.bundle.js +1330 -1330
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/generation/gemma-models.ts +312 -14
- package/apps/studio/generation/prefs.ts +7 -2
- package/apps/studio/generation/runtime-probe.ts +50 -0
- package/apps/studio/generation/whisper-models.ts +124 -0
- package/apps/studio/hmr-broadcast.ts +67 -0
- package/apps/studio/http.ts +247 -111
- package/apps/studio/input-router.tsx +55 -2
- package/apps/studio/server.ts +22 -9
- package/apps/studio/sync/autocommit.ts +61 -2
- package/apps/studio/sync/cell-pairing.ts +174 -0
- package/apps/studio/sync/codec.ts +11 -5
- package/apps/studio/sync/connection-state.ts +116 -6
- package/apps/studio/sync/index.ts +288 -26
- package/apps/studio/sync/limits.ts +49 -0
- package/apps/studio/sync/loopback.ts +21 -0
- package/apps/studio/sync/presentation.ts +273 -0
- package/apps/studio/sync/projection.ts +47 -12
- package/apps/studio/sync/remote-docs.ts +191 -0
- package/apps/studio/sync/status.ts +4 -1
- package/apps/studio/sync/supervisor.ts +178 -0
- 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 +102 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +5 -2
- package/apps/studio/test/csrf-write-guard.test.ts +19 -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/gemma-models.test.ts +245 -0
- package/apps/studio/test/hmr-broadcast.test.ts +57 -1
- package/apps/studio/test/input-router.test.ts +95 -0
- package/apps/studio/test/shared-doc-cell-pairing.test.ts +639 -0
- package/apps/studio/test/sync-autocommit.test.ts +47 -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/sync-supervisor.test.ts +212 -0
- package/apps/studio/test/trusted-request-host.test.ts +66 -0
- package/apps/studio/test/whisper-setup.test.ts +97 -0
- package/apps/studio/whats-new.json +45 -0
- package/apps/studio/ws.ts +9 -1
- package/cli/commands/kg.mjs +9 -2
- package/package.json +8 -8
- package/plugins/design/dependencies.json +21 -3
|
@@ -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
|
+
}
|
|
@@ -37,15 +37,13 @@ import {
|
|
|
37
37
|
applyMetaToDoc,
|
|
38
38
|
cssFromDoc,
|
|
39
39
|
htmlFromDoc,
|
|
40
|
-
MAX_CSS_BYTES,
|
|
41
|
-
MAX_HTML_BYTES,
|
|
42
|
-
MAX_META_BYTES,
|
|
43
40
|
mergeSharedMetaIntoLocal,
|
|
44
41
|
metaFromDoc,
|
|
45
42
|
stampBodyEdit,
|
|
46
43
|
} from './codec.ts';
|
|
47
44
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
48
45
|
import type { SyncJournal } from './journal.ts';
|
|
46
|
+
import { MAX_CSS_BYTES, MAX_HTML_BYTES, MAX_META_BYTES, withinByteCap } from './limits.ts';
|
|
49
47
|
import { ORIGINS } from './origins.ts';
|
|
50
48
|
|
|
51
49
|
export const PROJECT_FLUSH_MS = 800;
|
|
@@ -76,6 +74,20 @@ export interface DocProjectionOptions {
|
|
|
76
74
|
echoGuard?: EchoGuard;
|
|
77
75
|
/** Injected for tests — defaults to atomicWrite. */
|
|
78
76
|
writer?: (path: string, bytes: string | Uint8Array) => void;
|
|
77
|
+
/**
|
|
78
|
+
* Called with the absolute path after a doc→file write LANDS.
|
|
79
|
+
*
|
|
80
|
+
* Exists for one environment: a cell, where the container's recursive
|
|
81
|
+
* `fs.watch` does not see our atomic tmp+rename writes. Locally the watcher
|
|
82
|
+
* fires and the runtime leaves this unset — the same "the watcher owes us this
|
|
83
|
+
* event" reasoning (and the same workspace-mode gate) as
|
|
84
|
+
* `createContainerWriteBridge`, which covers the API write path and cannot
|
|
85
|
+
* cover this one because the projector never arms `activity:suppress`.
|
|
86
|
+
*
|
|
87
|
+
* Without it a peer's edit reaches the doc, reaches disk, and the browser's
|
|
88
|
+
* canvas iframe still shows the old render until somebody reloads by hand.
|
|
89
|
+
*/
|
|
90
|
+
onWrote?: (absPath: string) => void;
|
|
79
91
|
/** Override the 800 ms debounce. Tests use 0 to flush on the next microtask. */
|
|
80
92
|
flushMs?: number;
|
|
81
93
|
/** Circuit-breaker threshold (consecutive parse failures per path). */
|
|
@@ -153,18 +165,41 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
153
165
|
opts.echoGuard?.record(path, hashBytes(value));
|
|
154
166
|
}
|
|
155
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Write + announce. Every doc→file write goes through here rather than calling
|
|
170
|
+
* `writer` directly, so a future fourth projected type cannot silently skip
|
|
171
|
+
* the announcement — the bug class this whole hook exists to close.
|
|
172
|
+
*
|
|
173
|
+
* NO-OPS WHEN THE FILE ALREADY HOLDS THESE BYTES. The `last*` guards above
|
|
174
|
+
* compare against what THIS projector wrote, which is null on the first pass —
|
|
175
|
+
* so the cold-start `reconcile()` re-wrote every canvas with content identical
|
|
176
|
+
* to what was already there. Harmless while nobody was listening; once the
|
|
177
|
+
* write announces itself (below) it became a spurious reload of every open
|
|
178
|
+
* canvas at boot. A write that changes nothing should cost nothing, including
|
|
179
|
+
* downstream.
|
|
180
|
+
*
|
|
181
|
+
* `onWrote` is best-effort: it feeds a reload, and a reload that failed to
|
|
182
|
+
* fire must never cost the write that already succeeded.
|
|
183
|
+
*/
|
|
184
|
+
function writeAndAnnounce(path: string, value: string): void {
|
|
185
|
+
if (readLocal(path) === value) return;
|
|
186
|
+
writer(path, value);
|
|
187
|
+
try {
|
|
188
|
+
opts.onWrote?.(path);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(`[projection/${slug}] onWrote(${path}) failed:`, err);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
156
194
|
// Security re-audit (Phase D, finding A2 / DDR-054 §2d): the codec's
|
|
157
195
|
// MAX_*_BYTES caps guard the file→doc *import* lane, but hub-pushed content
|
|
158
196
|
// arrives as raw Yjs updates through the provider (NOT via applyXToDoc), so it
|
|
159
197
|
// bypasses those caps. This doc→file lane is the consumer's guard on that
|
|
160
198
|
// direction — refuse to materialize an oversized hub-pushed body to disk
|
|
161
|
-
// (disk-fill DoS). Returns true when the write is allowed.
|
|
199
|
+
// (disk-fill DoS). Returns true when the write is allowed. Shared with
|
|
200
|
+
// `collab/persistence.ts`'s equivalent guard via `limits.ts`.
|
|
162
201
|
function withinCap(path: string, value: string, max: number): boolean {
|
|
163
|
-
|
|
164
|
-
console.warn(
|
|
165
|
-
`[projection/${slug}] refusing doc→file write of ${path} > ${max} bytes (hub-pushed oversize). DDR-054 §2d.`
|
|
166
|
-
);
|
|
167
|
-
return false;
|
|
202
|
+
return withinByteCap(`projection/${slug}`, path, Buffer.byteLength(value, 'utf8'), max);
|
|
168
203
|
}
|
|
169
204
|
|
|
170
205
|
// ----- doc → file (html / css / meta only; room owns comments/annotations)
|
|
@@ -180,7 +215,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
180
215
|
}
|
|
181
216
|
if (!withinCap(paths.html, next, MAX_HTML_BYTES)) return;
|
|
182
217
|
recordEcho(paths.html, next);
|
|
183
|
-
|
|
218
|
+
writeAndAnnounce(paths.html, next);
|
|
184
219
|
lastHtml = next;
|
|
185
220
|
opts.journal?.record(slug, { bodyHash: hashBytes(next) }); // DDR-102 checkpoint
|
|
186
221
|
}
|
|
@@ -193,7 +228,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
193
228
|
if (next === null) return; // doc carries no css yet — nothing to write
|
|
194
229
|
if (!withinCap(paths.css, next, MAX_CSS_BYTES)) return;
|
|
195
230
|
recordEcho(paths.css, next);
|
|
196
|
-
|
|
231
|
+
writeAndAnnounce(paths.css, next);
|
|
197
232
|
opts.journal?.record(slug, { cssHash: hashBytes(next) }); // DDR-102 checkpoint
|
|
198
233
|
}
|
|
199
234
|
|
|
@@ -208,7 +243,7 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
|
|
|
208
243
|
if (merged === null || merged === local) return; // unparseable / disk matches
|
|
209
244
|
if (!withinCap(paths.meta, merged, MAX_META_BYTES)) return;
|
|
210
245
|
recordEcho(paths.meta, merged);
|
|
211
|
-
|
|
246
|
+
writeAndAnnounce(paths.meta, merged);
|
|
212
247
|
}
|
|
213
248
|
|
|
214
249
|
async function flush(): Promise<void> {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// What the PROJECT holds, versus what this machine happens to carry.
|
|
2
|
+
//
|
|
3
|
+
// The sync runtime opens one provider per canvas found on LOCAL disk, and Yjs
|
|
4
|
+
// has no enumeration — so a document that exists only on the hub is invisible
|
|
5
|
+
// to a peer by construction. A desktop with 72 of a project's 75 canvases syncs
|
|
6
|
+
// 72, reports "72/72 synced", and is accurate about the wrong universe. The
|
|
7
|
+
// user-visible form of that was "I pressed Open in Maude and nothing happened":
|
|
8
|
+
// three real canvases (welcome, how-to-use, how-to-make-video) lived only in
|
|
9
|
+
// the cloud and could never arrive.
|
|
10
|
+
//
|
|
11
|
+
// This module asks the hub what it has (`GET /api/documents`, scope-bound to
|
|
12
|
+
// the same token the sync uses) and diffs it against the local set, so the
|
|
13
|
+
// runtime can say which side is missing what.
|
|
14
|
+
//
|
|
15
|
+
// SYNC IS BIDIRECTIONAL AND COMPLETE. A project you have been granted access to
|
|
16
|
+
// is a project you get — all of it, both directions. Local canvases go up (they
|
|
17
|
+
// always did); hub-only documents now come DOWN, materialised as real files.
|
|
18
|
+
//
|
|
19
|
+
// What that trades, stated plainly: a hub can create files in your design root,
|
|
20
|
+
// where before it could only update canvases you already had (DDR-054 treats
|
|
21
|
+
// hub-pushed content as untrusted). That is accepted deliberately — the hub is
|
|
22
|
+
// the project, the caller is authenticated, and a partial project is not a
|
|
23
|
+
// project. The one guard kept is containment: a document NAME can never place a
|
|
24
|
+
// file outside the design root. That is not policy filtering, it is the
|
|
25
|
+
// difference between writing your project and writing your filesystem.
|
|
26
|
+
|
|
27
|
+
/** One document as the hub reports it. */
|
|
28
|
+
export interface RemoteDoc {
|
|
29
|
+
name: string;
|
|
30
|
+
bytes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RemoteDocDiff {
|
|
34
|
+
/** Documents on both sides — the ones actually syncing. */
|
|
35
|
+
shared: string[];
|
|
36
|
+
/** On the hub, absent here. The project has them; this machine cannot get them. */
|
|
37
|
+
hubOnly: RemoteDoc[];
|
|
38
|
+
/** Here, not on the hub yet — normal for a canvas this peer just created. */
|
|
39
|
+
localOnly: string[];
|
|
40
|
+
/** Null when the hub could not be asked (old hub, offline, refused). */
|
|
41
|
+
reachable: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** How long to wait for the listing. Never blocks a sync — see `fetchRemoteDocs`. */
|
|
45
|
+
const LIST_TIMEOUT_MS = 6000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ask the hub which documents this token may open.
|
|
49
|
+
*
|
|
50
|
+
* Returns null on ANY failure — an old hub without the route, a refused token,
|
|
51
|
+
* a network blip. A peer that cannot get the listing must still sync: this is a
|
|
52
|
+
* reporting improvement, and making it load-bearing would trade a real feature
|
|
53
|
+
* for a nicer message.
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchRemoteDocs(
|
|
56
|
+
hubUrl: string,
|
|
57
|
+
token: string,
|
|
58
|
+
fetchImpl: typeof fetch = fetch
|
|
59
|
+
): Promise<RemoteDoc[] | null> {
|
|
60
|
+
try {
|
|
61
|
+
const base = hubUrl.replace(/\/+$/, '');
|
|
62
|
+
const res = await fetchImpl(`${base}/api/documents`, {
|
|
63
|
+
headers: { authorization: `Bearer ${token}` },
|
|
64
|
+
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
|
|
65
|
+
});
|
|
66
|
+
if (!res.ok) return null;
|
|
67
|
+
const body = (await res.json()) as { documents?: unknown };
|
|
68
|
+
if (!Array.isArray(body?.documents)) return null;
|
|
69
|
+
return body.documents
|
|
70
|
+
.filter(
|
|
71
|
+
(d): d is RemoteDoc =>
|
|
72
|
+
!!d && typeof (d as RemoteDoc).name === 'string' && (d as RemoteDoc).name.length > 0
|
|
73
|
+
)
|
|
74
|
+
.map((d) => ({ name: d.name, bytes: Number(d.bytes) || 0 }));
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Diff the hub's documents against the ones this peer syncs.
|
|
82
|
+
*
|
|
83
|
+
* `localDocNames` must already be in WIRE form (`docNameFor(slug)`), not raw
|
|
84
|
+
* slugs — comparing a namespaced hub against flat local names would report
|
|
85
|
+
* every document as missing on both sides, which is the exact failure the
|
|
86
|
+
* namespace exists to prevent, arrived at from the reporting side.
|
|
87
|
+
*/
|
|
88
|
+
export function diffRemoteDocs(
|
|
89
|
+
localDocNames: readonly string[],
|
|
90
|
+
remote: RemoteDoc[] | null
|
|
91
|
+
): RemoteDocDiff {
|
|
92
|
+
if (remote === null) {
|
|
93
|
+
return { shared: [], hubOnly: [], localOnly: [...localDocNames], reachable: false };
|
|
94
|
+
}
|
|
95
|
+
const local = new Set(localDocNames);
|
|
96
|
+
const remoteNames = new Set(remote.map((d) => d.name));
|
|
97
|
+
return {
|
|
98
|
+
shared: [...local].filter((n) => remoteNames.has(n)).sort(),
|
|
99
|
+
hubOnly: remote.filter((d) => !local.has(d.name)).sort((a, b) => a.name.localeCompare(b.name)),
|
|
100
|
+
localOnly: [...local].filter((n) => !remoteNames.has(n)).sort(),
|
|
101
|
+
reachable: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One line a person can act on.
|
|
107
|
+
*
|
|
108
|
+
* Deliberately names the documents being PULLED rather than only what already
|
|
109
|
+
* matched: "72 synced" was true and useless, because the number is drawn from
|
|
110
|
+
* the local file set and can never express what the project holds beyond it.
|
|
111
|
+
*/
|
|
112
|
+
export function describeRemoteDiff(diff: RemoteDocDiff): string | null {
|
|
113
|
+
if (!diff.reachable) return null;
|
|
114
|
+
if (diff.hubOnly.length === 0) return null;
|
|
115
|
+
const n = diff.hubOnly.length;
|
|
116
|
+
const names = diff.hubOnly
|
|
117
|
+
.slice(0, 3)
|
|
118
|
+
.map((d) => d.name)
|
|
119
|
+
.join(', ');
|
|
120
|
+
return `pulling ${n} canvas${n === 1 ? '' : 'es'} down from the project (${names}${n > 3 ? ', …' : ''}).`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The local slug a hub document name maps to.
|
|
125
|
+
*
|
|
126
|
+
* Namespaced names (`ws/<workspace>/<branch>/<slug>` — DDR-192 §5) carry the
|
|
127
|
+
* slug in the last segment; a legacy flat name IS the slug. Returns null for
|
|
128
|
+
* anything that does not survive the charset the hub itself enforces on
|
|
129
|
+
* `documentName`, so a crafted name cannot become a path component.
|
|
130
|
+
*/
|
|
131
|
+
export function slugFromDocName(docName: string): string | null {
|
|
132
|
+
const raw = String(docName ?? '');
|
|
133
|
+
// Validate the WHOLE name against the two shapes a hub may legitimately use,
|
|
134
|
+
// never just its last segment. Taking the tail of an arbitrary string would
|
|
135
|
+
// accept `../../etc/passwd` as `passwd`: contained by the check below, and
|
|
136
|
+
// still a file this project never asked for, created from a name that should
|
|
137
|
+
// have been refused outright. A component is `[A-Za-z0-9_-]` — no dots (so no
|
|
138
|
+
// traversal and no extension smuggling), no spaces.
|
|
139
|
+
const COMPONENT = '[A-Za-z0-9_-]{1,120}';
|
|
140
|
+
const FLAT = new RegExp(`^${COMPONENT}$`);
|
|
141
|
+
const NAMESPACED = new RegExp(`^ws/${COMPONENT}/${COMPONENT}/(${COMPONENT})$`);
|
|
142
|
+
if (FLAT.test(raw)) return raw.toLowerCase();
|
|
143
|
+
const ns = NAMESPACED.exec(raw);
|
|
144
|
+
return ns ? ns[1].toLowerCase() : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface PullTarget {
|
|
148
|
+
slug: string;
|
|
149
|
+
docName: string;
|
|
150
|
+
/** Absolute path the body will be written to, flat under the design root. */
|
|
151
|
+
bodyAbs: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Where a hub-only document lands on disk.
|
|
156
|
+
*
|
|
157
|
+
* FLAT, directly under the design root — the same convention the hub applies in
|
|
158
|
+
* the mirror direction (`workspace-files.mjs defaultBodyPath`) and for the same
|
|
159
|
+
* reason: a slug is lossy, `ui-card` cannot be un-flattened into `ui/Card.tsx`
|
|
160
|
+
* without guessing, and guessing wrong scatters files into directories the user
|
|
161
|
+
* never made. A flat file is trivially moved; an invented tree is not. Moving it
|
|
162
|
+
* later does not break sync — both paths slug to the same document.
|
|
163
|
+
*
|
|
164
|
+
* Returns only targets that resolve INSIDE the design root. A document name is
|
|
165
|
+
* hub-controlled input, and this is the last point before a create.
|
|
166
|
+
*/
|
|
167
|
+
export function pullTargets(
|
|
168
|
+
hubOnly: readonly RemoteDoc[],
|
|
169
|
+
designRoot: string,
|
|
170
|
+
join: (...parts: string[]) => string,
|
|
171
|
+
resolve: (p: string) => string,
|
|
172
|
+
sep: string
|
|
173
|
+
): PullTarget[] {
|
|
174
|
+
const rootResolved = resolve(designRoot);
|
|
175
|
+
const out: PullTarget[] = [];
|
|
176
|
+
// One slug, one target. `slugFromDocName` lowercases, so a hub advertising
|
|
177
|
+
// `Foo`, `foo` and `ws/w/main/foo` yields three targets for ONE file — three
|
|
178
|
+
// providers on the same path, and a `pulled` count that overstates what
|
|
179
|
+
// arrived by a factor the hub chooses.
|
|
180
|
+
const seen = new Set<string>();
|
|
181
|
+
for (const doc of hubOnly) {
|
|
182
|
+
const slug = slugFromDocName(doc.name);
|
|
183
|
+
if (!slug || seen.has(slug)) continue;
|
|
184
|
+
seen.add(slug);
|
|
185
|
+
const bodyAbs = join(designRoot, `${slug}.tsx`);
|
|
186
|
+
const target = resolve(bodyAbs);
|
|
187
|
+
if (target !== rootResolved && !target.startsWith(rootResolved + sep)) continue;
|
|
188
|
+
out.push({ slug, docName: doc.name, bodyAbs });
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
@@ -79,7 +79,10 @@ export function createSyncStatusStore(opts: SyncStatusStoreOptions): SyncStatusS
|
|
|
79
79
|
const conflicts: SyncConflict[] = [];
|
|
80
80
|
|
|
81
81
|
let snapshot: SyncStatusSnapshot = {
|
|
82
|
-
state:
|
|
82
|
+
// Same reason the monitor is seeded `connecting` (see connection-state.ts):
|
|
83
|
+
// this is the payload a reader gets BEFORE the first monitor snapshot lands,
|
|
84
|
+
// so seeding it `online` re-introduced the born-connected lie one layer out.
|
|
85
|
+
state: 'connecting',
|
|
83
86
|
queuedOps: 0,
|
|
84
87
|
lastSyncAt: null,
|
|
85
88
|
offlineSince: null,
|