@1agh/maude 1.4.4 → 1.4.5

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 (34) hide show
  1. package/apps/studio/api.ts +4 -0
  2. package/apps/studio/canvas-comment-mount.tsx +13 -1
  3. package/apps/studio/canvas-shell.tsx +62 -13
  4. package/apps/studio/canvas-source-memo.ts +39 -0
  5. package/apps/studio/canvas-text-patch.ts +117 -0
  6. package/apps/studio/client/app.jsx +105 -60
  7. package/apps/studio/client/file-tree.jsx +157 -0
  8. package/apps/studio/client/index.html +1 -1
  9. package/apps/studio/client/panels/DiffView.jsx +37 -8
  10. package/apps/studio/client/panels/GitPanel.jsx +2 -1
  11. package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
  12. package/apps/studio/client/styles/3-shell-maude.css +21 -9
  13. package/apps/studio/client/styles/4-components.css +4 -2
  14. package/apps/studio/dist/client.bundle.js +1086 -1086
  15. package/apps/studio/dist/comment-mount.js +2 -2
  16. package/apps/studio/dist/styles.css +1 -1
  17. package/apps/studio/hmr-broadcast.ts +59 -3
  18. package/apps/studio/http.ts +3 -0
  19. package/apps/studio/sync/accepted-cold-start.ts +35 -3
  20. package/apps/studio/sync/accepted-link.ts +165 -29
  21. package/apps/studio/sync/file-plane.ts +24 -1
  22. package/apps/studio/sync/index.ts +87 -17
  23. package/apps/studio/sync/journal-client.ts +9 -3
  24. package/apps/studio/sync/projection.ts +114 -11
  25. package/apps/studio/sync/remote-docs.ts +3 -2
  26. package/apps/studio/sync/source-recovery.ts +67 -0
  27. package/apps/studio/sync/status.ts +11 -3
  28. package/apps/studio/sync/transaction-client.ts +49 -4
  29. package/apps/studio/use-selection-set.tsx +9 -3
  30. package/apps/studio/use-undo-stack.tsx +22 -2
  31. package/apps/studio/whats-new.json +9 -0
  32. package/cli/lib/workspace-plan.mjs +10 -0
  33. package/package.json +8 -8
  34. package/plugins/design/templates/_shell.html +21 -0
@@ -24,9 +24,11 @@
24
24
  // loaded via the importmap + Bun.build-produced ESM — there's no React Fast
25
25
  // Refresh runtime to register with. Full-reload is the reliable path.
26
26
 
27
- import { existsSync } from 'node:fs';
27
+ import { existsSync, readFileSync, statSync } from 'node:fs';
28
28
  import path from 'node:path';
29
29
 
30
+ import { recallCanvasBuild } from './canvas-source-memo.ts';
31
+ import { type TextPatch, textOnlyPatches } from './canvas-text-patch.ts';
30
32
  import type { Context } from './context.ts';
31
33
 
32
34
  const DEBOUNCE_MS = 50;
@@ -54,6 +56,12 @@ export interface HmrMessage {
54
56
  * lost race re-applied onto theirs).
55
57
  */
56
58
  remote?: boolean;
59
+ /**
60
+ * F4 — `module` only: the change is nothing but the text of this element
61
+ * (versus the source of the canvas's last build). The iframe may show them at once; the remount it still
62
+ * performs confirms them. Absent whenever anything else changed.
63
+ */
64
+ patches?: TextPatch[];
57
65
  }
58
66
 
59
67
  /** How long after a sync write its file's change still counts as remote. */
@@ -93,6 +101,30 @@ export function createHmrBroadcaster(
93
101
  hard: 3,
94
102
  };
95
103
 
