@1agh/maude 0.30.0 → 0.31.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 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +781 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +613 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +28780 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5633 -1617
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +384 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +73 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +30 -8
package/apps/studio/paths.ts
CHANGED
|
@@ -82,6 +82,18 @@ function isDevServerDir(dir: string): boolean {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function resolveDevServerRoot(): string {
|
|
85
|
+
// (0) Explicit override. Used when the runtime can't be found by walk-up — e.g.
|
|
86
|
+
// the Tauri desktop bundle (DDR-106) ships apps/studio/ as an app resource and
|
|
87
|
+
// sets MAUDE_DEV_SERVER_ROOT to it, because the sidecar binary sits alone in
|
|
88
|
+
// Maude.app/Contents/MacOS/ with no apps/studio/ up-tree (the bundle ships the
|
|
89
|
+
// full source, so `http.ts` is present at the resource root).
|
|
90
|
+
const override = process.env.MAUDE_DEV_SERVER_ROOT;
|
|
91
|
+
// Require the same `http.ts` anchor walk-up uses (not merely a `dist/` dir) so a
|
|
92
|
+
// planted dist-only directory can't hijack the runtime root (security review F4).
|
|
93
|
+
if (override && !isVirtualBunfsPath(override) && isDevServerDir(override)) {
|
|
94
|
+
return override;
|
|
95
|
+
}
|
|
96
|
+
|
|
85
97
|
// (1) Dev mode: import.meta.url is a real file:// path AND lands in the
|
|
86
98
|
// dev-server dir. Common case for `bun run server.ts`, tests, etc.
|
|
87
99
|
const importDir = getImportMetaDir();
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// scaffold-design.ts — write a minimal, BOOTABLE `.design/` into a folder (Phase 28).
|
|
2
|
+
//
|
|
3
|
+
// Used by two native-app flows: "New project" (create a GitHub repo → init a local
|
|
4
|
+
// project) and the "open a repo with no Maude design system" fallback ("set it up?").
|
|
5
|
+
// It deliberately scaffolds only the MINIMUM the dev-server needs to boot (per
|
|
6
|
+
// context.ts the only hard requirement is a `.design/` dir; config needs just
|
|
7
|
+
// `name` + `designRoot`). A real design system is still created by /design:setup-ds
|
|
8
|
+
// (agent-driven) — this just gets the project to open instead of crash-looping.
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { basename, join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
const CONFIG_SCHEMA =
|
|
14
|
+
'https://raw.githubusercontent.com/1aGh/maude/main/apps/studio/config.schema.json';
|
|
15
|
+
|
|
16
|
+
/** Whether `dir` is already a Maude project (has `.design/config.json`). */
|
|
17
|
+
export function hasDesign(dir: string): boolean {
|
|
18
|
+
return existsSync(join(dir, '.design', 'config.json'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScaffoldResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Scaffold a minimal bootable `.design/` (no design system yet). Refuses to
|
|
27
|
+
* clobber an existing project. `name` is the human project label. */
|
|
28
|
+
export function scaffoldDesign(dir: string, name?: string): ScaffoldResult {
|
|
29
|
+
const designDir = join(dir, '.design');
|
|
30
|
+
const configPath = join(designDir, 'config.json');
|
|
31
|
+
if (existsSync(configPath)) {
|
|
32
|
+
return { ok: false, error: 'This folder is already a Maude project.' };
|
|
33
|
+
}
|
|
34
|
+
const projectName = (name && name.trim()) || basename(dir) || 'Untitled';
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(join(designDir, 'ui'), { recursive: true });
|
|
37
|
+
mkdirSync(join(designDir, 'system'), { recursive: true });
|
|
38
|
+
const config = {
|
|
39
|
+
$schema: CONFIG_SCHEMA,
|
|
40
|
+
name: projectName,
|
|
41
|
+
designRoot: '.design',
|
|
42
|
+
canvasGroups: [
|
|
43
|
+
{ label: 'Design system', path: 'system' },
|
|
44
|
+
{ label: 'UI kit', path: 'ui' },
|
|
45
|
+
],
|
|
46
|
+
designSystems: [],
|
|
47
|
+
completenessProfile: 'standard',
|
|
48
|
+
};
|
|
49
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
50
|
+
// Keep the empty group dirs in git so a fresh clone still has the structure.
|
|
51
|
+
writeFileSync(join(designDir, 'ui', '.gitkeep'), '', 'utf8');
|
|
52
|
+
writeFileSync(join(designDir, 'system', '.gitkeep'), '', 'utf8');
|
|
53
|
+
return { ok: true };
|
|
54
|
+
} catch (e) {
|
|
55
|
+
return { ok: false, error: e instanceof Error ? e.message : 'Could not set up the project.' };
|
|
56
|
+
}
|
|
57
|
+
}
|
package/apps/studio/server.ts
CHANGED
|
@@ -16,14 +16,17 @@
|
|
|
16
16
|
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
18
|
|
|
19
|
+
import { createAcp } from './acp/index.ts';
|
|
19
20
|
import { createActivity } from './activity.ts';
|
|
20
21
|
import { createApi } from './api.ts';
|
|
21
22
|
import { bootSelfHeal } from './boot-self-heal.ts';
|
|
22
|
-
import {
|
|
23
|
+
import { createCanvasListWatch } from './canvas-list-watch.ts';
|
|
24
|
+
import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
|
|
23
25
|
import { createGitLifecycle } from './collab/git-lifecycle.ts';
|
|
24
26
|
import { createCollab } from './collab/index.ts';
|
|
25
27
|
import { createContext } from './context.ts';
|
|
26
28
|
import { createFsWatch } from './fs-watch.ts';
|
|
29
|
+
import { createGitWatch } from './git/watch.ts';
|
|
27
30
|
import { createHttp } from './http.ts';
|
|
28
31
|
import { createInspect } from './inspect.ts';
|
|
29
32
|
import { startHeapWatch } from './mem.ts';
|
|
@@ -77,11 +80,28 @@ await inspect.load();
|
|
|
77
80
|
|
|
78
81
|
collab = createCollab(ctx, api);
|
|
79
82
|
const aiActivity = createAiActivity(ctx);
|
|
83
|
+
|
|
84
|
+
// Phase 30 — bridge agent `ai-activity` onto the per-canvas room awareness so a
|
|
85
|
+
// remote peer sees "X is editing" cross-machine. The `ai-activity` bus event is
|
|
86
|
+
// loopback-only (inspector WS); awareness is the one channel that crosses the
|
|
87
|
+
// hub. Soft heads-up — projected onto the room's own awareness slot, cleared
|
|
88
|
+
// when the activity ends/expires. No-op when no room is live for the slug.
|
|
89
|
+
ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | null }) => {
|
|
90
|
+
if (!collab) return;
|
|
91
|
+
const slug = api.fileSlug(payload.file);
|
|
92
|
+
collab.registry.setAgentEditing(
|
|
93
|
+
slug,
|
|
94
|
+
payload.entry ? { name: payload.entry.author, since: payload.entry.startedAt } : null
|
|
95
|
+
);
|
|
96
|
+
});
|
|
80
97
|
const gitLifecycle = createGitLifecycle(ctx, collab.registry);
|
|
81
98
|
// Phase 13 / DDR-029 — fs-watch-driven canvas activity overlay. Subscribes to
|
|
82
99
|
// `fs:any` and emits `activity:change`; ws.ts forwards it to canvas iframes.
|
|
83
100
|
const activity = createActivity(ctx);
|
|
84
|
-
|
|
101
|
+
// Phase 31 (DDR-123) — ACP chat bridge manager. Owns one claude-agent-acp
|
|
102
|
+
// subprocess per /_ws/acp socket; main-origin + loopback only (wired below).
|
|
103
|
+
const acp = createAcp(ctx);
|
|
104
|
+
const ws = createWs(ctx, api, inspect, collab, activity, acp);
|
|
85
105
|
const http = createHttp(ctx, api, inspect, aiActivity);
|
|
86
106
|
const fsWatch = createFsWatch(ctx);
|
|
87
107
|
|
|
@@ -141,6 +161,26 @@ function startServer(port: number): BunServer {
|
|
|
141
161
|
return new Response('Upgrade failed', { status: 400 });
|
|
142
162
|
}
|
|
143
163
|
|
|
164
|
+
// Phase 31 (DDR-123) — ACP chat bridge WS. Main origin ONLY (this server,
|
|
165
|
+
// never startCanvasServer) and loopback-guarded like collab: the bridge
|
|
166
|
+
// spawns the user's `claude` and can drive file edits, so the untrusted
|
|
167
|
+
// canvas origin and remote hosts must never reach it. Checked BEFORE the
|
|
168
|
+
// generic `/_ws` inspector branch since `/_ws/acp`.startsWith('/_ws').
|
|
169
|
+
if (pathname === '/_ws/acp') {
|
|
170
|
+
if (!isLoopbackHost(req.headers.get('host'))) {
|
|
171
|
+
return new Response('ACP chat is loopback-only', { status: 403 });
|
|
172
|
+
}
|
|
173
|
+
const ok = srv.upgrade(req, {
|
|
174
|
+
data: {
|
|
175
|
+
id: crypto.randomUUID(),
|
|
176
|
+
remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
|
|
177
|
+
kind: 'acp',
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
if (ok) return undefined as unknown as Response;
|
|
181
|
+
return new Response('Upgrade failed', { status: 400 });
|
|
182
|
+
}
|
|
183
|
+
|
|
144
184
|
// Legacy inspector WS — JSON frames, designer-facing live tab state.
|
|
145
185
|
if (pathname.startsWith('/_ws')) {
|
|
146
186
|
const ok = srv.upgrade(req, {
|
|
@@ -339,6 +379,19 @@ await Bun.write(
|
|
|
339
379
|
fsWatch.start();
|
|
340
380
|
startHeapWatch();
|
|
341
381
|
|
|
382
|
+
// Phase 27 Task 5 (E2) — live dirty-state. Subscribes to `fs:any` (after
|
|
383
|
+
// fsWatch.start so it sees every event) and broadcasts `git-status` to the shell
|
|
384
|
+
// on each versionable change. No-op for a non-git project (gitStatus → repo:false).
|
|
385
|
+
const gitWatch = createGitWatch(ctx);
|
|
386
|
+
|
|
387
|
+
// Phase 31 follow-up — external-canvas list watcher. Subscribes to `fs:any` and
|
|
388
|
+
// emits `canvas-list-update` when a canvas file appears/disappears on disk from
|
|
389
|
+
// OUTSIDE the dev-server (ACP agent `/design:new`, agent Write, git checkout),
|
|
390
|
+
// so the browser file tree refreshes without a reload — the symmetric
|
|
391
|
+
// counterpart to api.ts's create/delete emit. RCA:
|
|
392
|
+
// `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
|
|
393
|
+
const canvasListWatch = createCanvasListWatch(ctx);
|
|
394
|
+
|
|
342
395
|
// Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
|
|
343
396
|
// linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
|
|
344
397
|
// off after fsWatch so the agent's bus subscription receives every fs event.
|
|
@@ -371,6 +424,16 @@ if (!process.env.NO_OPEN) {
|
|
|
371
424
|
async function shutdown() {
|
|
372
425
|
console.log('\n Stopping…');
|
|
373
426
|
fsWatch.stop();
|
|
427
|
+
try {
|
|
428
|
+
gitWatch.stop();
|
|
429
|
+
} catch {
|
|
430
|
+
/* timer cleanup is best-effort */
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
canvasListWatch.stop();
|
|
434
|
+
} catch {
|
|
435
|
+
/* timer cleanup is best-effort */
|
|
436
|
+
}
|
|
374
437
|
try {
|
|
375
438
|
activity.stop();
|
|
376
439
|
} catch {
|
|
@@ -50,17 +50,27 @@ import {
|
|
|
50
50
|
commentsFromDoc,
|
|
51
51
|
cssFromDoc,
|
|
52
52
|
htmlFromDoc,
|
|
53
|
+
markSeeded,
|
|
53
54
|
mergeSharedMetaIntoLocal,
|
|
54
55
|
metaFromDoc,
|
|
56
|
+
seededByFromDoc,
|
|
55
57
|
stampBodyEdit,
|
|
56
58
|
Y_SYNC_TYPES,
|
|
57
59
|
} from './codec.ts';
|
|
58
|
-
import { decideColdStart, unionCommentsById } from './cold-start.ts';
|
|
60
|
+
import { decideColdStart, isExactRepeat, unionCommentsById } from './cold-start.ts';
|
|
59
61
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
60
62
|
import type { SyncJournal } from './journal.ts';
|
|
61
63
|
|
|
62
64
|
export const DOC_FLUSH_MS = 800;
|
|
63
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Window after a `seed-local-up` during which this agent will repair a
|
|
68
|
+
* concurrent-seed duplication (F1). Bounded so the repair only ever fires for
|
|
69
|
+
* the cold-start race — a genuine peer edit that arrives minutes later is NEVER
|
|
70
|
+
* collapsed back to our original seed. Generous relative to the hub sync RTT.
|
|
71
|
+
*/
|
|
72
|
+
export const SEED_REPAIR_WINDOW_MS = 10_000;
|
|
73
|
+
|
|
64
74
|
export interface CanvasSyncPaths {
|
|
65
75
|
/** Absolute path to <designRoot>/<canvas>.html. */
|
|
66
76
|
html: string;
|
|
@@ -164,10 +174,21 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
164
174
|
let lastMeta: string | null = null;
|
|
165
175
|
let lastCss: string | null = null;
|
|
166
176
|
|
|
177
|
+
// F1 — the body this agent pushed on `seed-local-up`, plus the deadline past
|
|
178
|
+
// which the de-dup repair stops firing. Set on seed; nulled only when the
|
|
179
|
+
// window lapses (after convergence the repair is a cheap no-op — the
|
|
180
|
+
// `body === seedInfo.body` guard short-circuits, so we don't bother clearing).
|
|
181
|
+
// Null = this agent never seeded (so it never repairs).
|
|
182
|
+
let seedInfo: { body: string; until: number } | null = null;
|
|
183
|
+
|
|
167
184
|
function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
|
|
168
185
|
if (stopped) return;
|
|
169
186
|
// Self-applied (we just synced from disk) — disk is already current.
|
|
170
187
|
if (updateOrigin === origin) return;
|
|
188
|
+
// F1 — a remote update during the seed window may have concatenated a
|
|
189
|
+
// concurrent peer's identical seed onto ours. Collapse it (single elected
|
|
190
|
+
// writer) BEFORE the flush below can mirror a doubled body to disk.
|
|
191
|
+
maybeRepairSeedDuplication();
|
|
171
192
|
scheduleFlush();
|
|
172
193
|
}
|
|
173
194
|
|
|
@@ -188,6 +209,45 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
188
209
|
}, flushMs);
|
|
189
210
|
}
|
|
190
211
|
|
|
212
|
+
/**
|
|
213
|
+
* F1 — collapse a concurrent cold-seed duplication. When two peers
|
|
214
|
+
* `seed-local-up` the SAME body into an empty hub at the same instant, the
|
|
215
|
+
* merged Y.Text holds both insertions (`BODY` × N — un-buildable). This runs
|
|
216
|
+
* on every remote update inside the seed window and, on the single peer the
|
|
217
|
+
* `seededBy` Y.Map LWW elected, re-applies the canonical body so
|
|
218
|
+
* `applyHtmlToDoc`'s diff deletes the trailing duplicate(s). Only the elected
|
|
219
|
+
* owner repairs → the collapse op is emitted once and converges across peers
|
|
220
|
+
* (a non-owner just receives the delete). Restricted to an EXACT repeat of our
|
|
221
|
+
* seed: a divergent edit carries genuine bytes, so we DON'T touch it here —
|
|
222
|
+
* silently collapsing it would discard content. NOTE: that leaves the rare
|
|
223
|
+
* "two DIFFERENT bodies seeded into one empty hub at the same instant" case
|
|
224
|
+
* un-buildable until the NEXT boot, when `decideColdStart` routes it through
|
|
225
|
+
* the conflict path (dual snapshot + newest-wins). This live repair only
|
|
226
|
+
* covers the identical-seed race (the F1 RCA's scenario); the divergent
|
|
227
|
+
* variant is an accepted follow-up, not handled in-flight.
|
|
228
|
+
*/
|
|
229
|
+
function maybeRepairSeedDuplication(): void {
|
|
230
|
+
if (!seedInfo) return;
|
|
231
|
+
if (Date.now() > seedInfo.until) {
|
|
232
|
+
seedInfo = null;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const owner = seededByFromDoc(doc);
|
|
236
|
+
if (owner === null || owner !== doc.clientID) return; // not the elected writer
|
|
237
|
+
const body = htmlFromDoc(doc);
|
|
238
|
+
if (body === seedInfo.body) return; // already a single copy
|
|
239
|
+
if (!isExactRepeat(body, seedInfo.body)) return; // divergent edit → conflict path owns it
|
|
240
|
+
const canonical = seedInfo.body;
|
|
241
|
+
doc.transact(() => {
|
|
242
|
+
if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
|
|
243
|
+
}, origin);
|
|
244
|
+
lastHtml = canonical;
|
|
245
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
|
|
246
|
+
console.warn(
|
|
247
|
+
`[sync/${slug}] concurrent cold-seed duplication detected — collapsed to one copy (F1).`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
191
251
|
async function flush(): Promise<void> {
|
|
192
252
|
if (!dirty || stopped) return;
|
|
193
253
|
dirty = false;
|
|
@@ -348,7 +408,12 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
348
408
|
if (localHtml !== null) {
|
|
349
409
|
doc.transact(() => {
|
|
350
410
|
if (applyHtmlToDoc(doc, localHtml, origin)) stampBodyEdit(doc, origin);
|
|
411
|
+
// F1 — adopt is also a "seed local up" (first link / fresh hub); claim
|
|
412
|
+
// it + arm the repair window so two peers adopting the same draft into
|
|
413
|
+
// one hub at once self-heal the same way the decision-table seed does.
|
|
414
|
+
markSeeded(doc, origin);
|
|
351
415
|
}, origin);
|
|
416
|
+
seedInfo = { body: localHtml, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
352
417
|
}
|
|
353
418
|
if (localComments !== null) {
|
|
354
419
|
const parsed = tryParseJsonArray(localComments);
|
|
@@ -396,8 +461,15 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
396
461
|
const seedBodyUp = (body: string): void => {
|
|
397
462
|
doc.transact(() => {
|
|
398
463
|
if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
|
|
464
|
+
// F1 — claim the seed (clientID marker) in the SAME update so a
|
|
465
|
+
// concurrent second seeder's merge resolves a single elected writer.
|
|
466
|
+
markSeeded(doc, origin);
|
|
399
467
|
}, origin);
|
|
400
468
|
lastHtml = body;
|
|
469
|
+
// Arm the de-dup repair window: if another peer seeded the same body at the
|
|
470
|
+
// same instant, the merged Y.Text will double — maybeRepairSeedDuplication
|
|
471
|
+
// (on the elected owner) collapses it back during this window.
|
|
472
|
+
seedInfo = { body, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
401
473
|
opts.journal?.record(slug, { bodyHash: hashBytes(body) });
|
|
402
474
|
};
|
|
403
475
|
|
|
@@ -421,6 +493,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
421
493
|
seedBodyUp(localHtml as string);
|
|
422
494
|
bodyWinner = 'local';
|
|
423
495
|
break;
|
|
496
|
+
case 'recover-seed-dup':
|
|
497
|
+
// F1 — booting against a hub whose body is our local body repeated (a
|
|
498
|
+
// concurrent cold-seed that already duplicated). Re-apply local so the
|
|
499
|
+
// diff deletes the extra copy/copies; the content equals local, so the
|
|
500
|
+
// visually-coupled lanes follow local too. Idempotent across peers.
|
|
501
|
+
seedBodyUp(localHtml as string);
|
|
502
|
+
bodyWinner = 'local';
|
|
503
|
+
break;
|
|
424
504
|
case 'conflict': {
|
|
425
505
|
// Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
|
|
426
506
|
// write, then apply the newest-wins winner. Even a wrong pick costs
|
|
@@ -338,6 +338,30 @@ export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
|
|
|
338
338
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/**
|
|
342
|
+
* Record THIS doc's clientID as the seeder in `syncMeta.seededBy` (F1). Call it
|
|
343
|
+
* in the SAME transaction + origin as a `seed-local-up` body apply. The value is
|
|
344
|
+
* a Yjs clientID, so when two peers seed the same empty hub simultaneously and
|
|
345
|
+
* their maps merge, Y.Map's deterministic last-writer-wins resolution converges
|
|
346
|
+
* `seededBy` to ONE clientID on EVERY peer — that elected peer is the
|
|
347
|
+
* single-writer that performs the de-duplication repair, so the collapse op is
|
|
348
|
+
* never emitted twice. Informational/bookkeeping only; never materialized to
|
|
349
|
+
* disk (syncMeta is a sync-internal lane).
|
|
350
|
+
*/
|
|
351
|
+
export function markSeeded(doc: Y.Doc, origin?: unknown): void {
|
|
352
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
353
|
+
doc.transact(() => {
|
|
354
|
+
map.set('seededBy', doc.clientID);
|
|
355
|
+
}, origin);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The elected seeder's clientID (`syncMeta.seededBy`), or null when no peer
|
|
359
|
+
* ever seeded through the marker path (older peers / non-seed canvases). */
|
|
360
|
+
export function seededByFromDoc(doc: Y.Doc): number | null {
|
|
361
|
+
const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('seededBy');
|
|
362
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
363
|
+
}
|
|
364
|
+
|
|
341
365
|
/* ---------------------------------------------------------------- css */
|
|
342
366
|
|
|
343
367
|
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|
|
@@ -26,6 +26,7 @@ export type ColdStartAction =
|
|
|
26
26
|
| 'materialize-hub'
|
|
27
27
|
| 'seed-local-up'
|
|
28
28
|
| 'fast-forward-hub'
|
|
29
|
+
| 'recover-seed-dup'
|
|
29
30
|
| 'conflict';
|
|
30
31
|
|
|
31
32
|
export interface ColdStartInput {
|
|
@@ -56,6 +57,24 @@ function isEmptyBody(body: string | null): boolean {
|
|
|
56
57
|
return body === null || body.trim() === '';
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/**
|
|
61
|
+
* True when `docBody` is exactly `localBody` repeated N≥2 times — the signature
|
|
62
|
+
* of a concurrent cold-seed collision (F1): two peers each `seed-local-up`-ed
|
|
63
|
+
* the SAME body into an empty hub before either's write propagated, so the CRDT
|
|
64
|
+
* preserved both insertions and the Y.Text became `BODY` × N. We detect the
|
|
65
|
+
* exact-repeat shape (not a fuzzy "contains") so a legitimate later edit — which
|
|
66
|
+
* is never a clean integer-multiple repeat of the prior body — can't be
|
|
67
|
+
* mis-read as a duplication and clobbered back. Exact equality (N==1) is the
|
|
68
|
+
* caller's `noop`, handled before this is consulted.
|
|
69
|
+
*/
|
|
70
|
+
export function isExactRepeat(docBody: string, localBody: string): boolean {
|
|
71
|
+
if (localBody.length === 0) return false;
|
|
72
|
+
if (docBody.length <= localBody.length) return false;
|
|
73
|
+
if (docBody.length % localBody.length !== 0) return false;
|
|
74
|
+
const n = docBody.length / localBody.length;
|
|
75
|
+
return docBody === localBody.repeat(n);
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
function commentId(c: unknown): string | null {
|
|
60
79
|
if (c && typeof c === 'object' && typeof (c as { id?: unknown }).id === 'string') {
|
|
61
80
|
return (c as { id: string }).id;
|
|
@@ -117,6 +136,27 @@ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
|
|
|
117
136
|
return { action: 'noop', reason: 'local and hub identical' };
|
|
118
137
|
}
|
|
119
138
|
|
|
139
|
+
// Concurrent cold-seed collision (F1): the hub body is our local body repeated
|
|
140
|
+
// N≥2 times — two peers seeded the same canvas into an empty hub at the same
|
|
141
|
+
// moment and the CRDT concatenated both insertions (un-buildable: two `export
|
|
142
|
+
// default`). This is NOT divergence (it carries no new bytes — just a doubled
|
|
143
|
+
// copy of ours), so it must NOT take the conflict/fast-forward path. Collapse
|
|
144
|
+
// it back to one copy by re-applying local (the caller's `applyHtmlToDoc` diff
|
|
145
|
+
// deletes the trailing duplicate). Idempotent across peers — the delete targets
|
|
146
|
+
// the same CRDT items, so concurrent recoveries converge instead of fighting.
|
|
147
|
+
if (
|
|
148
|
+
input.localBody !== null &&
|
|
149
|
+
!localEmpty &&
|
|
150
|
+
!docEmpty &&
|
|
151
|
+
isExactRepeat(input.docBody, input.localBody)
|
|
152
|
+
) {
|
|
153
|
+
return {
|
|
154
|
+
action: 'recover-seed-dup',
|
|
155
|
+
reason:
|
|
156
|
+
'hub body is local repeated — concurrent cold-seed duplication; collapsing to one copy',
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
120
160
|
// Hash via hashBytes ONLY — the journal recorded its hashes through the same
|
|
121
161
|
// fn (single source, echo-guard.ts), so this comparison is apples-to-apples.
|
|
122
162
|
if (
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// sync/hub-link.ts — Phase 29 (E4) Door C: connect to a team hub from the wizard.
|
|
2
|
+
//
|
|
3
|
+
// The CLI (`maude design link`) owns the FULL link flow (interactive trust gate +
|
|
4
|
+
// per-project `.design/config.json` linkedHub write + `--adopt` push). This is the
|
|
5
|
+
// lean in-app counterpart used by the onboarding wizard's advanced door: it saves the
|
|
6
|
+
// hub CREDENTIAL to the global `~/.config/maude/hubs.json` (mode 0600) and records the
|
|
7
|
+
// hub as trusted on THIS machine — the in-UI "Connect" IS the explicit trust grant the
|
|
8
|
+
// CLI's interactive confirmation provides (DDR-054 F2). Hub tokens are GLOBAL /
|
|
9
|
+
// per-machine (keyed by normalized URL), so a project later opened whose
|
|
10
|
+
// `.design/config.json` names this hub syncs using the saved token. We deliberately do
|
|
11
|
+
// NOT write a per-project `linkedHub` here (onboarding has no project yet) — that stays
|
|
12
|
+
// a CLI / post-onboarding operation.
|
|
13
|
+
//
|
|
14
|
+
// SECURITY: the http layer gates this main-origin + loopback only (mirrors
|
|
15
|
+
// /_api/github/*). The health probe is a best-effort, TOKENLESS GET to the user-entered
|
|
16
|
+
// hub URL; only `{ ok, version }` is reflected back — no response-body passthrough, so it
|
|
17
|
+
// can't become an SSRF data-exfil channel. The probe deliberately does NOT carry the hub
|
|
18
|
+
// token: a user who pastes a lookalike URL must not have their credential delivered to the
|
|
19
|
+
// attacker's host (phase-29 /flow:done attacker finding A2). The token is only persisted
|
|
20
|
+
// locally (mode 0600) and presented later on the authenticated sync WS upgrade. The hub
|
|
21
|
+
// address is whatever the user typed (a LAN / Tailscale / fly.dev URL is legitimate —
|
|
22
|
+
// phase-9's hub model expects private hosts), so we do NOT block private IPs; the safety
|
|
23
|
+
// is the loopback caller gate + no reflection + no token on the probe.
|
|
24
|
+
|
|
25
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { dirname } from 'node:path';
|
|
27
|
+
|
|
28
|
+
import { hubsConfigPath, normalizeUrl } from './hubs-config.ts';
|
|
29
|
+
|
|
30
|
+
export interface HubLinkResult {
|
|
31
|
+
status: number;
|
|
32
|
+
json: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HUB_PROBE_TIMEOUT_MS = 4000;
|
|
36
|
+
|
|
37
|
+
interface HubsFile {
|
|
38
|
+
hubs: Record<string, { token: string; linkedAt: number }>;
|
|
39
|
+
trusted?: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Validate + probe + persist a hub credential. Returns an http-shaped result. */
|
|
43
|
+
export async function linkHub(body: unknown): Promise<HubLinkResult> {
|
|
44
|
+
const b = (body ?? {}) as { url?: unknown; token?: unknown };
|
|
45
|
+
if (typeof b.url !== 'string' || !b.url.trim()) return bad('Enter the hub address.');
|
|
46
|
+
if (typeof b.token !== 'string' || !b.token.trim())
|
|
47
|
+
return bad('Paste the invite link or token your team gave you.');
|
|
48
|
+
|
|
49
|
+
let norm: string;
|
|
50
|
+
try {
|
|
51
|
+
norm = normalizeUrl(b.url.trim());
|
|
52
|
+
} catch {
|
|
53
|
+
return bad("That doesn't look like a valid hub address.");
|
|
54
|
+
}
|
|
55
|
+
let parsed: URL;
|
|
56
|
+
try {
|
|
57
|
+
parsed = new URL(norm);
|
|
58
|
+
} catch {
|
|
59
|
+
return bad('Invalid hub address.');
|
|
60
|
+
}
|
|
61
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
62
|
+
return bad('The hub address must start with http:// or https://.');
|
|
63
|
+
|
|
64
|
+
const token = b.token.trim();
|
|
65
|
+
|
|
66
|
+
// Best-effort reachability probe — TOKENLESS (never deliver the credential to a
|
|
67
|
+
// possibly-lookalike host) and does NOT gate the save (a firewalled hub the user
|
|
68
|
+
// trusts still links; the real auth happens on the sync WS upgrade).
|
|
69
|
+
const probe = await probeHealth(norm);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
saveHubCredential(norm, token);
|
|
73
|
+
} catch {
|
|
74
|
+
return { status: 500, json: { ok: false, error: "Couldn't save the hub connection." } };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
status: 200,
|
|
79
|
+
json: { ok: true, url: norm, healthy: probe.ok, version: probe.version ?? null },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bad(error: string): HubLinkResult {
|
|
84
|
+
return { status: 400, json: { ok: false, error } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function probeHealth(url: string): Promise<{ ok: boolean; version?: string }> {
|
|
88
|
+
const ctrl = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => ctrl.abort(), HUB_PROBE_TIMEOUT_MS);
|
|
90
|
+
try {
|
|
91
|
+
// Tokenless on purpose (attacker finding A2): /health is a liveness check; the
|
|
92
|
+
// credential is never sent to the user-typed host, only to the trusted hub on the
|
|
93
|
+
// authenticated sync WS upgrade later.
|
|
94
|
+
const res = await fetch(`${url}/health`, { signal: ctrl.signal });
|
|
95
|
+
if (!res.ok) return { ok: false };
|
|
96
|
+
let version: string | undefined;
|
|
97
|
+
try {
|
|
98
|
+
const j = (await res.json()) as { version?: unknown };
|
|
99
|
+
if (j && typeof j.version === 'string') version = j.version;
|
|
100
|
+
} catch {
|
|
101
|
+
/* health may not be JSON — reachability alone is enough */
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, version };
|
|
104
|
+
} catch {
|
|
105
|
+
return { ok: false };
|
|
106
|
+
} finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Upsert the token under `normUrl` + record per-machine trust; mode 0600. */
|
|
112
|
+
export function saveHubCredential(normUrl: string, token: string): void {
|
|
113
|
+
const path = hubsConfigPath();
|
|
114
|
+
const dir = dirname(path);
|
|
115
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
116
|
+
let cfg: HubsFile = { hubs: {} };
|
|
117
|
+
if (existsSync(path)) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
120
|
+
if (parsed && typeof parsed.hubs === 'object' && parsed.hubs !== null)
|
|
121
|
+
cfg = parsed as HubsFile;
|
|
122
|
+
} catch {
|
|
123
|
+
/* malformed → start fresh rather than throw */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
cfg.hubs[normUrl] = { token, linkedAt: Date.now() };
|
|
127
|
+
if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
|
|
128
|
+
if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
|
|
129
|
+
writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
130
|
+
try {
|
|
131
|
+
chmodSync(path, 0o600);
|
|
132
|
+
} catch {
|
|
133
|
+
/* windows / read-only fs — best effort */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const __testing = { probeHealth };
|