@1agh/maude 1.2.0 → 1.3.1
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 +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +732 -38
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +468 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +783 -245
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +36 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1594 -1562
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +300 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +172 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1174 -21
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +315 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +83 -1
- package/apps/studio/sync/transaction-client.ts +486 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +137 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +95 -7
- package/plugins/flow/dependencies.json +3 -3
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// AI and multi-file action boundaries — plan T16 (DDR-241).
|
|
2
|
+
//
|
|
3
|
+
// An agent turn (the in-app chat) or a `/design:edit` run changes several
|
|
4
|
+
// files, one tool call at a time. Proposed as they land, the project would
|
|
5
|
+
// accept a canvas edited against a module the agent has not written yet, and
|
|
6
|
+
// an agent that fails half way would leave that half published. So while an
|
|
7
|
+
// AI action is OPEN, the file changes it makes are STAGED here instead of
|
|
8
|
+
// proposed: kept on disk, remembered with the value each was derived from,
|
|
9
|
+
// and proposed together — one transaction, one history action — when the
|
|
10
|
+
// action ends well.
|
|
11
|
+
//
|
|
12
|
+
// When it does not (the agent errors, is cancelled, stops at its token limit,
|
|
13
|
+
// or its heartbeat goes silent) the stage is HELD: nothing is published and
|
|
14
|
+
// nothing is thrown away. The person decides — publish it as it stands, or
|
|
15
|
+
// discard it (the accepted version returns; the candidate stays in the
|
|
16
|
+
// recovery slots). A held stage survives a restart (`_state/ai-stage.json`),
|
|
17
|
+
// so a crash can never turn into a partial publish by the next cold start.
|
|
18
|
+
//
|
|
19
|
+
// What is staged is only a WATCHER change (a tool writing a file). An edit the
|
|
20
|
+
// person makes in the UI is theirs and is proposed at once — unless it is made
|
|
21
|
+
// on top of a staged file, in which case it carries the agent's unpublished
|
|
22
|
+
// bytes and waits behind the stage (`dependents`), exactly as U2 waits on U1.
|
|
23
|
+
|
|
24
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
import type { LaneProposal, ProposalOutcome } from './projection.ts';
|
|
28
|
+
import type { Operation, ProposalResult } from './transaction-client.ts';
|
|
29
|
+
|
|
30
|
+
export type StageState = 'open' | 'held';
|
|
31
|
+
|
|
32
|
+
export interface StagedLane {
|
|
33
|
+
slug: string;
|
|
34
|
+
doc: string;
|
|
35
|
+
lane: LaneProposal['lane'];
|
|
36
|
+
/** The value the FIRST staged change was derived from — the group's base. */
|
|
37
|
+
baseContent: string;
|
|
38
|
+
/** The latest staged value. */
|
|
39
|
+
content: string;
|
|
40
|
+
writeId?: string;
|
|
41
|
+
/** Every staged proposal's transaction id — aliased to the group's on commit. */
|
|
42
|
+
txs: string[];
|
|
43
|
+
resolvers: Array<(o: ProposalOutcome) => void>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StageSummary {
|
|
47
|
+
state: StageState;
|
|
48
|
+
label: string;
|
|
49
|
+
canvases: string[];
|
|
50
|
+
since: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface Dependent {
|
|
54
|
+
send: (dependsOn: string[] | undefined) => void;
|
|
55
|
+
drop: (o: ProposalOutcome) => void;
|
|
56
|
+
dependsOn: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ActionStageOptions {
|
|
60
|
+
designRoot: string;
|
|
61
|
+
/** Send one grouped action; resolves with the final result. */
|
|
62
|
+
propose: (action: {
|
|
63
|
+
label: string;
|
|
64
|
+
kind: string;
|
|
65
|
+
operations: Operation[];
|
|
66
|
+
transactionId: string;
|
|
67
|
+
}) => Promise<ProposalResult>;
|
|
68
|
+
newTransactionId: () => string;
|
|
69
|
+
onChange?: (summary: StageSummary | null) => void;
|
|
70
|
+
log?: Pick<Console, 'log' | 'warn'>;
|
|
71
|
+
now?: () => number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createActionStage(opts: ActionStageOptions) {
|
|
75
|
+
const log = opts.log ?? console;
|
|
76
|
+
const now = opts.now ?? Date.now;
|
|
77
|
+
const file = path.join(opts.designRoot, '_state', 'ai-stage.json');
|
|
78
|
+
|
|
79
|
+
let state: StageState | null = null;
|
|
80
|
+
let label = '';
|
|
81
|
+
let since = 0;
|
|
82
|
+
const keys = new Set<string>();
|
|
83
|
+
let failed = false;
|
|
84
|
+
const lanes = new Map<string, StagedLane>();
|
|
85
|
+
const dependents: Dependent[] = [];
|
|
86
|
+
/** A staged transaction id → the group transaction that carried it. */
|
|
87
|
+
const alias = new Map<string, string>();
|
|
88
|
+
/** Slugs a restored (held) stage covers — cold-start changes to them are staged. */
|
|
89
|
+
const restoredSlugs = new Set<string>();
|
|
90
|
+
/** Bases restored from disk after a restart: `${slug}|${lane}` → baseContent. */
|
|
91
|
+
const restoredBases = new Map<string, string>();
|
|
92
|
+
|
|
93
|
+
function summary(): StageSummary | null {
|
|
94
|
+
if (!state) return null;
|
|
95
|
+
const canvases = new Set<string>([...restoredSlugs]);
|
|
96
|
+
for (const l of lanes.values()) canvases.add(l.slug);
|
|
97
|
+
return { state, label, canvases: [...canvases].sort(), since };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function persist(): void {
|
|
101
|
+
try {
|
|
102
|
+
if (!state || (lanes.size === 0 && restoredSlugs.size === 0)) {
|
|
103
|
+
rmSync(file, { force: true });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
107
|
+
const body = {
|
|
108
|
+
v: 1,
|
|
109
|
+
state,
|
|
110
|
+
label,
|
|
111
|
+
since,
|
|
112
|
+
lanes: [...lanes.values()].map((l) => ({
|
|
113
|
+
slug: l.slug,
|
|
114
|
+
doc: l.doc,
|
|
115
|
+
lane: l.lane,
|
|
116
|
+
baseContent: l.baseContent,
|
|
117
|
+
})),
|
|
118
|
+
restored: [...restoredBases.entries()].map(([k, baseContent]) => {
|
|
119
|
+
const [slug, lane] = k.split('|');
|
|
120
|
+
return { slug, lane, baseContent };
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
writeFileSync(`${file}.tmp`, JSON.stringify(body));
|
|
124
|
+
renameSync(`${file}.tmp`, file);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
log.warn(`[sync/ai] could not record the unfinished action: ${(err as Error).message}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function changed(): void {
|
|
131
|
+
persist();
|
|
132
|
+
opts.onChange?.(summary());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A previous process left a stage: it comes back HELD. */
|
|
136
|
+
function restore(): void {
|
|
137
|
+
if (!existsSync(file)) return;
|
|
138
|
+
try {
|
|
139
|
+
const body = JSON.parse(readFileSync(file, 'utf8')) as {
|
|
140
|
+
label?: string;
|
|
141
|
+
since?: number;
|
|
142
|
+
lanes?: { slug: string; lane: string; baseContent: string }[];
|
|
143
|
+
restored?: { slug: string; lane: string; baseContent: string }[];
|
|
144
|
+
};
|
|
145
|
+
const all = [...(body.lanes ?? []), ...(body.restored ?? [])];
|
|
146
|
+
if (all.length === 0) return;
|
|
147
|
+
state = 'held';
|
|
148
|
+
label = typeof body.label === 'string' ? body.label : 'AI edit';
|
|
149
|
+
since = typeof body.since === 'number' ? body.since : now();
|
|
150
|
+
for (const l of all) {
|
|
151
|
+
restoredSlugs.add(l.slug);
|
|
152
|
+
restoredBases.set(`${l.slug}|${l.lane}`, l.baseContent);
|
|
153
|
+
}
|
|
154
|
+
log.warn(
|
|
155
|
+
`[sync/ai] an unfinished AI edit (${label}) to ${restoredSlugs.size} canvas(es) was kept from the last session — it is not published until you choose.`
|
|
156
|
+
);
|
|
157
|
+
opts.onChange?.(summary());
|
|
158
|
+
} catch {
|
|
159
|
+
/* unreadable → nothing to restore */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function begin(key: string, nextLabel: string): void {
|
|
164
|
+
keys.add(key);
|
|
165
|
+
if (state === 'open') return;
|
|
166
|
+
if (state === 'held') {
|
|
167
|
+
// A new action on top of an unfinished one continues it: its files may
|
|
168
|
+
// already carry the held bytes. It stays one decision for the person.
|
|
169
|
+
state = 'open';
|
|
170
|
+
failed = false;
|
|
171
|
+
changed();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
state = 'open';
|
|
175
|
+
failed = false;
|
|
176
|
+
label = nextLabel.slice(0, 120) || 'AI edit';
|
|
177
|
+
since = now();
|
|
178
|
+
changed();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Should this proposal be staged instead of sent? */
|
|
182
|
+
function captures(slug: string, stageable: boolean): boolean {
|
|
183
|
+
if (!stageable || !state) return false;
|
|
184
|
+
return (
|
|
185
|
+
state === 'open' ||
|
|
186
|
+
restoredSlugs.has(slug) ||
|
|
187
|
+
[...lanes.values()].some((l) => l.slug === slug)
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function capture(slug: string, doc: string, p: LaneProposal): Promise<ProposalOutcome> {
|
|
192
|
+
const k = `${slug}|${p.lane}`;
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const existing = lanes.get(k);
|
|
195
|
+
if (existing) {
|
|
196
|
+
existing.content = p.content;
|
|
197
|
+
existing.txs.push(p.transactionId);
|
|
198
|
+
existing.resolvers.push(resolve);
|
|
199
|
+
if (p.writeId) existing.writeId = p.writeId;
|
|
200
|
+
} else {
|
|
201
|
+
lanes.set(k, {
|
|
202
|
+
slug,
|
|
203
|
+
doc,
|
|
204
|
+
lane: p.lane,
|
|
205
|
+
baseContent: restoredBases.get(k) ?? p.baseContent,
|
|
206
|
+
content: p.content,
|
|
207
|
+
...(p.writeId ? { writeId: p.writeId } : {}),
|
|
208
|
+
txs: [p.transactionId],
|
|
209
|
+
resolvers: [resolve],
|
|
210
|
+
});
|
|
211
|
+
changed();
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function isStagedTx(id: string): boolean {
|
|
217
|
+
for (const l of lanes.values()) if (l.txs.includes(id)) return true;
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A non-staged proposal authored on top of a staged one waits behind the stage. */
|
|
222
|
+
function holdsDependency(dependsOn: string[] | undefined): boolean {
|
|
223
|
+
return !!dependsOn?.some((id) => isStagedTx(id));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function wait(dep: Dependent): void {
|
|
227
|
+
dependents.push(dep);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Rewrite a dependency on a staged proposal to the group that carried it. */
|
|
231
|
+
function mapDeps(dependsOn: string[] | undefined): string[] | undefined {
|
|
232
|
+
if (!dependsOn?.length) return dependsOn;
|
|
233
|
+
return dependsOn.map((id) => alias.get(id) ?? id);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function laneOutcome(r: ProposalResult, l: StagedLane): ProposalOutcome {
|
|
237
|
+
const mine = r.doc === l.doc && r.lane === l.lane;
|
|
238
|
+
return {
|
|
239
|
+
status: r.status,
|
|
240
|
+
...(r.code ? { code: r.code } : {}),
|
|
241
|
+
...(mine && typeof r.head === 'string' ? { head: r.head } : {}),
|
|
242
|
+
...(typeof r.actionId === 'string' ? { actionId: r.actionId } : {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function reset(): void {
|
|
247
|
+
state = null;
|
|
248
|
+
label = '';
|
|
249
|
+
since = 0;
|
|
250
|
+
keys.clear();
|
|
251
|
+
failed = false;
|
|
252
|
+
lanes.clear();
|
|
253
|
+
restoredSlugs.clear();
|
|
254
|
+
restoredBases.clear();
|
|
255
|
+
changed();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Propose everything staged as ONE action. */
|
|
259
|
+
async function publish(): Promise<ProposalResult | null> {
|
|
260
|
+
if (!state) return null;
|
|
261
|
+
const staged = [...lanes.values()];
|
|
262
|
+
const waiting = dependents.splice(0);
|
|
263
|
+
const groupLabel = label || 'AI edit';
|
|
264
|
+
if (staged.length === 0) {
|
|
265
|
+
reset();
|
|
266
|
+
for (const d of waiting) d.send(mapDeps(d.dependsOn));
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
const transactionId = opts.newTransactionId();
|
|
270
|
+
for (const l of staged) for (const tx of l.txs) alias.set(tx, transactionId);
|
|
271
|
+
const operations: Operation[] = staged.map((l) => ({
|
|
272
|
+
op: 'lane.replace',
|
|
273
|
+
doc: l.doc,
|
|
274
|
+
lane: l.lane,
|
|
275
|
+
content: l.content,
|
|
276
|
+
baseContent: l.baseContent,
|
|
277
|
+
...(l.writeId ? { writeId: l.writeId } : {}),
|
|
278
|
+
}));
|
|
279
|
+
// The stage is closed before the answer: the next action starts clean,
|
|
280
|
+
// and the dependents go out right behind the group (same ordered outbox).
|
|
281
|
+
reset();
|
|
282
|
+
const sent = opts.propose({ label: groupLabel, kind: 'ai', operations, transactionId });
|
|
283
|
+
for (const d of waiting) d.send(mapDeps(d.dependsOn));
|
|
284
|
+
const r = await sent.catch((err: unknown) => {
|
|
285
|
+
const code = (err as { code?: string })?.code ?? 'retryable';
|
|
286
|
+
return { protocol: 1, status: 'rejected', code, transactionId } as ProposalResult;
|
|
287
|
+
});
|
|
288
|
+
for (const l of staged) for (const fn of l.resolvers) fn(laneOutcome(r, l));
|
|
289
|
+
log.log(
|
|
290
|
+
`[sync/ai] ${groupLabel}: ${staged.length} change(s) ${r.status === 'accepted' ? 'published as one action' : `refused (${r.code ?? 'rejected'})`}.`
|
|
291
|
+
);
|
|
292
|
+
return r;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** End one participant. The last `done` publishes; any failure holds. */
|
|
296
|
+
async function end(key: string, outcome: 'done' | 'failed'): Promise<ProposalResult | null> {
|
|
297
|
+
if (!keys.has(key)) return null;
|
|
298
|
+
keys.delete(key);
|
|
299
|
+
if (outcome === 'failed') failed = true;
|
|
300
|
+
if (keys.size > 0 || state !== 'open') return null;
|
|
301
|
+
if (failed) {
|
|
302
|
+
state = 'held';
|
|
303
|
+
changed();
|
|
304
|
+
log.warn(
|
|
305
|
+
`[sync/ai] ${label}: the action did not finish — ${lanes.size} change(s) kept on this device, not published.`
|
|
306
|
+
);
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
return publish();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Throw the held changes away: every lane returns to the accepted version. */
|
|
313
|
+
function discard(): number {
|
|
314
|
+
if (state !== 'held') return 0;
|
|
315
|
+
const staged = [...lanes.values()];
|
|
316
|
+
const waiting = dependents.splice(0);
|
|
317
|
+
const slugs = new Set([...restoredSlugs, ...staged.map((l) => l.slug)]);
|
|
318
|
+
reset();
|
|
319
|
+
for (const l of staged)
|
|
320
|
+
for (const fn of l.resolvers) fn({ status: 'rejected', code: 'discarded' });
|
|
321
|
+
for (const d of waiting) d.drop({ status: 'rejected', code: 'discarded' });
|
|
322
|
+
return slugs.size;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
restore,
|
|
327
|
+
begin,
|
|
328
|
+
end,
|
|
329
|
+
publish,
|
|
330
|
+
discard,
|
|
331
|
+
captures,
|
|
332
|
+
capture,
|
|
333
|
+
holdsDependency,
|
|
334
|
+
wait,
|
|
335
|
+
mapDeps,
|
|
336
|
+
summary,
|
|
337
|
+
get state() {
|
|
338
|
+
return state;
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export type ActionStage = ReturnType<typeof createActionStage>;
|
|
@@ -52,12 +52,10 @@ import {
|
|
|
52
52
|
cssFromDoc,
|
|
53
53
|
htmlFromDoc,
|
|
54
54
|
isEmptyAnnotationsSvg,
|
|
55
|
-
markSeeded,
|
|
56
55
|
mergeSharedMetaIntoLocal,
|
|
57
56
|
metaFromDoc,
|
|
58
57
|
movedToFromDoc,
|
|
59
58
|
repairSharedMeta,
|
|
60
|
-
seededByFromDoc,
|
|
61
59
|
stampAnnotationsEdit,
|
|
62
60
|
stampBodyEdit,
|
|
63
61
|
Y_SYNC_TYPES,
|
|
@@ -66,13 +64,13 @@ import {
|
|
|
66
64
|
decideAnnotationsColdStart,
|
|
67
65
|
decideColdStart,
|
|
68
66
|
decideCssColdStart,
|
|
69
|
-
isExactRepeat,
|
|
70
67
|
unionCommentsById,
|
|
71
68
|
} from './cold-start.ts';
|
|
72
69
|
import { applyColdStart, type ColdStartSnapshotReason } from './cold-start-apply.ts';
|
|
73
70
|
import { dedupeCommentsById, hasDuplicateComments } from './comment-identity.ts';
|
|
74
71
|
import { type EchoGuard, hashBytes } from './echo-guard.ts';
|
|
75
72
|
import type { SyncJournal } from './journal.ts';
|
|
73
|
+
import { rememberSeed, repairSeedDuplication } from './seed-repair.ts';
|
|
76
74
|
|
|
77
75
|
export const DOC_FLUSH_MS = 800;
|
|
78
76
|
|
|
@@ -82,7 +80,7 @@ export const DOC_FLUSH_MS = 800;
|
|
|
82
80
|
* the cold-start race — a genuine peer edit that arrives minutes later is NEVER
|
|
83
81
|
* collapsed back to our original seed. Generous relative to the hub sync RTT.
|
|
84
82
|
*/
|
|
85
|
-
export
|
|
83
|
+
export { SEED_REPAIR_WINDOW_MS } from './seed-repair.ts';
|
|
86
84
|
|
|
87
85
|
export interface CanvasSyncPaths {
|
|
88
86
|
/** Absolute path to <designRoot>/<canvas>.html. */
|
|
@@ -187,18 +185,6 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
187
185
|
let lastMeta: string | null = null;
|
|
188
186
|
let lastCss: string | null = null;
|
|
189
187
|
|
|
190
|
-
// F1 — the content this agent pushed on `seed-local-up`, plus the deadline
|
|
191
|
-
// past which the de-dup repair stops firing. Set on seed; nulled only when the
|
|
192
|
-
// window lapses (after convergence the repair is a cheap no-op — the
|
|
193
|
-
// `=== seedInfo.x` guards short-circuit, so we don't bother clearing).
|
|
194
|
-
// Null = this agent never seeded (so it never repairs).
|
|
195
|
-
//
|
|
196
|
-
// `css` rides along with the body (issue #114): a seed writes BOTH lanes in
|
|
197
|
-
// the same breath, so both can collide with a concurrent peer's identical
|
|
198
|
-
// seed, and repairing only the body left the canvas un-buildable anyway —
|
|
199
|
-
// duplicated css is as fatal to the build as a duplicated `export default`.
|
|
200
|
-
let seedInfo: { body: string; css: string | null; until: number } | null = null;
|
|
201
|
-
|
|
202
188
|
function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
|
|
203
189
|
if (stopped) return;
|
|
204
190
|
// Self-applied (we just synced from disk) — disk is already current.
|
|
@@ -246,45 +232,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
246
232
|
* variant is an accepted follow-up, not handled in-flight.
|
|
247
233
|
*/
|
|
248
234
|
function maybeRepairSeedDuplication(): void {
|
|
249
|
-
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
const owner = seededByFromDoc(doc);
|
|
255
|
-
if (owner === null || owner !== doc.clientID) return; // not the elected writer
|
|
256
|
-
const seeded = seedInfo;
|
|
257
|
-
const repaired: string[] = [];
|
|
258
|
-
|
|
259
|
-
const body = htmlFromDoc(doc);
|
|
260
|
-
// `=== seeded.body` → already a single copy; not an exact repeat → a
|
|
261
|
-
// divergent edit, which carries genuine bytes and belongs to the conflict
|
|
262
|
-
// path, not to a silent collapse.
|
|
263
|
-
if (body !== seeded.body && isExactRepeat(body, seeded.body)) {
|
|
264
|
-
const canonical = seeded.body;
|
|
265
|
-
doc.transact(() => {
|
|
266
|
-
if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
|
|
267
|
-
}, origin);
|
|
268
|
-
lastHtml = canonical;
|
|
269
|
-
opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
|
|
270
|
-
repaired.push('body');
|
|
235
|
+
const repaired = repairSeedDuplication(doc, origin);
|
|
236
|
+
if (repaired.includes('body')) {
|
|
237
|
+
lastHtml = htmlFromDoc(doc);
|
|
238
|
+
opts.journal?.record(slug, { bodyHash: hashBytes(lastHtml) });
|
|
271
239
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// idempotence: the collapse deletes the same CRDT items on every peer that
|
|
276
|
-
// runs it, so concurrent recoveries converge instead of fighting.
|
|
277
|
-
if (paths.css && seeded.css !== null) {
|
|
278
|
-
const css = cssFromDoc(doc);
|
|
279
|
-
if (css !== null && css !== seeded.css && isExactRepeat(css, seeded.css)) {
|
|
280
|
-
const canonicalCss = seeded.css;
|
|
281
|
-
doc.transact(() => {
|
|
282
|
-
applyCssToDoc(doc, canonicalCss, origin);
|
|
283
|
-
}, origin);
|
|
284
|
-
lastCss = canonicalCss;
|
|
285
|
-
opts.journal?.record(slug, { cssHash: hashBytes(canonicalCss) });
|
|
286
|
-
repaired.push('css');
|
|
287
|
-
}
|
|
240
|
+
if (repaired.includes('css')) {
|
|
241
|
+
lastCss = cssFromDoc(doc);
|
|
242
|
+
if (lastCss !== null) opts.journal?.record(slug, { cssHash: hashBytes(lastCss) });
|
|
288
243
|
}
|
|
289
244
|
|
|
290
245
|
if (repaired.length > 0) {
|
|
@@ -518,9 +473,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
518
473
|
// F1 — adopt is also a "seed local up" (first link / fresh hub); claim
|
|
519
474
|
// it + arm the repair window so two peers adopting the same draft into
|
|
520
475
|
// one hub at once self-heal the same way the decision-table seed does.
|
|
521
|
-
|
|
476
|
+
rememberSeed(doc, localHtml, localCss, origin);
|
|
522
477
|
}, origin);
|
|
523
|
-
seedInfo = { body: localHtml, css: localCss, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
524
478
|
}
|
|
525
479
|
if (localComments !== null) {
|
|
526
480
|
const parsed = tryParseJsonArray(localComments);
|
|
@@ -575,15 +529,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
|
|
|
575
529
|
if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
|
|
576
530
|
// F1 — claim the seed (clientID marker) in the SAME update so a
|
|
577
531
|
// concurrent second seeder's merge resolves a single elected writer.
|
|
578
|
-
|
|
532
|
+
rememberSeed(doc, body, localCss, origin);
|
|
579
533
|
}, origin);
|
|
580
534
|
lastHtml = body;
|
|
581
535
|
// Arm the de-dup repair window: if another peer seeded the same content at
|
|
582
536
|
// the same instant, the merged Y.Text will double — maybeRepairSeedDuplication
|
|
583
537
|
// (on the elected owner) collapses it back during this window. `localCss`
|
|
584
|
-
// is
|
|
585
|
-
// below and collides identically (issue #114).
|
|
586
|
-
seedInfo = { body, css: localCss, until: Date.now() + SEED_REPAIR_WINDOW_MS };
|
|
538
|
+
// is remembered too, since it collides identically (issue #114).
|
|
587
539
|
opts.journal?.record(slug, { bodyHash: hashBytes(body) });
|
|
588
540
|
};
|
|
589
541
|
|
|
@@ -89,6 +89,9 @@ export function startCellFileEvents(
|
|
|
89
89
|
url: target.url,
|
|
90
90
|
token: target.token,
|
|
91
91
|
onPoke: (head) => healer.onPoke(head),
|
|
92
|
+
// The paired cell runtime does not open a second control provider. Route
|
|
93
|
+
// metadata invalidation to its bounded discovery queue, not the journal.
|
|
94
|
+
onDocuments: () => ctx.bus.emit('sync:documents-changed'),
|
|
92
95
|
});
|
|
93
96
|
|
|
94
97
|
// Every write this process makes surfaces here, because both synthetic-event
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ANNOTATION_WRITE_ID } from '../annotations-sync.ts';
|
|
1
2
|
// Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
|
|
2
3
|
//
|
|
3
4
|
// The agent shuttles three classes of files between disk and the Y.Doc the
|
|
@@ -22,11 +23,11 @@
|
|
|
22
23
|
// in the unchanged regions.
|
|
23
24
|
|
|
24
25
|
import { hostname } from 'node:os';
|
|
25
|
-
|
|
26
|
+
import { diffChars } from 'diff';
|
|
26
27
|
import type * as Y from 'yjs';
|
|
27
28
|
|
|
28
29
|
import { Y_TYPES } from '../collab/persistence.ts';
|
|
29
|
-
import { commentKey } from './comment-identity.ts';
|
|
30
|
+
import { commentKey, dedupeCommentsById } from './comment-identity.ts';
|
|
30
31
|
import {
|
|
31
32
|
MAX_ANNOTATIONS_BYTES,
|
|
32
33
|
MAX_COMMENTS_BYTES,
|
|
@@ -97,8 +98,8 @@ export function htmlFromDoc(doc: Y.Doc): string {
|
|
|
97
98
|
}
|
|
98
99
|
|
|
99
100
|
/**
|
|
100
|
-
* Apply `next`
|
|
101
|
-
*
|
|
101
|
+
* Apply `next` as bounded text hunks, preserving unchanged interior CRDT
|
|
102
|
+
* anchors as well as the common prefix/suffix (#121).
|
|
102
103
|
*
|
|
103
104
|
* THIS SHAPE IS A CONVERGENCE PROPERTY, NOT AN OPTIMIZATION (issue #114).
|
|
104
105
|
* Collapsing a concurrency-duplicated lane — `X + X` back to `X` — comes out of
|
|
@@ -137,12 +138,37 @@ function applyTextLane(doc: Y.Doc, lane: string, next: string, origin?: unknown)
|
|
|
137
138
|
suffix++;
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
// Y.Text offsets are UTF-16; never leave half a surrogate outside a hunk.
|
|
142
|
+
if (prefix > 0 && /[\uD800-\uDBFF]/.test(current[prefix - 1] ?? '')) prefix--;
|
|
143
|
+
if (suffix > 0 && /[\uDC00-\uDFFF]/.test(current[current.length - suffix] ?? '')) suffix--;
|
|
140
144
|
const deleteLen = current.length - prefix - suffix;
|
|
141
145
|
const insertStr = next.slice(prefix, next.length - suffix);
|
|
142
146
|
|
|
147
|
+
// Keep pure inserts/deletes (especially repeat collapse) as one operation.
|
|
148
|
+
// For replacements preserve interior anchors: one prefix/suffix splice can
|
|
149
|
+
// move a concurrent insertion inside an unrelated JSX attribute (#121).
|
|
150
|
+
const changes =
|
|
151
|
+
deleteLen > 0 && insertStr.length > 0
|
|
152
|
+
? diffChars(current.slice(prefix, current.length - suffix), insertStr, {
|
|
153
|
+
maxEditLength: 4096,
|
|
154
|
+
timeout: 50,
|
|
155
|
+
})
|
|
156
|
+
: null;
|
|
157
|
+
if (changes === undefined) throw new Error('Source diff exceeds the safe merge budget');
|
|
143
158
|
doc.transact(() => {
|
|
144
|
-
if (
|
|
145
|
-
|
|
159
|
+
if (changes) {
|
|
160
|
+
let offset = prefix;
|
|
161
|
+
for (const change of changes) {
|
|
162
|
+
if (change.removed) yText.delete(offset, change.value.length);
|
|
163
|
+
else {
|
|
164
|
+
if (change.added) yText.insert(offset, change.value);
|
|
165
|
+
offset += change.value.length;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
} else {
|
|
169
|
+
if (deleteLen > 0) yText.delete(prefix, deleteLen);
|
|
170
|
+
if (insertStr.length > 0) yText.insert(prefix, insertStr);
|
|
171
|
+
}
|
|
146
172
|
}, origin);
|
|
147
173
|
|
|
148
174
|
return true;
|
|
@@ -306,6 +332,8 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
|
|
|
306
332
|
if (currentStr === next) return false;
|
|
307
333
|
|
|
308
334
|
doc.transact(() => {
|
|
335
|
+
// A filesystem import is a new operation, never the previous UI author's echo.
|
|
336
|
+
map.delete(ANNOTATION_WRITE_ID);
|
|
309
337
|
if (next === null || next === '') {
|
|
310
338
|
map.delete('svg');
|
|
311
339
|
} else {
|
|
@@ -389,6 +417,45 @@ function parsesAsObject(s: string): boolean {
|
|
|
389
417
|
}
|
|
390
418
|
|
|
391
419
|
/** The synced shared-meta JSON string held in the doc, or null when unset. */
|
|
420
|
+
/**
|
|
421
|
+
* The canonical LANE value for a file's text — what the accepted-revisions
|
|
422
|
+
* kernel stores and what `readLaneFromDoc` returns once it is accepted
|
|
423
|
+
* (DDR-241). `null` when the file text cannot be a value of that lane.
|
|
424
|
+
*/
|
|
425
|
+
export function laneValueFromFile(
|
|
426
|
+
lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments',
|
|
427
|
+
text: string
|
|
428
|
+
): string | null {
|
|
429
|
+
if (lane === 'html' || lane === 'css' || lane === 'annotations') return text;
|
|
430
|
+
let parsed: unknown;
|
|
431
|
+
try {
|
|
432
|
+
parsed = parseJsonSafe(text);
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
if (lane === 'meta') {
|
|
437
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
438
|
+
return sharedMetaCanonical(parsed as Record<string, unknown>);
|
|
439
|
+
}
|
|
440
|
+
if (!Array.isArray(parsed)) return null;
|
|
441
|
+
const list = dedupeCommentsById(parsed);
|
|
442
|
+
return list.length ? JSON.stringify(list) : '';
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** A lane's value as the accepted replica holds it (same canonical form). */
|
|
446
|
+
export function readLaneFromDoc(
|
|
447
|
+
doc: Y.Doc,
|
|
448
|
+
lane: 'html' | 'css' | 'meta' | 'annotations' | 'comments'
|
|
449
|
+
): string {
|
|
450
|
+
if (lane === 'html' || lane === 'css' || lane === 'meta') return doc.getText(lane).toString();
|
|
451
|
+
if (lane === 'annotations') {
|
|
452
|
+
const svg = doc.getMap<unknown>(Y_TYPES.annotations).get('svg');
|
|
453
|
+
return typeof svg === 'string' ? svg : '';
|
|
454
|
+
}
|
|
455
|
+
const list = doc.getArray<unknown>(Y_TYPES.comments).toArray();
|
|
456
|
+
return list.length ? JSON.stringify(list) : '';
|
|
457
|
+
}
|
|
458
|
+
|
|
392
459
|
export function metaFromDoc(doc: Y.Doc): string | null {
|
|
393
460
|
return normalizeSharedMeta(doc.getText(Y_SYNC_TYPES.meta).toString());
|
|
394
461
|
}
|
|
@@ -95,6 +95,8 @@ export interface CtlProviderOptions {
|
|
|
95
95
|
token: string;
|
|
96
96
|
/** Fired with the hub's head on every well-formed poke. */
|
|
97
97
|
onPoke: (head: number) => void;
|
|
98
|
+
/** Optional fast document-list invalidation; older consumers use onPoke. */
|
|
99
|
+
onDocuments?: () => void;
|
|
98
100
|
documentName?: string;
|
|
99
101
|
log?: Pick<Console, 'log' | 'warn' | 'error'>;
|
|
100
102
|
/** Injected in tests; production builds one from `@hocuspocus/provider`. */
|
|
@@ -155,9 +157,19 @@ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
|
|
|
155
157
|
|
|
156
158
|
const wire = (p: CtlProviderLike): void => {
|
|
157
159
|
p.on('status', (data: { status?: string }) => {
|
|
158
|
-
if (typeof data?.status
|
|
160
|
+
if (typeof data?.status !== 'string') return;
|
|
161
|
+
const reconnect = data.status === 'connected' && status !== 'connected';
|
|
162
|
+
status = data.status;
|
|
163
|
+
if (reconnect && !stopped) {
|
|
164
|
+
try {
|
|
165
|
+
opts.onDocuments?.();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
log.error?.(`[sync/ctl] discovery handler threw: ${(error as Error).message}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
159
170
|
});
|
|
160
171
|
p.on('stateless', (data: { payload: string }) => {
|
|
172
|
+
if (stopped) return;
|
|
161
173
|
const poke = parsePoke(data?.payload);
|
|
162
174
|
if (poke === null) {
|
|
163
175
|
malformed += 1;
|
|
@@ -172,7 +184,8 @@ export function createCtlProvider(opts: CtlProviderOptions): CtlProvider {
|
|
|
172
184
|
}
|
|
173
185
|
received += 1;
|
|
174
186
|
try {
|
|
175
|
-
|
|
187
|
+
if (poke.documents && opts.onDocuments) opts.onDocuments();
|
|
188
|
+
else opts.onPoke(poke.head);
|
|
176
189
|
} catch (err) {
|
|
177
190
|
log.error?.(`[sync/ctl] poke handler threw: ${(err as Error).message}`);
|
|
178
191
|
}
|