104
+ // Diff the canvas as it is now against the source the iframe renders (its
105
+ // last build). Read at flush, not per event, so a burst diffs end to end.
106
+ // String scanning plus the build's locator — never a parse (see
107
+ // canvas-text-patch.ts: in a cell this is the credential-holding process).
108
+ function withTextPatches(msg: HmrMessage): HmrMessage {
109
+ const root = ctx.paths?.designRoot;
110
+ if (msg.mode !== 'module' || !msg.file?.endsWith('.tsx') || !root) return msg;
111
+ const abs = path.join(root, msg.file);
112
+ let now: string;
113
+ try {
114
+ now = readFileSync(abs, 'utf8');
115
+ } catch {
116
+ return msg;
117
+ }
118
+ const built = recallCanvasBuild(abs);
119
+ if (built === undefined) return msg;
120
+ try {
121
+ const patches = textOnlyPatches(built.source, now, built.locator);
122
+ return patches?.length ? { ...msg, patches } : msg;
123
+ } catch {
124
+ return msg; // the remount still renders it
125
+ }
126
+ }
127
+
96
128
  function flush() {
97
129
  // A pending `hard` supersedes the per-file queue — every open canvas does a
98
130
  // full reload anyway, so the softer messages would be redundant churn.
@@ -100,7 +132,7 @@ export function createHmrBroadcaster(
100
132
  if (hard?.mode === 'hard') {
101
133
  broadcast(hard);
102
134
  } else {
103
- for (const msg of pendingByKey.values()) broadcast(msg);
135
+ for (const msg of pendingByKey.values()) broadcast(withTextPatches(msg));
104
136
  }
105
137
  pendingByKey.clear();
106
138
  pending = null;
@@ -116,6 +148,11 @@ export function createHmrBroadcaster(
116
148
 
117
149
  // Files sync just wrote (see HmrMessage.remote), with the time of the write.
118
150
  const projectedAt = new Map<string, number>();
151
+ // A real watcher and the cell fallback can report the same completed write
152
+ // more than 50 ms apart. A second import invalidates the first even when its
153
+ // bytes are already arriving. Deduplicate disk VERSIONS, never merely paths
154
+ // or a time window: another edit to this file must still reach the canvas.
155
+ const remoteVersions = new Map<string, string>();
119
156
  const isRemote = (rel: string | undefined): boolean => {
120
157
  if (!rel) return false;
121
158
  const at = projectedAt.get(rel);
@@ -146,7 +183,25 @@ export function createHmrBroadcaster(
146
183
  const offAny = ctx.bus.on('fs:any', (rel: string) => {
147
184
  const msg = classify(rel);
148
185
  if (!msg) return;
149
- if (isRemote(rel.replace(/\\/g, '/')) || isRemote(msg.file)) msg.remote = true;
186
+ const normalized = rel.replace(/\\/g, '/');
187
+ if (isRemote(normalized) || isRemote(msg.file)) msg.remote = true;
188
+ if (msg.remote && (msg.mode === 'module' || msg.mode === 'css') && ctx.paths.designRoot) {
189
+ try {
190
+ const s = statSync(path.join(ctx.paths.designRoot, normalized), { bigint: true });
191
+ // Nanosecond ctime also detects a same-size rewrite with restored mtime;
192
+ // inode detects atomic replacement. No source or large media is read.
193
+ const version = `${msg.mode}:${msg.file}:${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}`;
194
+ if (remoteVersions.get(normalized) === version) return;
195
+ remoteVersions.delete(normalized);
196
+ remoteVersions.set(normalized, version);
197
+ if (remoteVersions.size > 256) {
198
+ const oldest = remoteVersions.keys().next().value;
199
+ if (oldest !== undefined) remoteVersions.delete(oldest);
200
+ }
201
+ } catch {
202
+ remoteVersions.delete(normalized); // missing/unreadable: fail open
203
+ }
204
+ } else remoteVersions.delete(normalized);
150
205
  enqueue(msg);
151
206
  });
152
207
 
@@ -157,6 +212,7 @@ export function createHmrBroadcaster(
157
212
  if (pending) clearTimeout(pending);
158
213
  pending = null;
159
214
  pendingByKey.clear();
215
+ remoteVersions.clear();
160
216
  },
161
217
  };
162
218
  }
@@ -34,6 +34,7 @@ import { buildCanvasModule } from './canvas-build.ts';
34
34
  import { buildCanvasSandboxed, buildStats } from './canvas-build-sandbox.ts';
35
35
  import { canvasLibPath } from './canvas-lib-resolver.ts';
36
36
  import { TranspileError } from './canvas-pipeline.ts';
37
+ import { rememberCanvasBuild } from './canvas-source-memo.ts';
37
38
  import { createCloudEndpoints } from './cloud/endpoints.ts';
38
39
  import type { AiActivity } from './collab/ai-activity.ts';
39
40
  import type { Context } from './context.ts';
@@ -687,6 +688,7 @@ export async function serveCanvasTsx(
687
688
  deps,
688
689
  };
689
690
  canvasCache.set(absPath, cached);
691
+ rememberCanvasBuild(absPath, { source, locator: result.locator });
690
692
  await writeLocator(locatorAbsPath, canvasSlug(absPath, ctx.paths.designRoot), result.locator);
691
693
  return respondWithCanvasModule(req, cached);
692
694
  }
@@ -725,6 +727,7 @@ export async function serveCanvasTsx(
725
727
  deps,
726
728
  };
727
729
  canvasCache.set(absPath, cached);
730
+ rememberCanvasBuild(absPath, { source, locator: result.locator });
728
731
  // Persist the locator map. Awaited so the inspector / Phase-12 layers
729
732
  // panel sees a consistent (cdId -> source) view by the time the canvas
730
733
  // mounts. Per-path mutex inside writeLocator() makes concurrent transpiles
@@ -92,6 +92,19 @@ export interface AcceptedColdStartInput {
92
92
  projection: DocProjection;
93
93
  historyDir?: string;
94
94
  journal?: SyncJournal;
95
+ /**
96
+ * This disk's own html proposal a previous run left unanswered, which the
97
+ * outbox drain has just had accepted. The disk was saved on top of it, so it
98
+ * is the base — the recovery slot still holds the value before it.
99
+ */
100
+ ownAccepted?: string | null;
101
+ /**
102
+ * Did the project ever accept exactly this value? A disk with no record of
103
+ * its base that holds a value the store accepted at some revision holds an
104
+ * OLDER accepted state (a checkout restored from a backup — every cloud
105
+ * cell wake), not an edit: it is materialized, never held.
106
+ */
107
+ wasAccepted?: (content: string) => Promise<boolean | null>;
95
108
  createDoc: (lanes: Partial<Record<ProposalLane, string>>) => Promise<{
96
109
  status: 'accepted' | 'rejected';
97
110
  code?: string;
@@ -145,14 +158,27 @@ export async function acceptedColdStart(
145
158
  // An invalid local body cannot be proposed; the projection keeps its bytes
146
159
  // in recovery and reports it when it materializes the accepted source.
147
160
  verdicts.push({ lane: 'html', decision: 'materialize', local: localHtml });
161
+ } else if (i.ownAccepted != null && localHtml === i.ownAccepted) {
162
+ // Disk IS the drained, accepted save; its publication may still be on the
163
+ // way. Nothing to propose, and nothing older to write over it.
164
+ verdicts.push({ lane: 'html', decision: 'agreed', local: localHtml });
165
+ i.projection.adoptOwnAccepted(i.ownAccepted);
148
166
  } else {
149
167
  const d = decideSourceLane({
150
168
  local: localHtml,
151
169
  accepted: acceptedHtml,
152
- knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.html, 'base') : null,
170
+ knownBase:
171
+ i.ownAccepted ??
172
+ (i.historyDir ? readRecoveryBody(i.historyDir, i.paths.html, 'base') : null),
153
173
  baseHash: i.journal?.get(i.slug)?.bodyHash ?? null,
154
174
  });
155
- verdicts.push({ lane: 'html', ...d, ...(localHtml !== null ? { local: localHtml } : {}) });
175
+ const older =
176
+ d.decision === 'hold' && localHtml !== null && (await i.wasAccepted?.(localHtml)) === true;
177
+ verdicts.push({
178
+ lane: 'html',
179
+ ...(older ? { decision: 'materialize' as const } : d),
180
+ ...(localHtml !== null ? { local: localHtml } : {}),
181
+ });
156
182
  }
157
183
 
158
184
  // ---- css — opaque text, journal-checkpointed
@@ -164,7 +190,13 @@ export async function acceptedColdStart(
164
190
  knownBase: i.historyDir ? readRecoveryBody(i.historyDir, i.paths.css, 'base') : null,
165
191
  baseHash: i.journal?.get(i.slug)?.cssHash ?? null,
166
192
  });
167
- verdicts.push({ lane: 'css', ...d, ...(localCss !== null ? { local: localCss } : {}) });
193
+ const older =
194
+ d.decision === 'hold' && localCss !== null && (await i.wasAccepted?.(localCss)) === true;
195
+ verdicts.push({
196
+ lane: 'css',
197
+ ...(older ? { decision: 'materialize' as const } : d),
198
+ ...(localCss !== null ? { local: localCss } : {}),
199
+ });
168
200
  }
169
201
 
170
202
  // ---- meta — the shared layout keys (viewport never travels)
@@ -12,6 +12,9 @@
12
12
  // Nothing here writes a Y.Doc. The documents change when the hub publishes the
13
13
  // accepted revision, through the same providers that deliver a peer's edit.
14
14
 
15
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
+ import path from 'node:path';
17
+
15
18
  import { createActionStage, type StageSummary } from './action-stage.ts';
16
19
  import type { AcceptedLaneLink, LaneProposal, ProposalOutcome } from './projection.ts';
17
20
  import {
@@ -71,13 +74,61 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
71
74
  retryMs: opts.retryMs,
72
75
  });
