@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,81 @@
1
+ // A bounded metadata read queue. File walks/uploads have their own scheduler.
2
+ // A notification during a read requires another read: the first response may
3
+ // already describe the old head. Keep one pending pass, never an unbounded queue.
4
+ export function createDocumentDiscovery(opts: {
5
+ run: () => Promise<void>;
6
+ onError: (error: unknown) => void;
7
+ delayMs?: number;
8
+ minIntervalMs?: number;
9
+ now?: () => number;
10
+ setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
11
+ clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
12
+ }) {
13
+ const now = opts.now ?? Date.now;
14
+ const setTimer = opts.setTimer ?? setTimeout;
15
+ const clearTimer = opts.clearTimer ?? clearTimeout;
16
+ let timer: ReturnType<typeof setTimeout> | null = null;
17
+ let running = false;
18
+ let pending = false;
19
+ let stopped = false;
20
+ let lastStarted = -Infinity;
21
+ let waiters: Array<() => void> = [];
22
+
23
+ function arm(immediate = false) {
24
+ if (stopped || running || timer !== null || !pending) return;
25
+ const delay = Math.max(
26
+ immediate ? 0 : (opts.delayMs ?? 50),
27
+ (opts.minIntervalMs ?? 250) - (now() - lastStarted)
28
+ );
29
+ timer = setTimer(() => {
30
+ timer = null;
31
+ void drain();
32
+ }, delay);
33
+ timer.unref?.();
34
+ }
35
+
36
+ async function drain() {
37
+ if (stopped || running || !pending) return;
38
+ running = true;
39
+ pending = false;
40
+ lastStarted = now();
41
+ const currentWaiters = waiters;
42
+ waiters = [];
43
+ try {
44
+ await opts.run();
45
+ } catch (error) {
46
+ opts.onError(error);
47
+ } finally {
48
+ running = false;
49
+ for (const resolve of currentWaiters) resolve();
50
+ arm();
51
+ }
52
+ }
53
+
54
+ return {
55
+ schedule() {
56
+ if (!stopped) {
57
+ pending = true;
58
+ arm();
59
+ }
60
+ },
61
+ flush(): Promise<void> {
62
+ if (stopped) return Promise.resolve();
63
+ pending = true;
64
+ const done = new Promise<void>((resolve) => waiters.push(resolve));
65
+ if (timer !== null) {
66
+ clearTimer(timer);
67
+ timer = null;
68
+ }
69
+ arm(true);
70
+ return done;
71
+ },
72
+ stop() {
73
+ stopped = true;
74
+ pending = false;
75
+ if (timer !== null) clearTimer(timer);
76
+ timer = null;
77
+ for (const resolve of waiters) resolve();
78
+ waiters = [];
79
+ },
80
+ };
81
+ }
@@ -105,7 +105,7 @@ const INERT_MEDIA_EXTS = new Set([
105
105
  'ttf',
106
106
  'otf',
107
107
  ]);
108
- const COMPANION_TEXT_EXTS = new Set(['css', 'md', 'srt']);
108
+ const COMPANION_TEXT_EXTS = new Set(['css', 'md', 'srt', 'vtt']);
109
109
  const CODE_MODULE_EXTS = new Set(['ts', 'tsx', 'js', 'mjs']);
110
110
 
111
111
  /**
@@ -118,7 +118,15 @@ const CODE_MODULE_EXTS = new Set(['ts', 'tsx', 'js', 'mjs']);
118
118
  * machine silently loses every photo edit — the exact bug class this module
119
119
  * exists to end.
120
120
  */
121
- const COMPANION_SIDECAR_SUFFIXES = ['.photo.json', '.audio.json'];
121
+ // + the footage analysis, component registry, edit decision list: versioned
122
+ // sidecars (DDR-115) the T32 scale run found never reaching a peer.
123
+ const COMPANION_SIDECAR_SUFFIXES = [
124
+ '.photo.json',
125
+ '.audio.json',
126
+ '.footage.json',
127
+ '.registry.json',
128
+ '.edl.json',
129
+ ];
122
130
 
