@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
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
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 (
|
|
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
|
|
641
|
-
function
|
|
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
|
-
{
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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?:
|
|
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.
|
|
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
|
|
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.
|
|
1723
|
-
const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got
|
|
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.
|
|
1821
|
-
const landed = await ledger.adoptAfter(rel, remoteHash, () => materialize(rel, got
|
|
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):
|
|
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>();
|