73
76
 
74
- let mode: AcceptedMode = 'unknown';
77
+ // Every proposal this link sends goes out AFTER the creates queued before it
78
+ // (a lane edit of a canvas being added must never overtake its doc.create on
79
+ // the serialized queue); see `queueCreate` below.
80
+ const propose: TransactionClient['propose'] = (action) => {
81
+ flushCreates();
82
+ // A proposal about a canvas whose batched create is still unanswered waits
83
+ // until that create is settled or re-queued on its own: a refused batch
84
+ // falls back to one create each, and those must stay AHEAD of it.
85
+ const waits = [
86
+ ...new Set(
87
+ action.operations
88
+ .map((op) => (typeof op.doc === 'string' ? createsInFlight.get(op.doc) : undefined))
89
+ .filter((w): w is Promise<void> => !!w)
90
+ ),
91
+ ];
92
+ if (!waits.length) return client.propose(action);
93
+ return Promise.all(waits).then(() => client.propose(action));
94
+ };
95
+
96
+ // THE LAST VERDICT SURVIVES A RESTART (F3/S06). Held only in memory, a
97
+ // desktop that restarted without the network came back 'unknown', treated
98
+ // its offline edits as legacy shared-document writes, and the hub fenced
99
+ // those raw updates: the work never reached the project while the status
100
+ // read "synced". The hub still has the last word — the next bootstrap
101
+ // replaces this — but until it answers, the answer it last gave stands.
102
+ // Keyed by hub, so a relinked copy starts from 'unknown' again.
103
+ const modeFile = path.join(opts.designRoot, '_state', 'accepted-mode.json');
104
+ const loadMode = (): AcceptedMode => {
105
+ try {
106
+ const v = JSON.parse(readFileSync(modeFile, 'utf8'));
107
+ if (v?.v === 1 && v.hub === opts.hubUrl && (v.mode === 'transactions' || v.mode === 'legacy'))
108
+ return v.mode;
109
+ } catch {
110
+ /* absent or unreadable — ask the hub */
111
+ }
112
+ return 'unknown';
113
+ };
114
+ const saveMode = (next: AcceptedMode): void => {
115
+ if (next === 'unknown') return;
116
+ try {
117
+ mkdirSync(path.dirname(modeFile), { recursive: true });
118
+ const tmp = `${modeFile}.tmp`;
119
+ writeFileSync(tmp, JSON.stringify({ v: 1, hub: opts.hubUrl, mode: next, at: Date.now() }));
120
+ renameSync(tmp, modeFile);
121
+ } catch {
122
+ /* the in-memory verdict still holds for this run */
123
+ }
124
+ };
125
+ let mode: AcceptedMode = loadMode();
75
126
  let manifest: Bootstrap | null = null;