123
131
  /**
124
132
  * A DIRECTORY segment must start alphanumeric — the same rule as the hub's
@@ -46,13 +46,18 @@
46
46
 
47
47
  import { createHash } from 'node:crypto';
48
48
  import {
49
+ appendFileSync,
50
+ closeSync,
49
51
  type Dirent,
50
52
  existsSync,
51
53
  mkdirSync,
54
+ openSync,
52
55
  readdirSync,
53
56
  readFileSync,
57
+ readSync,
54
58
  realpathSync,
55
59
  renameSync,
60
+ rmSync,
56
61
  statSync,
57
62
  writeFileSync,
58
63
  } from 'node:fs';
@@ -77,8 +82,35 @@ const PUT_TIMEOUT_MS = 120_000;
77
82
 
78
83
  /** Refuse an implausible body rather than streaming it to disk. This is the
79
84
  * PULL/receive cap and is deliberately generous — it bounds what we will
80
- * accept, not what a hub will. */
81
- const MAX_FILE_BYTES = 512 * 1024 * 1024;
85
+ * accept, not what a hub will. Plan T18: the project-file ceiling the hub
86
+ * uses too (`apps/hub/src/file-limits.mjs`), so a real video moves. */
87
+ export const MAX_FILE_BYTES = (() => {
88
+ const raw = Number(process.env.MAUDE_MAX_PROJECT_FILE_BYTES);
89
+ return Number.isFinite(raw) && raw >= 95 * 1024 * 1024
90
+ ? Math.min(raw, 8192 * 1024 * 1024)
91
+ : 2048 * 1024 * 1024;
92
+ })();
93
+
94
+ /** T18 — above this a download streams to disk (and resumes) instead of
95
+ * being held in memory. */
96
+ export const STREAM_DOWNLOAD_ABOVE_BYTES = 32 * 1024 * 1024;
97
+
98
+ /** sha256 of a file, read in 1 MiB chunks (T18 — a video is never held whole). */
99
+ export function sha256File(abs: string): string {
100
+ const hash = createHash('sha256');
101
+ const buf = Buffer.allocUnsafe(1024 * 1024);
102
+ const fd = openSync(abs, 'r');
103
+ try {
104
+ for (;;) {
105
+ const n = readSync(fd, buf, 0, buf.length, null);
106
+ if (n <= 0) break;
107
+ hash.update(n === buf.length ? buf : buf.subarray(0, n));
108
+ }
109
+ } finally {
110
+ closeSync(fd);
111
+ }
112
+ return hash.digest('hex');
113
+ }
82
114
 
83
115
  /**
84
116
  * What the door accepts, until it tells us otherwise.
@@ -112,6 +144,8 @@ export const MIN_TRUSTED_MAX_FILE_BYTES = 1024 * 1024;
112
144
  * relative to the hub's hourly quota window, so a reading can never strand a
113
145
  * client for the rest of its life. */
114
146
  export const HUB_LIMITS_TTL_MS = 10 * 60_000;
147
+ /** A failed limits read is asked again this soon, not after the full TTL. */
148
+ export const HUB_LIMITS_RETRY_MS = 30_000;
115
149
  export const MAX_TRUSTED_QUOTA_PAUSE_MS = 2 * 60 * 60_000;
116
150
 
117
151
  /** How many files one pass will move. The remainder is the next pass's work. */
@@ -390,15 +424,35 @@ export interface FilePlaneOptions {
390
424
  onProgress?: () => void;
391
425
  }
392
426
 
