@hiveku-apps/sync 0.3.5 → 0.4.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.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Save code files to Hiveku in batches with project_files_bulk_save, and say
3
+ * exactly which ones did not land.
4
+ *
5
+ * On Your site the route writes files one by one and stops at an 85 s wall
6
+ * budget (the edge would otherwise close the request): it answers 207 with
7
+ * `partial: true` and `remaining_paths`, the files it did NOT attempt, which
8
+ * are not in `results[]`. Its own hint: call again with ONLY the files in
9
+ * remaining_paths, never the whole batch. So each batch is sent once, then
10
+ * once more with only what did not land (ok:false results plus
11
+ * remaining_paths); whatever still has not landed is reported as not saved.
12
+ * A file is counted as saved once, however many times it was sent.
13
+ *
14
+ * A branch save answers the branch's new `working_tree_etag`; the last one
15
+ * is returned so the caller can record which tree its push left behind.
16
+ *
17
+ * Builder contract: hiveku_builder feature/versions-core
18
+ * src/app/api/olympus/builder/projects/[projectId]/files-bulk/route.ts (POST).
19
+ */
20
+
21
+ /** Batches of about 25 text files finish inside the route's time budget (the tool's own advice). */
22
+ export const BATCH_FILES = 25;
23
+ /** Well under the route's 20 MB cap; sized for MCP transport reliability. */
24
+ export const BATCH_BYTES = 4_000_000;
25
+ const ATTEMPTS = 2;
26
+
27
+ /** Split files into batches of at most BATCH_FILES files and BATCH_BYTES of content. */
28
+ export function batchFiles(files, { maxFiles = BATCH_FILES, maxBytes = BATCH_BYTES } = {}) {
29
+ const batches = [];
30
+ let batch = [];
31
+ let bytes = 0;
32
+ for (const file of files) {
33
+ const size = String(file.content ?? '').length;
34
+ if (batch.length >= maxFiles || (bytes > 0 && bytes + size > maxBytes)) {
35
+ batches.push(batch);
36
+ batch = [];
37
+ bytes = 0;
38
+ }
39
+ batch.push(file);
40
+ bytes += size;
41
+ }
42
+ if (batch.length) batches.push(batch);
43
+ return batches;
44
+ }
45
+
46
+ /**
47
+ * Read one bulk_save answer for the files that were sent: which landed, which
48
+ * did not, and the branch etag. `results[]` names each file the route tried
49
+ * and `remaining_paths` the ones it did not try. A file the answer does not
50
+ * name counts as landed only when `summary.succeeded` accounts for it (an
51
+ * older answer carries the summary alone), else as not landed.
52
+ */
53
+ export function readSaveAnswer(data, sentPaths) {
54
+ if (!data || typeof data !== 'object') return { landed: [], notLanded: [...sentPaths], etag: null };
55
+ const sent = new Set(sentPaths);
56
+ const results = Array.isArray(data.results) ? data.results.filter((r) => r && sent.has(r.path)) : [];
57
+ const notLanded = new Set([
58
+ ...results.filter((r) => r.ok === false).map((r) => r.path),
59
+ ...(Array.isArray(data.remaining_paths) ? data.remaining_paths.filter((p) => sent.has(p)) : []),
60
+ ]);
61
+ const landed = new Set(results.filter((r) => r.ok !== false && !notLanded.has(r.path)).map((r) => r.path));
62
+ const unnamed = sentPaths.filter((p) => !landed.has(p) && !notLanded.has(p));
63
+ if (unnamed.length) {
64
+ const succeeded = typeof data.summary?.succeeded === 'number' ? data.summary.succeeded : null;
65
+ const restLanded = succeeded === null ? notLanded.size === 0 : succeeded - landed.size >= unnamed.length;
66
+ for (const p of unnamed) (restLanded ? landed : notLanded).add(p);
67
+ }
68
+ const etag = typeof data.working_tree_etag === 'string' && data.working_tree_etag ? data.working_tree_etag : null;
69
+ return { landed: [...landed], notLanded: [...notLanded], etag };
70
+ }
71
+
72
+ /**
73
+ * Save `files` ({ path, content, encoding }) to Your site (branch null) or a
74
+ * branch. Returns { saved, failed: [paths], workingTreeEtag }. Never throws for
75
+ * a refused or failed call: those files are reported in `failed`.
76
+ */
77
+ export async function saveCodeFiles({ client, projectId, files, branch = null, note, log = () => {} }) {
78
+ const savedPaths = new Set();
79
+ const failed = [];
80
+ let workingTreeEtag = null;
81
+ const total = files.length;
82
+ for (const batch of batchFiles(files)) {
83
+ let pending = batch;
84
+ for (let attempt = 1; attempt <= ATTEMPTS && pending.length; attempt++) {
85
+ const sentPaths = pending.map((f) => f.path);
86
+ let data;
87
+ try {
88
+ data = (
89
+ await client.callToolJson('project_files_bulk_save', {
90
+ project_id: projectId,
91
+ files: pending.map((f) => ({ path: f.path, content: f.content, encoding: f.encoding })),
92
+ // A note on each file's row, never a version (the version is saved once, after every file).
93
+ commit_message: note,
94
+ ...(branch ? { branch } : {}),
95
+ })
96
+ )?.data;
97
+ } catch {
98
+ // No answer: nothing is known about this batch, so all of it is sent again.
99
+ continue;
100
+ }
101
+ const answer = readSaveAnswer(data, sentPaths);
102
+ for (const p of answer.landed) savedPaths.add(p);
103
+ if (answer.etag) workingTreeEtag = answer.etag;
104
+ const again = new Set(answer.notLanded);
105
+ pending = pending.filter((f) => again.has(f.path) && !savedPaths.has(f.path));
106
+ }
107
+ failed.push(...pending.map((f) => f.path));
108
+ log(` code ${savedPaths.size}/${total}`);
109
+ }
110
+ return { saved: savedPaths.size, failed, workingTreeEtag };
111
+ }