76
127
 
77
128
  // T16 — AI and multi-file action boundaries (see action-stage.ts).
78
129
  const stage = createActionStage({
79
130
  designRoot: opts.designRoot,
80
- propose: (action) => client.propose(action),
131
+ propose: (action) => propose(action),
81
132
  newTransactionId: client.newTransactionId,
82
133
  onChange: opts.onStage,
83
134
  log,
@@ -96,12 +147,16 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
96
147
  if (next !== mode && mode !== 'unknown') {
97
148
  log.log(`[sync/tx] project save mode changed: ${mode} → ${next}`);
98
149
  }
150
+ if (next !== mode) saveMode(next);
99
151
  mode = next;
100
152
  manifest = b;
101
153
  opts.onBootstrap?.(b);
102
154
  return b;
103
155
  } catch (err) {
104
- if (err instanceof TransactionError && err.code === 'absent') mode = 'legacy';
156
+ if (err instanceof TransactionError && err.code === 'absent') {
157
+ if (mode !== 'legacy') saveMode('legacy');
158
+ mode = 'legacy';
159
+ }
105
160
  return null;
106
161
  }
107
162
  }
@@ -114,7 +169,10 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
114
169
  * confirmed by the next `refresh()`.
115
170
  */
116
171
  function noteMode(next: 'transactions' | 'legacy'): void {
117
- if (next !== mode) log.log(`[sync/tx] the project switched its save mode: ${mode} → ${next}`);
172
+ if (next !== mode) {
173
+ log.log(`[sync/tx] the project switched its save mode: ${mode} → ${next}`);
174
+ saveMode(next);
175
+ }
118
176
  mode = next;
119
177
  }