393
- interface LocalFile {
427
+ interface SyncableLocalFile {
394
428
  rel: string;
395
429
  abs: string;
396
430
  hash: string;
397
431
  size: number;
398
432
  mtimeMs: number;
399
433
  cls: FileClass;
434
+ oversized?: undefined;
400
435
  }
401
436
 
437
+ /**
438
+ * Present here, eligible, and over the plane's ceiling (audit 2026-09-13 P1
439
+ * #4). It is IN the inventory — absent from it, it was indistinguishable from
440
+ * a local delete (a grown asset was deleted on the hub) and from a missing
441
+ * local copy (a hub copy overwrote it). Never hashed: reading half a gigabyte
442
+ * to label a row it cannot move is the cost the ceiling exists to avoid.
443
+ */
444
+ interface OversizedLocalFile {
445
+ rel: string;
446
+ abs: string;
447
+ hash: null;
448
+ size: number;
449
+ mtimeMs: number;
450
+ cls: FileClass;
451
+ oversized: true;
452
+ }
453
+
454
+ type LocalFile = SyncableLocalFile | OversizedLocalFile;
455
+
402
456
  const sha256 = (bytes: Uint8Array | string): string =>
403
457
  createHash('sha256').update(bytes).digest('hex');
404
458
 
@@ -441,7 +495,6 @@ export function scanLocalFiles(
441
495
  } catch {
442
496
  continue;
443
497
  }
444
- if (st.size > MAX_FILE_BYTES) continue;
445
498
  found.push({ rel: childRel, abs: childAbs, size: st.size, mtimeMs: st.mtimeMs });
446
499
  }
447
500
  };
@@ -456,10 +509,14 @@ export function scanLocalFiles(
456
509
  for (const f of found) {
457
510
  const cls = classifyProjectFile(f.rel, opts);
458
511
  if (!isFilePlaneClass(cls)) continue;
512
+ if (f.size > MAX_FILE_BYTES) {
513
+ out.set(f.rel, { ...f, cls, hash: null, oversized: true });
514
+ continue;
515
+ }
459
516
  let hash = ledger.cachedHash(f.rel, f.size, f.mtimeMs);
460
517
  if (hash === null) {
461
518
  try {
462
- hash = sha256(readFileSync(f.abs));
519
+ hash = sha256File(f.abs);
463
520
  } catch {
464
521
  continue;
465
522
  }
@@ -578,6 +635,9 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
578
635
  quotaResetsAt?: number;
579
636
  quotaUsed?: number;
580
637
  quotaBytesPerWindow?: number;
638
+ /** T18 — the hub takes resumable upload sessions up to this size. */
639
+ maxSessionBytes?: number;
640
+ partBytes?: number;
581
641
  } | null = null;
582
642
  /** When the limits were last learned. 0 = never. */
583
643
  let hubLimitsAt = 0;
@@ -607,12 +667,26 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
607
667
  headers: auth(),
608
668
  signal: AbortSignal.timeout(GET_TIMEOUT_MS),
609
669
  });
610
- if (!res.ok) return;
670
+ if (res.status === 404) {
671
+ // A hub older than this route: its door takes one request per file
672
+ // and nothing more, which IS an answer.
673
+ hubLimits = { maxFileBytes: DEFAULT_HUB_MAX_FILE_BYTES };
674
+ return;
675
+ }
676
+ if (!res.ok) {
677
+ // Not an answer (a 429 behind a shared address, a 5xx): ask again
678
+ // soon instead of living on the fallback ceiling for the whole TTL.
679
+ hubLimitsAt = now() - HUB_LIMITS_TTL_MS + HUB_LIMITS_RETRY_MS;
680
+ return;
681
+ }
611
682
  const body = (await res.json()) as {
612
683
  maxFileBytes?: unknown;
613
684
  quotaResetsAt?: unknown;
614
685
  quotaUsed?: unknown;
615
686
  quotaBytesPerWindow?: unknown;
687
+ uploadSessions?: unknown;
688
+ maxSessionBytes?: unknown;
689
+ partBytes?: unknown;
616
690
  };
