@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.
Files changed (91) hide show
  1. package/README.md +3 -1
  2. package/apps/studio/acp/index.ts +43 -7
  3. package/apps/studio/annotations-layer.tsx +191 -116
  4. package/apps/studio/annotations-model.ts +39 -0
  5. package/apps/studio/annotations-sync.ts +50 -0
  6. package/apps/studio/api.ts +732 -38
  7. package/apps/studio/bin/annotate.mjs +3 -1
  8. package/apps/studio/bin/server-up.sh +17 -2
  9. package/apps/studio/canvas-build.ts +49 -8
  10. package/apps/studio/canvas-edit.ts +468 -7
  11. package/apps/studio/canvas-lib.tsx +82 -18
  12. package/apps/studio/canvas-list-watch.ts +33 -2
  13. package/apps/studio/canvas-notice-message.ts +16 -0
  14. package/apps/studio/canvas-notifications.tsx +19 -0
  15. package/apps/studio/canvas-shell.tsx +57 -6
  16. package/apps/studio/client/app.jsx +783 -245
  17. package/apps/studio/client/apply-edit-request.ts +55 -0
  18. package/apps/studio/client/canvas-url.js +39 -1
  19. package/apps/studio/client/export-center.jsx +111 -61
  20. package/apps/studio/client/github.js +38 -1
  21. package/apps/studio/client/index-loader.ts +76 -0
  22. package/apps/studio/client/panels/CloudBar.jsx +52 -6
  23. package/apps/studio/client/panels/GitPanel.jsx +103 -0
  24. package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
  25. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
  26. package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
  27. package/apps/studio/client/panels/SyncPanel.jsx +113 -0
  28. package/apps/studio/client/panels/TeamProjects.jsx +376 -0
  29. package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
  30. package/apps/studio/client/photo-knobs.jsx +3 -0
  31. package/apps/studio/client/share-dialog.jsx +91 -0
  32. package/apps/studio/client/share-link.js +94 -0
  33. package/apps/studio/client/styles/3-shell-maude.css +36 -18
  34. package/apps/studio/client/styles/4-components.css +18 -1
  35. package/apps/studio/client/tree-row-menu.jsx +48 -3
  36. package/apps/studio/client/whats-new.jsx +26 -32
  37. package/apps/studio/cloud/endpoints.ts +132 -0
  38. package/apps/studio/collab/awareness-bridge.ts +65 -14
  39. package/apps/studio/collab/index.ts +4 -0
  40. package/apps/studio/collab/persistence.ts +5 -1
  41. package/apps/studio/collab/registry.ts +9 -3
  42. package/apps/studio/collab/room.ts +13 -2
  43. package/apps/studio/context.ts +59 -0
  44. package/apps/studio/dist/client.bundle.js +1594 -1562
  45. package/apps/studio/dist/comment-mount.js +2 -2
  46. package/apps/studio/dist/runtime/.min-sizes.json +1 -0
  47. package/apps/studio/dist/runtime/sonner.js +1 -0
  48. package/apps/studio/dist/styles.css +1 -1
  49. package/apps/studio/git/accepted-guard.ts +36 -0
  50. package/apps/studio/hmr-broadcast.ts +34 -1
  51. package/apps/studio/http.ts +300 -18
  52. package/apps/studio/inspect.ts +19 -0
  53. package/apps/studio/managed-projects.ts +172 -0
  54. package/apps/studio/notifications.tsx +292 -0
  55. package/apps/studio/runtime-bundle.ts +2 -0
  56. package/apps/studio/server.ts +59 -3
  57. package/apps/studio/sync/accepted-cold-start.ts +225 -0
  58. package/apps/studio/sync/accepted-link.ts +320 -0
  59. package/apps/studio/sync/action-stage.ts +343 -0
  60. package/apps/studio/sync/agent.ts +12 -60
  61. package/apps/studio/sync/cell-file-events.ts +3 -0
  62. package/apps/studio/sync/codec.ts +73 -6
  63. package/apps/studio/sync/ctl-provider.ts +15 -2
  64. package/apps/studio/sync/document-discovery.ts +81 -0
  65. package/apps/studio/sync/file-membership.ts +10 -2
  66. package/apps/studio/sync/file-plane.ts +309 -26
  67. package/apps/studio/sync/index.ts +1174 -21
  68. package/apps/studio/sync/migrate-seed.ts +110 -19
  69. package/apps/studio/sync/poke.ts +4 -2
  70. package/apps/studio/sync/presentation.ts +315 -2
  71. package/apps/studio/sync/projection.ts +820 -23
  72. package/apps/studio/sync/repeated-module.ts +73 -0
  73. package/apps/studio/sync/revision-barrier.ts +129 -0
  74. package/apps/studio/sync/seed-repair.ts +46 -0
  75. package/apps/studio/sync/source-merge.ts +100 -0
  76. package/apps/studio/sync/source-ops.ts +289 -0
  77. package/apps/studio/sync/source-recovery.ts +70 -0
  78. package/apps/studio/sync/source-validation.ts +56 -0
  79. package/apps/studio/sync/status.ts +83 -1
  80. package/apps/studio/sync/transaction-client.ts +486 -0
  81. package/apps/studio/sync/writer-registry.ts +236 -0
  82. package/apps/studio/text-caret.ts +35 -0
  83. package/apps/studio/undo-hud.tsx +9 -87
  84. package/apps/studio/use-canvas-media-drop.tsx +4 -39
  85. package/apps/studio/use-tool-mode.tsx +44 -0
  86. package/apps/studio/whats-new.json +137 -0
  87. package/cli/lib/harness/codex-runtime.mjs +4 -1
  88. package/package.json +9 -8
  89. package/plugins/design/dependencies.json +3 -3
  90. package/plugins/design/templates/_shell.html +95 -7
  91. package/plugins/flow/dependencies.json +3 -3