120
178
 
@@ -133,24 +191,22 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
133
191
  const doc = opts.docNameFor(slug);
134
192
  if (stage.captures(slug, p.stageable === true)) return stage.capture(slug, doc, p);
135
193
  const send = (dependsOn: string[] | undefined) =>
136
- client
137
- .propose({
138
- kind: 'edit',
139
- label: LANE_LABEL[p.lane],
140
- transactionId: p.transactionId,
141
- dependsOn,
142
- operations: [
143
- {
144
- op: 'lane.replace',
145
- doc,
146
- lane: p.lane,
147
- content: p.content,
148
- baseContent: p.baseContent,
149
- ...(p.writeId ? { writeId: p.writeId } : {}),
150
- },
151
- ],
152
- })
153
- .then(outcome);
194
+ propose({
195
+ kind: 'edit',
196
+ label: LANE_LABEL[p.lane],
197
+ transactionId: p.transactionId,
198
+ dependsOn,
199
+ operations: [
200
+ {
201
+ op: 'lane.replace',
202
+ doc,
203
+ lane: p.lane,
204
+ content: p.content,
205
+ baseContent: p.baseContent,
206
+ ...(p.writeId ? { writeId: p.writeId } : {}),
207
+ },
208
+ ],
209
+ }).then(outcome);
154
210
  if (stage.holdsDependency(p.dependsOn)) {
155
211
  // Authored on top of an agent's unpublished bytes: it waits behind
156
212
  // the stage, and goes out right after the group (or is discarded
@@ -175,13 +231,86 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
175
231
  * it). The caller proceeds with its local change either way — the designer