617
691
  if (
618
692
  Number.isFinite(body?.maxFileBytes) &&
@@ -630,18 +704,35 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
630
704
  ...(Number.isFinite(body?.quotaBytesPerWindow)
631
705
  ? { quotaBytesPerWindow: body.quotaBytesPerWindow as number }
632
706
  : {}),
707
+ // T18 — sessions, clamped like every other hub-supplied lever: never
708
+ // past our own receive cap, parts between 1 and 64 MiB.
709
+ ...(body?.uploadSessions === true && Number.isFinite(body?.maxSessionBytes)
710
+ ? {
711
+ maxSessionBytes: Math.min(body.maxSessionBytes as number, MAX_FILE_BYTES),
712
+ partBytes: Math.min(
713
+ Math.max(Number(body.partBytes) || 8 * 1024 * 1024, 1024 * 1024),
714
+ 64 * 1024 * 1024
715
+ ),
716
+ }
717
+ : {}),
633
718
  };
634
719
  }
635
720
  } catch {
636
- /* the fallback is the pre-existing behaviour */
721
+ /* the fallback is the pre-existing behaviour — and it is asked again soon */
722
+ hubLimitsAt = now() - HUB_LIMITS_TTL_MS + HUB_LIMITS_RETRY_MS;
637
723
  }
638
724
  }
639
725
 
640
- /** The push ceiling in force right now. */
641
- function pushCeiling(): number {
726
+ /** The single-PUT ceiling in force right now. */
727
+ function singlePutCeiling(): number {
642
728
  return hubLimits?.maxFileBytes ?? DEFAULT_HUB_MAX_FILE_BYTES;
643
729
  }
644
730
 
731
+ /** The push ceiling in force right now — sessions raise it (T18). */
732
+ function pushCeiling(): number {
733
+ return Math.max(singlePutCeiling(), hubLimits?.maxSessionBytes ?? 0);
734
+ }
735
+
645
736
  /**
646
737
  * Bytes still available inside the hub's current write window, or null when
647
738
  * the hub did not say.
@@ -720,20 +811,38 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
720
811
  expectHash: string,
721
812
  ceiling: number
722
813
  ): Promise<
723
- | { ok: true; bytes: Uint8Array }
814
+ | { ok: true; bytes: Uint8Array; file?: undefined; size: number }
815
+ | { ok: true; file: string; bytes?: undefined; size: number }
724
816
  | { ok: false; reason: string; overCap?: true; rateLimited?: true }
725
817
  > {
726
818
  let res: Response;
727
819
  requestsThisPass += 1;
820
+ // T18 — a partial download of these exact bytes resumes where it stopped.
821
+ const staged = path.join(designRoot, '_state', 'downloads', `${expectHash}.part`);
822
+ let resumeFrom = 0;
823
+ try {
824
+ resumeFrom = statSync(staged).size;
825
+ } catch {
826
+ /* nothing staged */
827
+ }
728
828
  try {
729
829
  res = await fetchImpl(
730
830
  `${base}/_project-file/${rel.split('/').map(encodeURIComponent).join('/')}`,
731
- { headers: auth(), signal: AbortSignal.timeout(GET_TIMEOUT_MS) }
831
+ {
832
+ headers: { ...auth(), ...(resumeFrom > 0 ? { range: `bytes=${resumeFrom}-` } : {}) },
833
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
834
+ }
732
835
  );
733
836
  } catch (err) {
734
837
  return { ok: false, reason: transportFailure(err, rel, log) };
735
838
  }
839
+ if (res.status === 416) {
840
+ // The staged part is not a prefix of what the hub holds now: start over.
841
+ rmSync(staged, { force: true });
842
+ return { ok: false, reason: 'the partial download no longer matches — restarting it' };
843
+ }
736
844
  if (!res.ok) return await refusal(res);
845
+ const resumed = res.status === 206 && resumeFrom > 0;
737
846
 
738
847
  // The cap is consulted BEFORE the body exists, and again as it arrives.
739
848
  // Buffering first and measuring after is not a cap at all: a hub answering
@@ -750,6 +859,49 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
750
859
  };
751
860
  }
752
861
 
