@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,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>;
|