176
232
  * never waits on the network to move a canvas.
177
233
  */
234
+ // ONE PROPOSAL FOR A BURST OF NEW CANVASES. A fresh link adds every local
235
+ // canvas the project does not have, each from its own cold start, all at
236
+ // once; sent one by one they are that many round trips on the one
237
+ // serialized transaction queue, and the first edit waits behind all of them
238
+ // (F3 S14 on the cloud cell: 77 creates, 44 s). Creates that arrive within a
239
+ // short window travel together; a refused batch falls back to one proposal
240
+ // each, so one canvas the project will not take cannot hold back the rest.
241
+ const CREATE_WINDOW_MS = 25;
242
+ const CREATE_BATCH_MAX = 100;
243
+ const CREATE_BATCH_BYTES = 4 * 1024 * 1024;
244
+ type PendingCreate = { op: Operation; bytes: number; resolve: (o: ProposalOutcome) => void };
245
+ let createBatch: PendingCreate[] = [];
246
+ let createBatchBytes = 0;
247
+ let createTimer: ReturnType<typeof setTimeout> | null = null;
248
+ /** doc → the gate a batched create of it holds until settled or re-queued. */
249
+ const createsInFlight = new Map<string, Promise<void>>();
250
+ const proposeCreates = (ops: Operation[]) =>
251
+ client
252
+ .propose({
253
+ kind: 'canvas.create',
254
+ label: ops.length === 1 ? 'Create canvas' : `Add ${ops.length} canvases`,
255
+ operations: ops,
256
+ })
257
+ .then(outcome);
258
+ function flushCreates(): void {
259
+ if (createTimer) clearTimeout(createTimer);
260
+ createTimer = null;
261
+ const batch = createBatch;
262
+ createBatch = [];
263
+ createBatchBytes = 0;
264
+ if (!batch.length) return;
265
+ const docs = batch.map((c) => c.op.doc).filter((d): d is string => typeof d === 'string');
266
+ let settled: () => void = () => {};
267
+ const gate = new Promise<void>((r) => {
268
+ settled = r;
269
+ });
270
+ for (const d of docs) createsInFlight.set(d, gate);
271
+ const release = () => {
272
+ for (const d of docs) if (createsInFlight.get(d) === gate) createsInFlight.delete(d);
273
+ settled();
274
+ };
275
+ const fail = (err: unknown) => {
276
+ release();
277
+ for (const c of batch)
278
+ c.resolve({ status: 'rejected', code: (err as TransactionError)?.code ?? 'failed' });
279
+ };
280
+ void proposeCreates(batch.map((c) => c.op)).then((o) => {
281
+ if (o.status === 'accepted' || batch.length === 1) {
282
+ release();
283
+ for (const c of batch) c.resolve(o);
284
+ return;
285
+ }
286
+ // Each create is queued (and so ahead of anything waiting on the gate)
287
+ // before the gate opens.
288
+ const each = batch.map((c) => proposeCreates([c.op]));
289
+ release();
290
+ each.forEach((answer, i) => void answer.then(batch[i].resolve, () => batch[i].resolve(o)));
291
+ }, fail);
292
+ }
293
+ function queueCreate(op: Operation): Promise<ProposalOutcome> {
294
+ const bytes = Buffer.byteLength(JSON.stringify(op), 'utf8');
295
+ if (
296
+ createBatch.length &&
297
+ (createBatch.length >= CREATE_BATCH_MAX || createBatchBytes + bytes > CREATE_BATCH_BYTES)
298
+ )
299
+ flushCreates();
300
+ return new Promise((resolve) => {
301
+ createBatch.push({ op, bytes, resolve });
302
+ createBatchBytes += bytes;
303
+ createTimer ??= setTimeout(flushCreates, CREATE_WINDOW_MS);
304
+ });
305
+ }
306
+
178
307
  function structural(
179
308
  label: string,
180
309
  kind: string,
181
310
  operations: Operation[],
182
311
  waitMs: number
183
312
  ): Promise<StructuralOutcome> {
184
- const answer = client.propose({ kind, label, operations }).then(outcome);
313
+ const answer = propose({ kind, label, operations }).then(outcome);
185
314
  if (!Number.isFinite(waitMs)) return answer;
186
315
  return Promise.race([
187
316
  answer,
@@ -214,12 +343,19 @@ export function createAcceptedLink(opts: AcceptedLinkOptions) {
214
343
  ): Promise<StructuralOutcome> {
215
344
  const clean: Record<string, string> = {};
216
345
  for (const [lane, v] of Object.entries(lanes)) if (v) clean[lane] = v;
217
- return structural(
218
- 'Create canvas',
219
- 'canvas.create',
220
- [{ op: 'doc.create', doc: opts.docNameFor(slug), path: rel, lanes: clean }],
221
- waitMs
222
- );
346
+ const answer = queueCreate({
347
+ op: 'doc.create',
348
+ doc: opts.docNameFor(slug),
349
+ path: rel,
350
+ lanes: clean,
351
+ });
352
+ if (!Number.isFinite(waitMs)) return answer;
353
+ return Promise.race([
354
+ answer,
355
+ new Promise<StructuralOutcome>((resolve) =>
356
+ setTimeout(() => resolve({ status: 'accepted', queued: true }), waitMs)
357
+ ),
358
+ ]);
223
359
  },
224
360
  deleteDoc(slug: string, waitMs = 8_000): Promise<StructuralOutcome> {
225
361
  return structural(
@@ -61,6 +61,7 @@ import {
61
61
  statSync,
62
62
  writeFileSync,
63
63
  } from 'node:fs';
64
+ import { open as openAsync } from 'node:fs/promises';
64
65
  import path from 'node:path';
65
66
 
66
67
  import { conflictCopyName, decideFile, type FileState } from './decide-file.ts';
@@ -112,6 +113,28 @@ export function sha256File(abs: string): string {
112
113
  return hash.digest('hex');
113
114
  }
114
115
 
116
+ /**
117
+ * The same digest, yielding to the event loop between chunks. A received video
118
+ * is verified on the path every canvas sync shares: hashing 513 MiB in one
119
+ * synchronous run held a teammate's edit back ~5 s on a receiving peer
120
+ * (F3 S14, 2026-09-23).
121
+ */
122
+ export async function sha256FileAsync(abs: string): Promise<string> {
123
+ const hash = createHash('sha256');
124
+ const buf = Buffer.allocUnsafe(1024 * 1024);
125
+ const fh = await openAsync(abs, 'r');
126
+ try {
127
+ for (;;) {
128
+ const { bytesRead } = await fh.read(buf, 0, buf.length, null);
129
+ if (bytesRead <= 0) break;
130
+ hash.update(bytesRead === buf.length ? buf : buf.subarray(0, bytesRead));
131
+ }
132
+ } finally {
133
+ await fh.close();
134
+ }
135
+ return hash.digest('hex');
136
+ }
137
+
115
138
  /**
116
139
  * What the door accepts, until it tells us otherwise.
117
140
  *
@@ -901,7 +924,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
901
924
  }
902
925
  let got: string;
903
926
  try {
904
- got = sha256File(staged);
927
+ got = await sha256FileAsync(staged);
905
928
  } catch {
906
929
  return { ok: false, reason: 'could not read the downloaded file back' };
907
930
  }