862
+ // T18 — a large body streams to the staging file (appending after a
863
+ // resume) and is verified from disk; memory stays one chunk.
864
+ if (resumed || (Number.isFinite(declared) && declared > STREAM_DOWNLOAD_ABOVE_BYTES)) {
865
+ if (!resumed) rmSync(staged, { force: true });
866
+ mkdirSync(path.dirname(staged), { recursive: true });
867
+ const body = res.body;
868
+ if (!body?.getReader) return { ok: false, reason: 'no response stream' };
869
+ const reader = body.getReader();
870
+ let total = resumed ? resumeFrom : 0;
871
+ try {
872
+ for (;;) {
873
+ const { done, value } = await reader.read();
874
+ if (done) break;
875
+ if (!value) continue;
876
+ total += value.byteLength;
877
+ if (total > cap) {
878
+ await reader.cancel().catch(() => {});
879
+ rmSync(staged, { force: true });
880
+ return {
881
+ ok: false,
882
+ reason: `over the cap mid-stream (${total} B > ${cap} B)`,
883
+ overCap: true,
884
+ };
885
+ }
886
+ appendFileSync(staged, value);
887
+ }
888
+ } catch (err) {
889
+ // What arrived stays staged: the next pass asks for the rest.
890
+ return { ok: false, reason: transportFailure(err, '<response body>', log) };
891
+ }
892
+ let got: string;
893
+ try {
894
+ got = sha256File(staged);
895
+ } catch {
896
+ return { ok: false, reason: 'could not read the downloaded file back' };
897
+ }
898
+ if (got !== expectHash) {
899
+ rmSync(staged, { force: true });
900
+ return { ok: false, reason: 'content hash mismatch (racing a write?)' };
901
+ }
902
+ return { ok: true, file: staged, size: total };
903
+ }
904
+
753
905
  const bytes = await readCapped(res, cap);
754
906
  if (!bytes.ok) return bytes;
755
907
  // The hub may REFUSE to serve; it must never be able to SUBSTITUTE.
@@ -757,7 +909,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
757
909
  if (got !== expectHash) {
758
910
  return { ok: false, reason: 'content hash mismatch (racing a write?)' };
759
911
  }
760
- return { ok: true, bytes: bytes.bytes };
912
+ return { ok: true, bytes: bytes.bytes, size: bytes.bytes.byteLength };
761
913
  }
762
914
 
763
915
  /**
@@ -931,13 +1083,18 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
931
1083
  return isFilePlaneClass(cls);
932
1084
  }
933
1085
 
934
- /** Land bytes at `rel`, atomically. Throws on failure — `adoptAfter` catches. */
935
- function materialize(rel: string, bytes: Uint8Array): void {
1086
+ /** Land bytes at `rel`, atomically. Throws on failure — `adoptAfter` catches.
1087
+ * A streamed download (T18) is moved into place from its staging file. */
1088
+ function materialize(rel: string, payload: { bytes?: Uint8Array; file?: string }): void {
936
1089
  const target = safeTarget(rel);
937
1090
  if (!target) throw new Error(`refusing to write ${rel} — it does not resolve inside the root`);
938
1091
  mkdirSync(target.parent, { recursive: true });
1092
+ if (payload.file) {
1093
+ renameSync(payload.file, target.abs);
1094
+ return;
1095
+ }
939
1096
  const tmp = `${target.abs}.part`;
940
- writeFileSync(tmp, bytes);
1097
+ writeFileSync(tmp, payload.bytes ?? new Uint8Array());
941
1098
  renameSync(tmp, target.abs);
942
1099
  }
943
1100
 
@@ -964,7 +1121,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
964
1121
  * Upload one file with a compare-and-swap against the state we decided from.
965
1122
  */
966
1123
  async function push(
967
- local: LocalFile,
1124
+ local: SyncableLocalFile,
968
1125
  expect: string | null
969
1126
  ): Promise<
970
1127
  | { ok: true; seq: number | null }
@@ -997,6 +1154,12 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
997
1154
  ...(hubLimits?.quotaResetsAt ? { quotaResetsAt: hubLimits.quotaResetsAt } : {}),
998
1155
  };
