@1agh/maude 1.2.0 → 1.3.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 +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 +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- 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 +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- 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 +35 -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/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 +1586 -1554
- 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/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -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 +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -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 +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -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/whats-new.json +101 -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 +47 -0
- package/plugins/flow/dependencies.json +3 -3
|
@@ -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>;
|
|
@@ -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>;
|