@@ -0,0 +1,225 @@
1
+ // Cold start under accepted revisions — DDR-241 §3, plan T13/T14.
2
+ //
3
+ // The legacy cold start (`migrate-seed.ts`) had to CHOOSE a source and write it
4
+ // into the shared document, because the document was writable and whatever a
5
+ // peer put there was the project. Here the document is a read-only replica of
6
+ // the accepted state, so cold start only ever DECIDES, per lane:
7
+ //
8
+ // agreed — disk already holds the accepted value;
9
+ // materialize — disk is older than the project (or absent): the projection
10
+ // writes the accepted value, keeping local bytes in recovery;
11
+ // propose — disk carries a change made on top of a known base: it is
12
+ // proposed, and the hub merges it three-way from that base;
13
+ // hold — disk differs and nothing proves what it was derived from:
14
+ // both are kept, the lane stays blocked with a visible
15
+ // conflict, and the next save resolves it (T2's rule — never a
16
+ // silent overwrite of either side).
17
+ //
18
+ // A canvas the project does not know yet is proposed as `doc.create` with all
19
+ // of its lanes, so it arrives on every peer as ONE action.
20
+ //
21
+ // Comments and annotations are never re-proposed from disk here: every change
22
+ // the studio made to them went through the durable outbox (drained before any
23
+ // cold start), so a difference on disk is an older accepted state the room had
24
+ // not re-projected — the accepted value wins, and the local file is kept in a
25
+ // recovery slot in case a raw edit made while the studio was down lived there.
26
+
27
+ import { existsSync, readFileSync } from 'node:fs';
28
+ import type * as Y from 'yjs';
29
+
30
+ import { cssFromDoc, htmlFromDoc, laneValueFromFile, readLaneFromDoc } from './codec.ts';
31
+ import { hashBytes } from './echo-guard.ts';
32
+ import type { SyncJournal } from './journal.ts';
33
+ import type { DocProjection, ProjectionPaths, ProposalLane } from './projection.ts';
34
+ import { readRecoveryBody, saveRecoveryBody } from './source-recovery.ts';
35
+ import { sourceError } from './source-validation.ts';
36
+
37
+ export type LaneDecision = 'agreed' | 'materialize' | 'propose' | 'hold';
38
+
39
+ export interface LaneVerdict {
40
+ lane: ProposalLane;
41
+ decision: LaneDecision;
42
+ /** For `propose`: the value the local edit was derived from. For `hold`: the accepted value adopted as the base. */
43
+ base?: string;
44
+ local?: string;
45
+ }
46
+
47
+ function readText(p: string | undefined): string | null {
48
+ if (!p || !existsSync(p)) return null;
49
+ try {
50
+ return readFileSync(p, 'utf8');
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Pure decision for the source lanes. `knownBase` is the last value disk and
58
+ * the accepted replica agreed on (recovery slot), `baseHash` the journal's
59
+ * hash of it when the bytes are gone.
60
+ */
61
+ export function decideSourceLane(input: {
62
+ local: string | null;
63
+ accepted: string;
64
+ knownBase: string | null;
65
+ baseHash?: string | null;
66
+ }): { decision: LaneDecision; base?: string } {
67
+ const { local, accepted } = input;
68
+ if (local === null) return { decision: 'materialize' };
69
+ if (local === accepted) return { decision: 'agreed' };
70
+ let base = input.knownBase;
71
+ if (base === null && input.baseHash) {
72
+ if (hashBytes(accepted) === input.baseHash) base = accepted;
73
+ else if (hashBytes(local) === input.baseHash) base = local;
74
+ }
75
+ if (base === null) {
76
+ // The project never had this lane: nothing of anybody's can be lost.
77
+ if (accepted === '') return { decision: 'propose', base: '' };
78
+ return { decision: 'hold', base: accepted };
79
+ }
80
+ if (base === local) return { decision: 'materialize' }; // an older accepted state
81
+ return { decision: 'propose', base };
82
+ }
83
+
84
+ export interface AcceptedColdStartInput {
85
+ slug: string;
86
+ doc: Y.Doc;
87
+ paths: ProjectionPaths;
88
+ /** Design-root-relative body path (`ui/home.tsx`). */
89
+ rel: string;
90
+ /** Is this canvas a live document of the project's accepted state? */
91
+ inProject: boolean;
92
+ projection: DocProjection;
93
+ historyDir?: string;
94
+ journal?: SyncJournal;
95
+ createDoc: (lanes: Partial<Record<ProposalLane, string>>) => Promise<{
96
+ status: 'accepted' | 'rejected';
97
+ code?: string;
98
+ }>;
99
+ log?: Pick<Console, 'log' | 'warn'>;
100
+ }
101
+
102
+ export type AcceptedColdStartResult =
103
+ | { kind: 'created' }
104
+ | { kind: 'create-refused'; code?: string }
105
+ | { kind: 'reconciled'; verdicts: LaneVerdict[] };
106
+
107
+ export async function acceptedColdStart(
108
+ i: AcceptedColdStartInput
109
+ ): Promise<AcceptedColdStartResult> {
110
+ const log = i.log ?? console;
111
+ const localHtml = readText(i.paths.html);
112
+
113
+ if (!i.inProject) {
114
+ if (localHtml === null) return { kind: 'reconciled', verdicts: [] };
115
+ const lanes: Partial<Record<ProposalLane, string>> = { html: localHtml };
116
+ const css = readText(i.paths.css);
117
+ if (css) lanes.css = css;
118
+ const metaText = readText(i.paths.meta);
119
+ const meta = metaText === null ? null : laneValueFromFile('meta', metaText);
120
+ if (meta && meta !== '{}') lanes.meta = meta;
121
+ const ann = readText(i.paths.annotations);
122
+ if (ann) lanes.annotations = ann;
123
+ const commentsText = readText(i.paths.comments);
124
+ const comments = commentsText === null ? null : laneValueFromFile('comments', commentsText);
125
+ if (comments) lanes.comments = comments;
126
+ const r = await i.createDoc(lanes);
127
+ if (r.status === 'accepted') {
128
+ // What the project now holds IS the local file: the next save is based
129
+ // on it, even if it lands before the publication reaches this replica.
130
+ i.projection.adoptBase(localHtml);
131
+ log.log(`[sync/${i.slug}] added to the project (accepted).`);
132
+ return { kind: 'created' };
133
+ }
134
+ log.warn(
135
+ `[sync/${i.slug}] the project did not accept this canvas (${r.code ?? 'rejected'}) — it stays local.`
136
+ );
137
+ return { kind: 'create-refused', code: r.code };
138
+ }
139
+
140
+ const verdicts: LaneVerdict[] = [];
141
+
142
+ // ---- html — the canvas source
143
+ const acceptedHtml = htmlFromDoc(i.doc);
144
+ if (localHtml !== null && localHtml !== acceptedHtml && sourceError(i.paths.html, localHtml)) {
145
+ // An invalid local body cannot be proposed; the projection keeps its bytes
146
+ // in recovery and reports it when it materializes the accepted source.
147
+ verdicts.push({ lane: 'html', decision: 'materialize', local: localHtml });
148
+ } else {
149
+ const d = decideSourceLane({
150
+ local: localHtml,
151
+ accepted: acceptedHtml,
152
+ knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.html, 'base') : null,
153
+ baseHash: i.journal?.get(i.slug)?.bodyHash ?? null,
154
+ });
155
+ verdicts.push({ lane: 'html', ...d, ...(localHtml !== null ? { local: localHtml } : {}) });
156
+ }
157
+
158
+ // ---- css — opaque text, journal-checkpointed
159
+ if (i.paths.css) {
160
+ const localCss = readText(i.paths.css);
161
+ const d = decideSourceLane({
162
+ local: localCss,
163
+ accepted: cssFromDoc(i.doc) ?? '',
164
+ knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.css, 'base') : null,
165
+ baseHash: i.journal?.get(i.slug)?.cssHash ?? null,
166
+ });
167
+ verdicts.push({ lane: 'css', ...d, ...(localCss !== null ? { local: localCss } : {}) });
168
+ }
169
+
170
+ // ---- meta — the shared layout keys (viewport never travels)
171
+ if (i.paths.meta) {
172
+ const metaText = readText(i.paths.meta);
173
+ const local = metaText === null ? null : laneValueFromFile('meta', metaText);
174
+ const accepted = readLaneFromDoc(i.doc, 'meta');
175
+ if (local === null || local === accepted || (local === '{}' && accepted === '')) {
176
+ verdicts.push({ lane: 'meta', decision: local === null ? 'materialize' : 'agreed' });
177
+ } else if (accepted === '') {
178
+ verdicts.push({ lane: 'meta', decision: 'propose', base: '', local });
179
+ } else {
180
+ // No base is recorded for layout: the project's arrangement wins and the
181
+ // projection merges it into the local file's private keys.
182
+ verdicts.push({ lane: 'meta', decision: 'materialize', local });
183
+ }
184
+ }
185
+
186
+ // ---- comments / annotations — the accepted value wins (see header)
187
+ for (const lane of ['comments', 'annotations'] as const) {
188
+ const p = lane === 'comments' ? i.paths.comments : i.paths.annotations;
189
+ const text = readText(p);
190
+ const local = text === null ? null : laneValueFromFile(lane, text);
191
+ const accepted = readLaneFromDoc(i.doc, lane);
192
+ if (local === null || local === accepted) {
193
+ verdicts.push({ lane, decision: local === null ? 'materialize' : 'agreed' });
194
+ continue;
195
+ }
196
+ if (i.historyDir && text) {
197
+ try {
198
+ saveRecoveryBody(i.historyDir, p, 'local', text);
199
+ } catch {
200
+ /* recovery is best-effort for these lanes */
201
+ }
202
+ }
203
+ verdicts.push({ lane, decision: 'materialize', local: text ?? undefined });
204
+ }
205
+
206
+ // ---- act
207
+ for (const v of verdicts) {
208
+ if (v.decision === 'propose' && v.local !== undefined) {
209
+ const value = v.lane === 'meta' ? v.local : (laneValueFromFile(v.lane, v.local) ?? v.local);
210
+ if (v.lane === 'html') i.projection.adoptBase(v.base ?? '');
211
+ // Stageable: a restored unfinished AI action (T16) keeps its canvases'
212
+ // differences for the person's decision instead of publishing them.
213
+ void i.projection.proposeLane(v.lane, value, { baseContent: v.base ?? '', stageable: true });
214
+ } else if (v.decision === 'hold' && v.local !== undefined) {
215
+ i.projection.hold(v.lane, v.base ?? '', v.local);
216
+ }
217
+ }
218
+ const moved = verdicts.filter((v) => v.decision === 'propose' || v.decision === 'hold');
219
+ if (moved.length) {
220
+ log.log(
221
+ `[sync/${i.slug}] cold start: ${moved.map((v) => `${v.lane}=${v.decision}`).join(', ')}`
222
+ );
223
+ }
224
+ return { kind: 'reconciled', verdicts };
225
+ }
@@ -0,0 +1,320 @@
1
+ // The studio's side of accepted revisions — DDR-241 §3, plan T13–T17.
2
+ //
3
+ // One object per sync runtime. It knows whether the linked project is in
4
+ // `transactions` mode (asked, never assumed: a hub without the route is a
5
+ // legacy hub), and it turns every persistent change the studio makes into a
6
+ // proposal through the durable transaction client:
7
+ //
8
+ // • lane values — text/CSS/meta/comments/annotations, via `laneLink(slug)`
9
+ // handed to each canvas's projection;
10
+ // • the manifest — canvas create/move/delete and folder create/move/delete.
11
+ //
12
+ // Nothing here writes a Y.Doc. The documents change when the hub publishes the
13
+ // accepted revision, through the same providers that deliver a peer's edit.
14
+
15
+ import { createActionStage, type StageSummary } from './action-stage.ts';
16
+ import type { AcceptedLaneLink, LaneProposal, ProposalOutcome } from './projection.ts';
17
+ import {
18
+ type Bootstrap,
19
+ createTransactionClient,
20
+ type Operation,
21
+ type ProposalResult,
22
+ type TransactionClient,
23
+ TransactionError,
24
+ type TransactionStats,
25
+ } from './transaction-client.ts';
26
+
27
+ export type AcceptedMode = 'unknown' | 'legacy' | 'transactions';
28
+
29
+ export interface AcceptedLinkOptions {
30
+ hubUrl: string;
31
+ token: () => string | null;
32
+ designRoot: string;
33
+ docNameFor: (slug: string) => string;
34
+ fetchImpl?: typeof fetch;
35
+ log?: Pick<Console, 'log' | 'warn' | 'error'>;
36
+ onPending?: (count: number) => void;
37
+ onStats?: (stats: TransactionStats) => void;
38
+ onResult?: (result: ProposalResult, action: { label: string; operations: Operation[] }) => void;
39
+ retryMs?: number;
40
+ /** Injected client (tests). */
41
+ client?: TransactionClient;
42
+ /** T16 — an AI action opened, was held, or ended. */
43
+ onStage?: (summary: StageSummary | null) => void;
44
+ }
45
+
46
+ export type StructuralOutcome = ProposalOutcome & { queued?: boolean };
47
+
48
+ const LANE_LABEL: Record<LaneProposal['lane'], string> = {
49
+ html: 'Edit canvas',
50
+ css: 'Edit styles',
51
+ meta: 'Edit layout',
52
+ annotations: 'Edit annotations',
53
+ comments: 'Edit comments',
54
+ };
55
+
56
+ export function createAcceptedLink(opts: AcceptedLinkOptions) {
57
+ const log = opts.log ?? console;
58
+ const client =
59
+ opts.client ??
60
+ createTransactionClient({
61
+ hubUrl: opts.hubUrl,
62
+ token: opts.token,
63
+ designRoot: opts.designRoot,
64
+ fetchImpl: opts.fetchImpl,
65
+ log,
66
+ onPending: opts.onPending,
67
+ onStats: opts.onStats,
68
+ onResult: opts.onResult,
69
+ retryMs: opts.retryMs,
70
+ });
71
+
72
+ let mode: AcceptedMode = 'unknown';
73
+ let manifest: Bootstrap | null = null;
74
+
75
+ // T16 — AI and multi-file action boundaries (see action-stage.ts).
76
+ const stage = createActionStage({
77
+ designRoot: opts.designRoot,
78
+ propose: (action) => client.propose(action),
79
+ newTransactionId: client.newTransactionId,
80
+ onChange: opts.onStage,
81
+ log,
82
+ });
83
+ stage.restore();
84
+
85
+ /**
86
+ * Ask the hub which protocol this project speaks. A network failure keeps
87
+ * the previous verdict — flapping into legacy while the hub is unreachable
88
+ * would let a local write bypass the proposal lane.
89
+ */
90
+ async function refresh(): Promise<Bootstrap | null> {
91
+ try {
92
+ const b = await client.bootstrap();
93
+ const next: AcceptedMode = b.mode === 'transactions' ? 'transactions' : 'legacy';
94
+ if (next !== mode && mode !== 'unknown') {
95
+ log.log(`[sync/tx] project save mode changed: ${mode} → ${next}`);
96
+ }
97
+ mode = next;
98
+ manifest = b;
99
+ return b;
100
+ } catch (err) {
101
+ if (err instanceof TransactionError && err.code === 'absent') mode = 'legacy';
102
+ return null;
103
+ }
104
+ }
105
+
106
+ const on = (): boolean => mode === 'transactions';
107
+
108
+ /**
109
+ * The hub announced a mode change on a document socket. Believed at once —
110
+ * it is the hub's own word, delivered before it closes the socket — and
111
+ * confirmed by the next `refresh()`.
112
+ */
113
+ function noteMode(next: 'transactions' | 'legacy'): void {
114
+ if (next !== mode) log.log(`[sync/tx] the project switched its save mode: ${mode} → ${next}`);
115
+ mode = next;
116
+ }
117
+
118
+ const outcome = (r: ProposalResult): ProposalOutcome => ({
119
+ status: r.status,
120
+ ...(r.code ? { code: r.code } : {}),
121
+ ...(typeof r.head === 'string' ? { head: r.head } : {}),
122
+ ...(typeof r.actionId === 'string' ? { actionId: r.actionId } : {}),
123
+ });
124
+
125
+ function laneLink(slug: string): AcceptedLaneLink {
126
+ return {
127
+ on,
128
+ newTransactionId: client.newTransactionId,
129
+ propose: (p) => {
130
+ const doc = opts.docNameFor(slug);
131
+ if (stage.captures(slug, p.stageable === true)) return stage.capture(slug, doc, p);
132
+ const send = (dependsOn: string[] | undefined) =>
133
+ client
134
+ .propose({
135
+ kind: 'edit',
136
+ label: LANE_LABEL[p.lane],
137
+ transactionId: p.transactionId,
138
+ dependsOn,
139
+ operations: [
140
+ {
141
+ op: 'lane.replace',
142
+ doc,
143
+ lane: p.lane,
144
+ content: p.content,
145
+ baseContent: p.baseContent,
146
+ ...(p.writeId ? { writeId: p.writeId } : {}),
147
+ },
148
+ ],
149
+ })
150
+ .then(outcome);
151
+ if (stage.holdsDependency(p.dependsOn)) {
152
+ // Authored on top of an agent's unpublished bytes: it waits behind
153
+ // the stage, and goes out right after the group (or is discarded
154
+ // with it).
155
+ return new Promise<ProposalOutcome>((resolve, reject) => {
156
+ stage.wait({
157
+ dependsOn: p.dependsOn ?? [],
158
+ send: (d) => void send(d).then(resolve, reject),
159
+ drop: resolve,
160
+ });
161
+ });
162
+ }
163
+ return send(stage.mapDeps(p.dependsOn));
164
+ },
165
+ };
166
+ }
167
+
168
+ /**
169
+ * A structural action, answered within `waitMs` or reported as QUEUED: the
170
+ * proposal stays in the durable outbox and lands when the hub is reachable
171
+ * (the client delivers in creation order, so a later edit cannot overtake
172
+ * it). The caller proceeds with its local change either way — the designer
173
+ * never waits on the network to move a canvas.
174
+ */
175
+ function structural(
176
+ label: string,
177
+ kind: string,
178
+ operations: Operation[],
179
+ waitMs: number
180
+ ): Promise<StructuralOutcome> {
181
+ const answer = client.propose({ kind, label, operations }).then(outcome);
182
+ if (!Number.isFinite(waitMs)) return answer;
183
+ return Promise.race([
184
+ answer,
185
+ new Promise<StructuralOutcome>((resolve) =>
186
+ setTimeout(() => resolve({ status: 'accepted', queued: true }), waitMs)
187
+ ),
188
+ ]);
189
+ }
190
+
191
+ return {
192
+ client,
193
+ refresh,
194
+ on,
195
+ get mode(): AcceptedMode {
196
+ return mode;
197
+ },
198
+ /** The last bootstrap answer — manifest dirs and docs at that revision. */
199
+ get manifest(): Bootstrap | null {
200
+ return manifest;
201
+ },
202
+ laneLink,
203
+ noteMode,
204
+ /** T16 — AI action boundaries. */
205
+ stage,
206
+ createDoc(
207
+ slug: string,
208
+ rel: string,
209
+ lanes: Partial<Record<LaneProposal['lane'], string>>,
210
+ waitMs = Number.POSITIVE_INFINITY
211
+ ): Promise<StructuralOutcome> {
212
+ const clean: Record<string, string> = {};
213
+ for (const [lane, v] of Object.entries(lanes)) if (v) clean[lane] = v;
214
+ return structural(
215
+ 'Create canvas',
216
+ 'canvas.create',
217
+ [{ op: 'doc.create', doc: opts.docNameFor(slug), path: rel, lanes: clean }],
218
+ waitMs
219
+ );
220
+ },
221
+ deleteDoc(slug: string, waitMs = 8_000): Promise<StructuralOutcome> {
222
+ return structural(
223
+ 'Delete canvas',
224
+ 'canvas.delete',
225
+ [{ op: 'doc.delete', doc: opts.docNameFor(slug) }],
226
+ waitMs
227
+ );
228
+ },
229
+ moveDoc(slug: string, toRel: string, waitMs = 8_000): Promise<StructuralOutcome> {
230
+ return structural(
231
+ 'Move canvas',
232
+ 'canvas.move',
233
+ [{ op: 'doc.move', doc: opts.docNameFor(slug), to: { path: toRel } }],
234
+ waitMs
235
+ );
236
+ },
237
+ dirCreate(rel: string, waitMs = 8_000): Promise<StructuralOutcome> {
238
+ return structural(
239
+ 'Create folder',
240
+ 'folder.create',
241
+ [{ op: 'dir.create', path: rel }],
242
+ waitMs
243
+ );
244
+ },
245
+ /** Several folders in ONE action (a cold start publishing local folders). */
246
+ dirsCreate(rels: string[], waitMs = Number.POSITIVE_INFINITY): Promise<StructuralOutcome> {
247
+ return structural(
248
+ rels.length === 1 ? 'Create folder' : `Add ${rels.length} folders`,
249
+ 'folder.create',
250
+ rels.map((path) => ({ op: 'dir.create', path })),
251
+ waitMs
252
+ );
253
+ },
254
+ dirDelete(rel: string, waitMs = 8_000): Promise<StructuralOutcome> {
255
+ return structural(
256
+ 'Delete folder',
257
+ 'folder.delete',
258
+ [{ op: 'dir.delete', path: rel }],
259
+ waitMs
260
+ );
261
+ },
262
+ dirMove(from: string, to: string, waitMs = 8_000): Promise<StructuralOutcome> {
263
+ return structural('Move folder', 'folder.move', [{ op: 'dir.move', from, to }], waitMs);
264
+ },
265
+ /** Logical history, newest first (optionally one entry's). */
266
+ history(q: { limit?: number; before?: number | null; entry?: string | null }) {
267
+ const params: Record<string, string | number> = { limit: q.limit ?? 50 };
268
+ if (q.before) params.before = q.before;
269
+ if (q.entry) params.entry = q.entry;
270
+ return client.read('history', params) as Promise<{ history: HistoryAction[] }>;
271
+ },
272
+ /** A canvas lane as it stood at `revision`. */
273
+ laneAt(slug: string, lane: LaneProposal['lane'], revision: number) {
274
+ return client.read('lane', { doc: opts.docNameFor(slug), lane, rev: revision }) as Promise<{
275
+ body: string;
276
+ hash: string | null;
277
+ }>;
278
+ },
279
+ /** Restore canvases to a revision — a NEW action; nothing is rewound. */
280
+ restore(slugs: string[], revision: number, label: string): Promise<StructuralOutcome> {
281
+ return structural(
282
+ label,
283
+ 'history.restore',
284
+ [{ op: 'history.restore', revision, docs: slugs.map((s) => opts.docNameFor(s)) }],
285
+ 8_000
286
+ );
287
+ },
288
+ /** Personal undo / redo of one of this actor's actions. */
289
+ undo(actionId: string, redo = false): Promise<StructuralOutcome> {
290
+ return structural(
291
+ redo ? 'Redo' : 'Undo',
292
+ redo ? 'redo' : 'undo',
293
+ [{ op: redo ? 'history.redo' : 'history.undo', actionId }],
294
+ 8_000
295
+ );
296
+ },
297
+ stop() {
298
+ client.stop();
299
+ },
300
+ };
301
+ }
302
+
303
+ export interface HistoryAction {
304
+ revision: number;
305
+ actor: string;
306
+ actionId: string;
307
+ kind: string;
308
+ label: string | null;
309
+ committedAt: number;
310
+ undoes: string | null;
311
+ effects: {
312
+ doc: string | null;
313
+ lane: string;
314
+ op: string;
315
+ beforePath: string | null;
316
+ afterPath: string | null;
317
+ }[];
318
+ }
319
+
320
+ export type AcceptedLink = ReturnType<typeof createAcceptedLink>;