999
1156
  }
1157
+ if (local.size > pushCeiling() && hubLimits === null) {
1158
+ // The hub has not said what it takes (its answer was refused or lost):
1159
+ // over the fallback ceiling is unknown, not too large. Retry; never
1160
+ // park a file as terminally refused on a guess.
1161
+ return { ok: false, reason: 'Waiting for the workspace to say how large a file it accepts' };
1162
+ }
1000
1163
  if (local.size > pushCeiling()) {
1001
1164
  return {
1002
1165
  ok: false,
@@ -1004,6 +1167,8 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1004
1167
  reason: `Too big for this workspace — ${mb(local.size)}, and the limit is ${mb(pushCeiling())}`,
1005
1168
  };
1006
1169
  }
1170
+ // T18 — past one request: a resumable session, in verified parts.
1171
+ if (local.size > singlePutCeiling()) return pushSession(local, expect);
1007
1172
  let bytes: Uint8Array;
1008
1173
  try {
1009
1174
  bytes = readFileSync(local.abs);
@@ -1053,6 +1218,104 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1053
1218
  }
1054
1219
  }
1055
1220
 
1221
+ /**
1222
+ * T18 — upload a large file as a resumable session. The hub makes creation
1223
+ * idempotent for the same (person, path, bytes), so a restarted app resumes
1224
+ * by asking again and sending only the parts the hub does not hold. Memory
1225
+ * is one part; the whole object is verified by the hub before it lands.
1226
+ */
1227
+ async function pushSession(
1228
+ local: SyncableLocalFile,
1229
+ expect: string | null
1230
+ ): Promise<Awaited<ReturnType<typeof push>>> {
1231
+ const json = async (res: Response) =>
1232
+ (await res.json().catch(() => ({}))) as Record<string, unknown>;
1233
+ ledger.outboxAdd(local.hash);
1234
+ requestsThisPass += 1;
1235
+ try {
1236
+ const created = await fetchImpl(`${base}/api/file-uploads`, {
1237
+ method: 'POST',
1238
+ headers: { ...auth(), 'content-type': 'application/json' },
1239
+ body: JSON.stringify({
1240
+ path: local.rel,
1241
+ size: local.size,
1242
+ sha256: local.hash,
1243
+ expectHash: expect ?? 'none',
1244
+ }),
1245
+ signal: AbortSignal.timeout(GET_TIMEOUT_MS),
1246
+ });
1247
+ if (!created.ok) return await refusal(created);
1248
+ const s = await json(created);
1249
+ const id = typeof s.id === 'string' ? s.id : '';
1250
+ const partBytes = Number(s.partBytes);
1251
+ const parts = Number(s.parts);
1252
+ if (!/^up_[0-9a-f]{32}$/.test(id) || !(partBytes > 0) || !(parts > 0)) {
1253
+ return { ok: false, reason: 'The workspace answered the upload with nonsense' };
1254
+ }
1255
+ const have = new Set(Array.isArray(s.received) ? (s.received as number[]) : []);
1256
+ const buf = Buffer.allocUnsafe(partBytes);
1257
+ const fd = openSync(local.abs, 'r');
1258
+ try {
1259
+ for (let n = 0; n < parts; n += 1) {
1260
+ if (have.has(n)) continue;
1261
+ const len = Math.min(partBytes, local.size - n * partBytes);
1262
+ const read = readSync(fd, buf, 0, len, n * partBytes);
1263
+ if (read !== len) return { ok: false, reason: 'The file changed while it was uploading' };
1264
+ const part = Buffer.from(buf.subarray(0, len));
1265
+ let res: Response | null = null;
1266
+ let lastErr: unknown = null;
1267
+ for (let attempt = 0; attempt < 3 && !res?.ok; attempt += 1) {
1268
+ try {
1269
+ res = await fetchImpl(`${base}/api/file-uploads/${id}/${n}`, {
1270
+ method: 'PUT',
1271
+ headers: {
1272
+ ...auth(),
1273
+ 'content-type': 'application/octet-stream',
1274
+ 'x-maude-part-sha256': sha256(part),
1275
+ },
1276
+ body: part as unknown as BodyInit,
1277
+ signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
1278
+ });
1279
+ if (res.status === 429 || res.status === 507) return await refusal(res);
1280
+ } catch (err) {
1281
+ lastErr = err;
1282
+ res = null;
1283
+ }
1284
+ }
1285
+ if (!res) return { ok: false, reason: transportFailure(lastErr, local.rel, log) };
1286
+ if (!res.ok) return await refusal(res);
1287
+ }
1288
+ } finally {
1289
+ closeSync(fd);
1290
+ }
1291
+ const done = await fetchImpl(`${base}/api/file-uploads/${id}/complete`, {
1292
+ method: 'POST',
1293
+ headers: auth(),
1294
+ signal: AbortSignal.timeout(PUT_TIMEOUT_MS),
1295
+ });
1296
+ if (done.status === 409) {
1297
+ const body = await json(done);
1298
+ if (Array.isArray(body.received)) {
1299
+ // Parts the hub lost between our PUTs and the completion: the next
1300
+ // pass resumes and sends exactly those.
1301
+ return { ok: false, reason: 'Upload interrupted — it resumes on the next pass' };
1302
+ }
1303
+ return {
1304
+ ok: false,
1305
+ conflict: true,
1306
+ current: typeof body.current === 'string' ? body.current : null,
1307
+ };
1308
+ }
1309
+ if (!done.ok) return await refusal(done);
1310
+ const body = await json(done);
1311
+ return { ok: true, seq: typeof body.seq === 'number' ? body.seq : null };
1312
+ } catch (err) {
1313
+ return { ok: false, reason: transportFailure(err, local.rel, log) };
1314
+ } finally {
1315
+ ledger.outboxDone(local.hash);
1316
+ }
1317
+ }
1318
+
1056
1319
  /**
1057
1320
  * Tell the hub a file is gone here — Increment 6.
1058
1321
  *
@@ -1316,7 +1579,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1316
1579
  let work: {
1317
1580
  rel: string;
1318
1581
  decision: ReturnType<typeof decideFile>;
1319
- local: LocalFile | undefined;
1582
+ local: SyncableLocalFile | undefined;
1320
1583
  row: JournalEntry | undefined;
1321
1584
  remoteHash: string | null;
1322
1585
  }[] = [];
@@ -1331,6 +1594,12 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1331
1594
  for (const rel of paths) {
1332
1595
  const row = delta.get(rel);
1333
1596
  const here = local.get(rel);
1597
+ // HELD, NOT ABSENT. A file this peer cannot move is still a file: no
1598
+ // decision may read it as deleted here or missing here.
1599
+ if (here?.oversized) {
1600
+ holdOversized(out, ledger, here);
1601
+ continue;
1602
+ }
1334
1603
  // What the hub holds: this page when it spoke about the path, otherwise
1335
1604
  // what we last learned. `undefined` (never learned) reads as null only
1336
1605
  // after a full read has had the chance to say so.
@@ -1500,7 +1769,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1500
1769
  // Referenced assets first (DDR-223's strokes→bytes coupling): a picture a
1501
1770
  // just-arrived annotation points at is the one a person is staring at.
1502
1771
  const referenced = referencedAssetNames(designRoot);
1503
- work.sort((a, b) => rank(a.rel, referenced) - rank(b.rel, referenced));
1772
+ // …then smallest first (T19): one 1 GB master never makes the forty
1773
+ // images a canvas needs wait behind it; big media fills in last.
1774
+ const sizeOf = (w: (typeof work)[number]) => Math.max(claimedSize(w.row), w.local?.size ?? 0);
1775
+ work.sort((a, b) => rank(a.rel, referenced) - rank(b.rel, referenced) || sizeOf(a) - sizeOf(b));
1504
1776
 
1505
1777
  const budget = createPullBudget({
1506
1778
  label: 'sync/files',
@@ -1629,7 +1901,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1629
1901
  item: {
1630
1902
  rel: string;
1631
1903
  decision: ReturnType<typeof decideFile>;
1632
- local?: LocalFile;
1904
+ local?: SyncableLocalFile;
1633
1905
  row?: JournalEntry;
1634
1906
  remoteHash: string | null;
1635
1907
  },
@@ -1676,10 +1948,10 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1676
1948
  }
1677
1949
  return false;
1678
1950
  }
1679
- if (!chargeOverrun(budget, rel, got.bytes.byteLength, claim, out)) return false;
1951
+ if (!chargeOverrun(budget, rel, got.size, claim, out)) return false;
1680
1952
  const copyRel = conflictCopyName(rel, now(), 'hub');
1681
1953
  try {
1682
- materialize(copyRel, got.bytes);
1954
+ materialize(copyRel, got);
1683
1955
  ledger.setState(rel, 'conflict', {
1684
1956
  reason: 'the hub’s log restarted; their copy is parked beside yours',
1685
1957
  conflictCopy: copyRel,
@@ -1719,8 +1991,8 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1719
1991
  }
1720
1992
  return false;
1721
1993
  }
1722
- if (!chargeOverrun(budget, rel, got.bytes.byteLength, claim, out)) return false;
1723
- const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got.bytes), {
1994
+ if (!chargeOverrun(budget, rel, got.size, claim, out)) return false;
1995
+ const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got), {
1724
1996
  ...(row ? { remoteSeq: row.seq } : {}),
1725
1997
  state: 'on-hub',
1726
1998
  });
@@ -1817,8 +2089,8 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1817
2089
  }
1818
2090
  return false;
1819
2091
  }
1820
- if (!chargeOverrun(budget, rel, got.bytes.byteLength, claim, out)) return false;
1821
- const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got.bytes), {
2092
+ if (!chargeOverrun(budget, rel, got.size, claim, out)) return false;
2093
+ const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got), {
1822
2094
  ...(row ? { remoteSeq: row.seq } : {}),
1823
2095
  state: 'conflict',
1824
2096
  });
@@ -1905,7 +2177,7 @@ export function createFilePlane(opts: FilePlaneOptions): FilePlane {
1905
2177
  }
1906
2178
  }
1907
2179
 
1908
- function statLocal(rel: string): LocalFile | null {
2180
+ function statLocal(rel: string): SyncableLocalFile | null {
1909
2181
  const abs = path.join(designRoot, rel);
1910
2182
  try {
1911
2183
  const st = statSync(abs);
@@ -1953,6 +2225,17 @@ function drop(out: FilePlaneResult, rel: string, reason: string, ledger?: FileLe
1953
2225
  ledger?.setState(rel, 'stuck', { reason });
1954
2226
  }
1955
2227
 
2228
+ function holdOversized(out: FilePlaneResult, ledger: FileLedger, file: OversizedLocalFile): void {
2229
+ const mb = (n: number) => Math.round(n / (1024 * 1024));
2230
+ const reason = `too big to sync — ${mb(file.size)} MB is over the ${mb(MAX_FILE_BYTES)} MB limit`;
2231
+ out.dropped.push({ rel: file.rel, reason });
2232
+ const row = ledger.row(file.rel);
2233
+ // Unchanged rows stay unwritten: this runs every pass for as long as the
2234
+ // file stays too big, and the ledger flushes on every setState.
2235
+ if (row?.state === 'refused' && row.blockedClass === 'too-large' && row.reason === reason) return;
2236
+ ledger.setState(file.rel, 'refused', { reason, blockedClass: 'too-large' });
2237
+ }
2238
+
1956
2239
  /** `assets/<name>` referenced anywhere in the tree — the priority front-queue. */
1957
2240
  function referencedAssetNames(designRoot: string): Set<string> {
1958
2241
  const out = new Set<string>();