@nyte-ai/host 0.0.0-stage → 0.0.19

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 (250) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/THIRD-PARTY-NOTICES.md +866 -0
  4. package/dist/chunks/canvas-preview-0cmbkp2j.js +65 -0
  5. package/dist/chunks/canvas-preview-0nq75h9k.js +7 -0
  6. package/dist/chunks/canvas-preview-11nadadc.js +241 -0
  7. package/dist/chunks/canvas-preview-1sq7z9jq.js +17 -0
  8. package/dist/chunks/canvas-preview-2jwbe14k.js +130 -0
  9. package/dist/chunks/canvas-preview-2zdyh9ny.js +26 -0
  10. package/dist/chunks/canvas-preview-31w7xm8z.js +34 -0
  11. package/dist/chunks/canvas-preview-4b6sayhg.js +295 -0
  12. package/dist/chunks/canvas-preview-4e0pxa32.js +63 -0
  13. package/dist/chunks/canvas-preview-518dh8by.js +169 -0
  14. package/dist/chunks/canvas-preview-55s0y68g.js +387 -0
  15. package/dist/chunks/canvas-preview-5ekjx6sw.js +613 -0
  16. package/dist/chunks/canvas-preview-5mex725c.js +14 -0
  17. package/dist/chunks/canvas-preview-5z63am63.js +295 -0
  18. package/dist/chunks/canvas-preview-65xqjw7f.js +20 -0
  19. package/dist/chunks/canvas-preview-69fkk9yn.js +95 -0
  20. package/dist/chunks/canvas-preview-8xc0vn7v.js +458 -0
  21. package/dist/chunks/canvas-preview-9aqczacs.js +73 -0
  22. package/dist/chunks/canvas-preview-af86jz8t.js +141 -0
  23. package/dist/chunks/canvas-preview-aq5j4p25.js +240 -0
  24. package/dist/chunks/canvas-preview-b2v8wfnz.js +36 -0
  25. package/dist/chunks/canvas-preview-befkjea2.js +60 -0
  26. package/dist/chunks/canvas-preview-bxnqt8cd.js +103 -0
  27. package/dist/chunks/canvas-preview-cawy6vbz.js +352 -0
  28. package/dist/chunks/canvas-preview-crmzxxt8.js +518 -0
  29. package/dist/chunks/canvas-preview-drctegz4.js +591 -0
  30. package/dist/chunks/canvas-preview-e3vnkkc5.js +134 -0
  31. package/dist/chunks/canvas-preview-ec2mt7es.js +108 -0
  32. package/dist/chunks/canvas-preview-fxa0rfb0.js +61 -0
  33. package/dist/chunks/canvas-preview-g5fkjs34.js +598 -0
  34. package/dist/chunks/canvas-preview-jeddjex8.js +166 -0
  35. package/dist/chunks/canvas-preview-k1yg4fgp.js +571 -0
  36. package/dist/chunks/canvas-preview-kdqgrstc.js +194 -0
  37. package/dist/chunks/canvas-preview-kn7kvbfj.js +12 -0
  38. package/dist/chunks/canvas-preview-kp75gb63.js +141 -0
  39. package/dist/chunks/canvas-preview-mgcx2b2p.js +271 -0
  40. package/dist/chunks/canvas-preview-n2a35zv5.js +81 -0
  41. package/dist/chunks/canvas-preview-pkzqg96v.js +63 -0
  42. package/dist/chunks/canvas-preview-rp4x6y1h.js +144 -0
  43. package/dist/chunks/canvas-preview-s94ebwf5.js +112 -0
  44. package/dist/chunks/canvas-preview-skzwvw4d.js +23 -0
  45. package/dist/chunks/canvas-preview-srgfpdmf.js +60 -0
  46. package/dist/chunks/canvas-preview-tjhabynd.js +252 -0
  47. package/dist/chunks/canvas-preview-tnqe7k0b.js +374 -0
  48. package/dist/chunks/canvas-preview-vaatfznm.js +189 -0
  49. package/dist/chunks/canvas-preview-vm9zdp2t.js +398 -0
  50. package/dist/chunks/canvas-preview-w54ymtb3.js +272 -0
  51. package/dist/chunks/canvas-preview-wg80rbc2.js +46 -0
  52. package/dist/chunks/canvas-preview-y5y6ghaw.js +125 -0
  53. package/dist/chunks/canvas-preview-ycepb05s.js +176 -0
  54. package/dist/chunks/canvas-preview-yjz0f7n4.js +50 -0
  55. package/dist/chunks/canvas-preview-zaaj99wd.js +244 -0
  56. package/dist/src/canvas-preview.d.ts +29 -0
  57. package/dist/src/canvas-preview.js +271 -0
  58. package/dist/src/credentials.d.ts +44 -0
  59. package/dist/src/credentials.js +10 -0
  60. package/dist/src/deploy/cloudflare/worker.d.ts +50 -0
  61. package/dist/src/deploy/cloudflare/worker.js +196 -0
  62. package/dist/src/deploy/vercel/nitro.config.d.ts +2 -0
  63. package/dist/src/deploy/vercel/nitro.config.js +30 -0
  64. package/dist/src/deploy/vercel/server/routes/[...].d.ts +2 -0
  65. package/dist/src/deploy/vercel/server/routes/[...].js +18 -0
  66. package/dist/src/deploy/vercel/server/utils/host.d.ts +13 -0
  67. package/dist/src/deploy/vercel/server/utils/host.js +7 -0
  68. package/dist/src/deploy/vercel/server/utils/repair.d.ts +1 -0
  69. package/dist/src/deploy/vercel/server/utils/repair.js +6 -0
  70. package/dist/src/deploy/vercel/server/utils/step.d.ts +10 -0
  71. package/dist/src/deploy/vercel/server/utils/step.js +6 -0
  72. package/dist/src/deploy/vercel/server/workflows/drive.d.ts +14 -0
  73. package/dist/src/deploy/vercel/server/workflows/drive.js +13 -0
  74. package/dist/src/durable-object.d.ts +14 -0
  75. package/dist/src/durable-object.js +231 -0
  76. package/dist/src/hash.d.ts +1 -0
  77. package/dist/src/hash.js +6 -0
  78. package/dist/src/identity-key.d.ts +11 -0
  79. package/dist/src/identity-key.js +9 -0
  80. package/dist/src/identity.d.ts +12 -0
  81. package/dist/src/identity.js +6 -0
  82. package/dist/src/images/exif.d.ts +1 -0
  83. package/dist/src/images/exif.js +6 -0
  84. package/dist/src/images/node.d.ts +37 -0
  85. package/dist/src/images/node.js +12 -0
  86. package/dist/src/images/photon.d.ts +3 -0
  87. package/dist/src/images/photon.js +7 -0
  88. package/dist/src/images/worker.d.ts +1 -0
  89. package/dist/src/images/worker.js +23 -0
  90. package/dist/src/index.d.ts +20 -0
  91. package/dist/src/index.js +121 -0
  92. package/dist/src/key-file.d.ts +1 -0
  93. package/dist/src/key-file.js +7 -0
  94. package/dist/src/listen-fetch.d.ts +8 -0
  95. package/dist/src/listen-fetch.js +9 -0
  96. package/dist/src/listen.d.ts +4 -0
  97. package/dist/src/listen.js +10 -0
  98. package/dist/src/local/command.d.ts +20 -0
  99. package/dist/src/local/command.js +12 -0
  100. package/dist/src/local/feed.d.ts +7 -0
  101. package/dist/src/local/feed.js +6 -0
  102. package/dist/src/local/files.d.ts +18 -0
  103. package/dist/src/local/files.js +13 -0
  104. package/dist/src/local/git-watch.d.ts +15 -0
  105. package/dist/src/local/git-watch.js +7 -0
  106. package/dist/src/local/git.d.ts +5 -0
  107. package/dist/src/local/git.js +13 -0
  108. package/dist/src/local/github.d.ts +30 -0
  109. package/dist/src/local/github.js +9 -0
  110. package/dist/src/local/identity.d.ts +3 -0
  111. package/dist/src/local/identity.js +7 -0
  112. package/dist/src/local/jobs.d.ts +7 -0
  113. package/dist/src/local/jobs.js +7 -0
  114. package/dist/src/local/legacy-trust.d.ts +10 -0
  115. package/dist/src/local/legacy-trust.js +6 -0
  116. package/dist/src/local/patch.d.ts +16 -0
  117. package/dist/src/local/patch.js +17 -0
  118. package/dist/src/local/provider.d.ts +11 -0
  119. package/dist/src/local/provider.js +20 -0
  120. package/dist/src/local/snapshots.d.ts +7 -0
  121. package/dist/src/local/snapshots.js +11 -0
  122. package/dist/src/local/text.d.ts +1 -0
  123. package/dist/src/local/text.js +6 -0
  124. package/dist/src/local/tree.d.ts +50 -0
  125. package/dist/src/local/tree.js +16 -0
  126. package/dist/src/local/workspace-migration.d.ts +2 -0
  127. package/dist/src/local/workspace-migration.js +7 -0
  128. package/dist/src/local/worktrees.d.ts +46 -0
  129. package/dist/src/local/worktrees.js +14 -0
  130. package/dist/src/mcp/config.d.ts +75 -0
  131. package/dist/src/mcp/config.js +12 -0
  132. package/dist/src/mcp/connect.d.ts +15 -0
  133. package/dist/src/mcp/connect.js +9 -0
  134. package/dist/src/mcp/hub.d.ts +19 -0
  135. package/dist/src/mcp/hub.js +11 -0
  136. package/dist/src/mcp/oauth.d.ts +46 -0
  137. package/dist/src/mcp/oauth.js +12 -0
  138. package/dist/src/mcp/tools.d.ts +29 -0
  139. package/dist/src/mcp/tools.js +12 -0
  140. package/dist/src/open.d.ts +14 -0
  141. package/dist/src/open.js +46 -0
  142. package/dist/src/plugins/loader.d.ts +21 -0
  143. package/dist/src/plugins/loader.js +11 -0
  144. package/dist/src/postgres.d.ts +19 -0
  145. package/dist/src/postgres.js +8 -0
  146. package/dist/src/providers.d.ts +16 -0
  147. package/dist/src/providers.js +112 -0
  148. package/dist/src/secrets.d.ts +12 -0
  149. package/dist/src/secrets.js +8 -0
  150. package/dist/src/serve/binding.d.ts +39 -0
  151. package/dist/src/serve/binding.js +6 -0
  152. package/dist/src/serve/handler.d.ts +15 -0
  153. package/dist/src/serve/handler.js +8 -0
  154. package/dist/src/serve/stream-server.d.ts +6 -0
  155. package/dist/src/serve/stream-server.js +6 -0
  156. package/dist/src/session-index.d.ts +11 -0
  157. package/dist/src/session-index.js +11 -0
  158. package/dist/src/shell-env.d.ts +14 -0
  159. package/dist/src/shell-env.js +108 -0
  160. package/dist/src/sqlite-workers.d.ts +6 -0
  161. package/dist/src/sqlite-workers.js +6 -0
  162. package/dist/src/sqlite.d.ts +8 -0
  163. package/dist/src/sqlite.js +11 -0
  164. package/dist/src/store-rows.d.ts +13 -0
  165. package/dist/src/store-rows.js +26 -0
  166. package/dist/src/store-usage.d.ts +25 -0
  167. package/dist/src/store-usage.js +19 -0
  168. package/dist/src/usage-history.d.ts +54 -0
  169. package/dist/src/usage-history.js +14 -0
  170. package/dist/src/usage-scan.d.ts +24 -0
  171. package/dist/src/usage-scan.js +65 -0
  172. package/dist/src/usage-service.d.ts +23 -0
  173. package/dist/src/usage-service.js +16 -0
  174. package/dist/src/usage-summary.d.ts +9 -0
  175. package/dist/src/usage-summary.js +16 -0
  176. package/dist/src/usage-types.d.ts +86 -0
  177. package/dist/src/usage-types.js +0 -0
  178. package/dist/src/usage-worker.d.ts +1 -0
  179. package/dist/src/usage-worker.js +29 -0
  180. package/dist/src/usage.d.ts +195 -0
  181. package/dist/src/usage.js +23 -0
  182. package/package.json +140 -3
  183. package/src/canvas-preview.ts +403 -0
  184. package/src/credentials.ts +294 -0
  185. package/src/deploy/cloudflare/README.md +3 -0
  186. package/src/deploy/cloudflare/worker.ts +306 -0
  187. package/src/deploy/cloudflare/wrangler.jsonc +12 -0
  188. package/src/deploy/vercel/README.md +3 -0
  189. package/src/deploy/vercel/nitro.config.ts +29 -0
  190. package/src/deploy/vercel/server/routes/[...].ts +10 -0
  191. package/src/deploy/vercel/server/utils/host.ts +104 -0
  192. package/src/deploy/vercel/server/utils/repair.ts +35 -0
  193. package/src/deploy/vercel/server/utils/step.ts +25 -0
  194. package/src/deploy/vercel/server/workflows/drive.ts +46 -0
  195. package/src/deploy/vercel/tsconfig.json +9 -0
  196. package/src/deploy/vercel/types/bun.d.ts +2 -0
  197. package/src/durable-object.ts +386 -0
  198. package/src/hash.ts +5 -0
  199. package/src/identity-key.ts +20 -0
  200. package/src/identity.ts +66 -0
  201. package/src/images/exif.ts +69 -0
  202. package/src/images/node.ts +163 -0
  203. package/src/images/photon.ts +141 -0
  204. package/src/images/worker.ts +29 -0
  205. package/src/index.ts +39 -0
  206. package/src/key-file.ts +53 -0
  207. package/src/listen-fetch.ts +409 -0
  208. package/src/listen.ts +228 -0
  209. package/src/local/command.ts +161 -0
  210. package/src/local/feed.ts +70 -0
  211. package/src/local/files.ts +817 -0
  212. package/src/local/git-watch.ts +199 -0
  213. package/src/local/git.ts +919 -0
  214. package/src/local/github.ts +691 -0
  215. package/src/local/identity.ts +21 -0
  216. package/src/local/jobs.ts +573 -0
  217. package/src/local/legacy-trust.ts +105 -0
  218. package/src/local/patch.ts +334 -0
  219. package/src/local/provider.ts +184 -0
  220. package/src/local/snapshots.ts +778 -0
  221. package/src/local/text.ts +9 -0
  222. package/src/local/tree.ts +398 -0
  223. package/src/local/workspace-migration.ts +95 -0
  224. package/src/local/worktrees.ts +239 -0
  225. package/src/mcp/config.ts +330 -0
  226. package/src/mcp/connect.ts +92 -0
  227. package/src/mcp/hub.ts +836 -0
  228. package/src/mcp/oauth.ts +436 -0
  229. package/src/mcp/tools.ts +333 -0
  230. package/src/open.ts +449 -0
  231. package/src/plugins/loader.ts +186 -0
  232. package/src/postgres.ts +492 -0
  233. package/src/providers.ts +135 -0
  234. package/src/secrets.ts +31 -0
  235. package/src/serve/binding.ts +629 -0
  236. package/src/serve/handler.ts +362 -0
  237. package/src/serve/stream-server.ts +176 -0
  238. package/src/session-index.ts +99 -0
  239. package/src/shell-env.ts +153 -0
  240. package/src/sqlite-workers.ts +283 -0
  241. package/src/sqlite.ts +553 -0
  242. package/src/store-rows.ts +110 -0
  243. package/src/store-usage.ts +347 -0
  244. package/src/usage-history.ts +219 -0
  245. package/src/usage-scan.ts +81 -0
  246. package/src/usage-service.ts +82 -0
  247. package/src/usage-summary.ts +79 -0
  248. package/src/usage-types.ts +87 -0
  249. package/src/usage-worker.ts +24 -0
  250. package/src/usage.ts +776 -0
@@ -0,0 +1,778 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ copyFile,
4
+ lstat,
5
+ mkdir,
6
+ open,
7
+ readFile,
8
+ readlink,
9
+ rename,
10
+ rm,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import { tmpdir } from "node:os";
14
+ import { join, resolve } from "node:path";
15
+ import { asId, SdkError } from "@nyte-ai/schema";
16
+ import type {
17
+ ChangeSet,
18
+ DiffOptions,
19
+ LineStat,
20
+ PatchEntry,
21
+ RunId,
22
+ SnapshotId,
23
+ } from "@nyte-ai/schema";
24
+ import { dev } from "@nyte-ai/core";
25
+ import type { ExecutionEnv, SnapshotsApi } from "@nyte-ai/core";
26
+ import { sha256 } from "../hash.ts";
27
+ import { run } from "./command.ts";
28
+ import type { Ran } from "./command.ts";
29
+ import {
30
+ PATCH_LIMIT,
31
+ lineStat,
32
+ parseNameStatus,
33
+ parseNumstat,
34
+ readPatches,
35
+ textEntry,
36
+ } from "./patch.ts";
37
+ import type { Changed } from "./patch.ts";
38
+ import { decodeText } from "./text.ts";
39
+ import { WALK_LIMIT, walkTree } from "./tree.ts";
40
+
41
+ type Repository = { readonly root: string; readonly gitDir: string; readonly prefix: string };
42
+
43
+ type Manifest = Readonly<Record<string, string>>;
44
+
45
+ type Tree = { readonly files: Manifest; readonly incomplete: boolean };
46
+
47
+ /** The root tree of `refs/nyte/kept`: its entries, and the newest generation, the only one a rewrite adds to. */
48
+ type Kept = {
49
+ readonly id: string;
50
+ readonly root: readonly { readonly tree: string; readonly name: string }[];
51
+ readonly last: {
52
+ readonly name: string;
53
+ readonly number: number;
54
+ readonly trees: readonly string[];
55
+ } | null;
56
+ };
57
+
58
+ const GENERATION_NAME = /^g(\d{6})$/;
59
+
60
+ const NOTHING_KEPT: Kept = { id: "", root: [], last: null };
61
+
62
+ const ENV: Readonly<Record<string, string>> = {
63
+ GIT_OPTIONAL_LOCKS: "0",
64
+ GIT_TERMINAL_PROMPT: "0",
65
+ GIT_LITERAL_PATHSPECS: "1",
66
+ LC_ALL: "C",
67
+ };
68
+
69
+ const CONFIG = ["-c", "core.quotepath=off", "-c", "color.ui=false", "-c", "diff.noprefix=false"];
70
+
71
+ const DIFF = [
72
+ "--no-color",
73
+ "--no-ext-diff",
74
+ "--no-textconv",
75
+ "--src-prefix=a/",
76
+ "--dst-prefix=b/",
77
+ "-M",
78
+ ];
79
+
80
+ const BEFORE_KEPT = 1_000;
81
+
82
+ const KEPT_REF = "refs/nyte/kept";
83
+
84
+ const LEGACY_REFS = "refs/nyte/snapshots/";
85
+
86
+ /** Snapshot trees per generation tree under `refs/nyte/kept`; a full generation is never rewritten. */
87
+ const GENERATION = 1_000;
88
+
89
+ const KEEP_TRIES = 3;
90
+
91
+ const CHUNK = 64 * 1024;
92
+
93
+ /** Returns content-addressed snapshots of an environment's tree, so equal trees are equal ids whichever run took them. `before(run)` is taken once per run in this process and answered again after; `after(run)` is taken on every call. In a repository (`git`, default `git` on the PATH), a snapshot writes the environment's part of the worktree, tracked and untracked files that are not ignored, into a temporary index seeded from the real one (`GIT_INDEX_FILE`, `git add -A`, `git write-tree --prefix` for a workspace below the root; the real index is never touched) and keeps the tree reachable for good from the hidden ref `refs/nyte/kept`, a tree of generation trees `g000000`, `g000001`, ... that each hold up to 1,000 snapshot trees named by their hash (only the newest generation and the root are rewritten, in the background after a snapshot of a tree this process has not kept, so a snapshot costs two git spawns: `add` and `write-tree`; the per-tree refs `refs/nyte/snapshots/<tree>` of earlier versions all fold into it and go); the id is `git:<tree>`. The repository is looked up once. Without a repository it walks the tree as `localFiles.scan` does (up to `limit` entries, default 50 000), stores each file's bytes up to 1 MiB once under `<store>/objects/<sha256>` (a larger file is hashed, not stored; a file whose size, mtime and inode did not move is not re-read) and the tree, `{ incomplete, files }` (whether the walk stopped short, at its limit or at an unreadable directory, and the manifest of paths to content hashes), under `<store>/trees/<sha256 of it>.json`; the id is `tree:<hash>`. `diff(from, to)` answers the `ChangeSet` between two snapshots of one kind (empty, without running git, when they are equal; `git diff-tree -r -M --name-status`/`--numstat`, or the two manifests compared, an exact-content move a rename; the empty id `""` is the empty tree), its `revision` `to`; a path only an incomplete side lacks is neither added nor deleted but `modified` with an `unknown` stat; `patches(from, to, paths, options)` answers one `PatchEntry` per path the comparison reports, a text one carrying its unified diff as `localGit`'s do (`git diff` between the trees, read as `localGit.patches` reads: a path past 1 MiB `too-large`, one whose read fails `failed`, the others fine), or, between manifests, from the stored contents with the line diff, a text entry's digest its patch's so it moves with `ignoreWhitespace` (`too-large` for a file not stored, `binary` for one not text, `failed { reason: "incomplete" }` for a path only an incomplete side lacks). Snapshots of two kinds do not compare: `SdkError("invalid")`. */
94
+ export function localSnapshots(
95
+ env: ExecutionEnv,
96
+ options: { readonly store: string; readonly git?: string; readonly limit?: number },
97
+ ): SnapshotsApi {
98
+ const binary = options.git ?? "git";
99
+ const befores = new Map<RunId, Promise<SnapshotId>>();
100
+ const stamps = new Map<string, { readonly stamp: string; readonly hash: string }>();
101
+ const fresh: string[] = [];
102
+ let repositoryLookup: Promise<Repository | null> | undefined;
103
+ let emptyTree: Promise<string> | undefined;
104
+ let keeping: Promise<void> | undefined;
105
+ let kept: Kept | null = null;
106
+ const keptTrees = new Set<string>();
107
+
108
+ const git = (
109
+ cwd: string,
110
+ args: readonly string[],
111
+ extra?: {
112
+ readonly env?: Readonly<Record<string, string>>;
113
+ readonly input?: string;
114
+ readonly limit?: number;
115
+ },
116
+ ): Promise<Ran> =>
117
+ run(binary, [...CONFIG, ...args], {
118
+ cwd,
119
+ env: { ...ENV, ...extra?.env },
120
+ input: extra?.input,
121
+ limit: extra?.limit,
122
+ });
123
+
124
+ const findRepository = async (): Promise<Repository | null> => {
125
+ const ran = await git(env.cwd, [
126
+ "rev-parse",
127
+ "--show-toplevel",
128
+ "--absolute-git-dir",
129
+ "--show-prefix",
130
+ ]).catch(() => null);
131
+
132
+ if (ran === null || ran.code !== 0) return null;
133
+ const [root, gitDir, prefix = ""] = ran.stdout.toString("utf8").split("\n");
134
+
135
+ return root === undefined || gitDir === undefined
136
+ ? null
137
+ : { root: resolve(root), gitDir: resolve(gitDir), prefix };
138
+ };
139
+
140
+ const repository = (): Promise<Repository | null> => {
141
+ repositoryLookup ??= findRepository();
142
+
143
+ return repositoryLookup;
144
+ };
145
+
146
+ const checked = async (ran: Ran): Promise<string> => {
147
+ if (ran.code !== 0) throw new SdkError("unavailable", ran.stderr.trim() || "git failed");
148
+
149
+ return ran.stdout.toString("utf8").trim();
150
+ };
151
+
152
+ const gitSnapshot = async (repo: Repository): Promise<SnapshotId> => {
153
+ const write = async (seeded: boolean): Promise<Ran> => {
154
+ const index = join(tmpdir(), `nyte-index-${randomUUID()}`);
155
+
156
+ try {
157
+ if (seeded) await copyFile(join(repo.gitDir, "index"), index).catch(() => undefined);
158
+ const indexEnv = { GIT_INDEX_FILE: index };
159
+ await checked(await git(env.cwd, ["add", "-A", "--", "."], { env: indexEnv }));
160
+
161
+ return await git(
162
+ repo.root,
163
+ repo.prefix === "" ? ["write-tree"] : ["write-tree", `--prefix=${repo.prefix}`],
164
+ { env: indexEnv },
165
+ );
166
+ } finally {
167
+ await rm(index, { force: true });
168
+ }
169
+ };
170
+
171
+ const first = await write(true);
172
+ const tree = await checked(first.code === 0 ? first : await write(false));
173
+ const id = asId<"snapshot">(`git:${tree}`);
174
+
175
+ if (fresh.includes(tree) || keptTrees.has(tree)) return id;
176
+ fresh.push(tree);
177
+ keeping ??= keepTrees(repo).finally(() => {
178
+ keeping = undefined;
179
+ });
180
+
181
+ return id;
182
+ };
183
+
184
+ const keepTrees = async (repo: Repository): Promise<void> => {
185
+ for (let tries = 0; fresh.length > 0 && tries < KEEP_TRIES; tries += 1) {
186
+ const taken = fresh.splice(0);
187
+ let landed = false;
188
+
189
+ try {
190
+ landed = await rewriteKept(repo, taken);
191
+ } catch (error) {
192
+ dev("snapshots", "keep failed", { error: String(error) });
193
+ }
194
+
195
+ if (!landed) fresh.unshift(...taken);
196
+ }
197
+ };
198
+
199
+ const rewriteKept = async (repo: Repository, taken: readonly string[]): Promise<boolean> => {
200
+ const listed = await checked(
201
+ await git(repo.root, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/nyte/"]),
202
+ );
203
+
204
+ const refs = listed
205
+ .split("\n")
206
+ .filter((line) => line !== "")
207
+ .map((line) => {
208
+ const [id = "", name = ""] = line.split(" ");
209
+
210
+ return { id, name };
211
+ });
212
+
213
+ const current = refs.find((ref) => ref.name === KEPT_REF)?.id ?? null;
214
+ const legacy = refs.filter((ref) => ref.name.startsWith(LEGACY_REFS));
215
+ const known = current === null ? NOTHING_KEPT : await readKept(repo, current);
216
+
217
+ const open = known.last !== null && known.last.trees.length < GENERATION ? known.last : null;
218
+
219
+ const openTrees = open?.trees ?? [];
220
+
221
+ const adding = [...new Set([...legacy.map((ref) => ref.id), ...taken])].filter(
222
+ (tree) => !keptTrees.has(tree) && !openTrees.includes(tree),
223
+ );
224
+
225
+ if (adding.length === 0 && legacy.length === 0) return true;
226
+ const filled = [...openTrees, ...adding];
227
+ const first = open?.number ?? (known.last === null ? 0 : known.last.number + 1);
228
+
229
+ const generations = await Promise.all(
230
+ Array.from({ length: Math.ceil(filled.length / GENERATION) }, async (_, index) => {
231
+ const trees = filled.slice(index * GENERATION, (index + 1) * GENERATION);
232
+
233
+ return {
234
+ number: first + index,
235
+ trees,
236
+ tree: await mktree(
237
+ repo,
238
+ trees.map((tree) => ({ tree, name: tree })),
239
+ ),
240
+ };
241
+ }),
242
+ );
243
+
244
+ const root = [
245
+ ...known.root.filter((entry) => entry.name !== open?.name),
246
+ ...generations.map((generation) => ({
247
+ tree: generation.tree,
248
+ name: generationName(generation.number),
249
+ })),
250
+ ];
251
+
252
+ const next = await mktree(repo, root);
253
+
254
+ const commands = [
255
+ current === null ? `create ${KEPT_REF} ${next}` : `update ${KEPT_REF} ${next} ${current}`,
256
+ ...legacy.map((ref) => `delete ${ref.name} ${ref.id}`),
257
+ ];
258
+
259
+ const updated = await git(repo.root, ["update-ref", "--stdin"], {
260
+ input: `${commands.join("\n")}\n`,
261
+ });
262
+
263
+ if (updated.code !== 0) {
264
+ kept = null;
265
+
266
+ return false;
267
+ }
268
+
269
+ const newest = generations.at(-1);
270
+
271
+ kept = {
272
+ id: next,
273
+ root,
274
+ last:
275
+ newest === undefined
276
+ ? known.last
277
+ : {
278
+ name: generationName(newest.number),
279
+ number: newest.number,
280
+ trees: newest.trees,
281
+ },
282
+ };
283
+
284
+ for (const tree of filled) keptTrees.add(tree);
285
+
286
+ return true;
287
+ };
288
+
289
+ const mktree = async (
290
+ repo: Repository,
291
+ entries: readonly { readonly tree: string; readonly name: string }[],
292
+ ): Promise<string> =>
293
+ checked(
294
+ await git(repo.root, ["mktree"], {
295
+ input: entries.map((entry) => `040000 tree ${entry.tree}\t${entry.name}\n`).join(""),
296
+ }),
297
+ );
298
+
299
+ const readTree = async (
300
+ repo: Repository,
301
+ id: string,
302
+ ): Promise<readonly { readonly tree: string; readonly name: string }[]> => {
303
+ const listed = await checked(await git(repo.root, ["ls-tree", id]));
304
+
305
+ return listed.split("\n").flatMap((line) => {
306
+ const [mode = "", type = "", rest = ""] = line.split(" ");
307
+ const [tree = "", name = ""] = rest.split("\t");
308
+
309
+ return mode === "040000" && type === "tree" ? [{ tree, name }] : [];
310
+ });
311
+ };
312
+
313
+ const readKept = async (repo: Repository, id: string): Promise<Kept> => {
314
+ if (kept?.id === id) return kept;
315
+ const root = await readTree(repo, id);
316
+
317
+ const last = root
318
+ .flatMap((entry) => {
319
+ const number = GENERATION_NAME.exec(entry.name)?.[1];
320
+
321
+ return number === undefined ? [] : [{ ...entry, number: Number(number) }];
322
+ })
323
+ .toSorted((left, right) => left.number - right.number)
324
+ .at(-1);
325
+
326
+ if (last === undefined) return { id, root, last: null };
327
+ const trees = (await readTree(repo, last.tree)).map((entry) => entry.tree);
328
+
329
+ for (const tree of trees) keptTrees.add(tree);
330
+
331
+ return { id, root, last: { name: last.name, number: last.number, trees } };
332
+ };
333
+
334
+ const walkSnapshot = async (): Promise<SnapshotId> => {
335
+ const manifest: Record<string, string> = {};
336
+ let incomplete = false;
337
+
338
+ for await (const frame of walkTree(env.cwd, {
339
+ ignored: "exclude",
340
+ limit: options.limit ?? WALK_LIMIT,
341
+ })) {
342
+ if (frame.kind === "incomplete") incomplete = true;
343
+
344
+ if (frame.kind !== "entries") continue;
345
+
346
+ for (const entry of frame.entries) {
347
+ if (entry.kind === "directory") continue;
348
+
349
+ const hash = await storeEntry(
350
+ options.store,
351
+ join(env.cwd, entry.path),
352
+ entry.kind,
353
+ stamps,
354
+ ).catch(() => null);
355
+
356
+ if (hash !== null) manifest[entry.path] = hash;
357
+ }
358
+ }
359
+
360
+ const text = JSON.stringify({
361
+ incomplete,
362
+ files: Object.fromEntries(
363
+ Object.entries(manifest).sort(([left], [right]) => (left < right ? -1 : 1)),
364
+ ),
365
+ });
366
+
367
+ const id = sha256(text);
368
+ await keep(join(options.store, "trees", `${id}.json`), text);
369
+
370
+ return asId<"snapshot">(`tree:${id}`);
371
+ };
372
+
373
+ const take = async (): Promise<SnapshotId> => {
374
+ const started = Date.now();
375
+ const repo = await repository();
376
+ const id = await (repo === null ? walkSnapshot() : gitSnapshot(repo));
377
+ dev("snapshots", "take", { kind: repo === null ? "tree" : "git", ms: Date.now() - started });
378
+
379
+ return id;
380
+ };
381
+
382
+ const pair = async (
383
+ from: SnapshotId,
384
+ to: SnapshotId,
385
+ ): Promise<
386
+ | {
387
+ readonly kind: "git";
388
+ readonly repo: Repository;
389
+ readonly from: string;
390
+ readonly to: string;
391
+ }
392
+ | { readonly kind: "tree"; readonly from: Tree; readonly to: Tree }
393
+ > => {
394
+ const kinds = new Set([from, to].flatMap((id) => (id === "" ? [] : id.split(":", 1))));
395
+
396
+ if (kinds.size > 1)
397
+ throw new SdkError("invalid", `Snapshots ${from} and ${to} are of two kinds.`);
398
+
399
+ if (kinds.has("tree") || kinds.size === 0)
400
+ return {
401
+ kind: "tree",
402
+ from: await readWalkedTree(options.store, from),
403
+ to: await readWalkedTree(options.store, to),
404
+ };
405
+ const repo = await repository();
406
+
407
+ if (repo === null)
408
+ throw new SdkError("unavailable", `${env.cwd} is no longer in a git repository.`);
409
+
410
+ const empty =
411
+ from === "" || to === ""
412
+ ? await (emptyTree ??= git(repo.root, ["hash-object", "-t", "tree", "--stdin"], {
413
+ input: "",
414
+ }).then(checked))
415
+ : "";
416
+
417
+ const tree = (id: SnapshotId): string => (id === "" ? empty : id.slice("git:".length));
418
+
419
+ return { kind: "git", repo, from: tree(from), to: tree(to) };
420
+ };
421
+
422
+ const changed = async (
423
+ from: SnapshotId,
424
+ to: SnapshotId,
425
+ ): Promise<readonly (Changed & { readonly stat: LineStat })[]> => {
426
+ if (from === to) return [];
427
+ const compared = await pair(from, to);
428
+
429
+ if (compared.kind === "git") {
430
+ const [names, numstat] = await Promise.all([
431
+ git(compared.repo.root, [
432
+ "diff-tree",
433
+ "-r",
434
+ "-z",
435
+ "-M",
436
+ "--name-status",
437
+ compared.from,
438
+ compared.to,
439
+ ]),
440
+ git(compared.repo.root, [
441
+ "diff-tree",
442
+ "-r",
443
+ "-z",
444
+ "-M",
445
+ "--numstat",
446
+ compared.from,
447
+ compared.to,
448
+ ]),
449
+ ]);
450
+
451
+ const stats = parseNumstat(await checked(numstat));
452
+
453
+ return parseNameStatus(await checked(names)).map((file) => ({
454
+ ...file,
455
+ stat: stats.get(file.path) ?? { kind: "unknown" },
456
+ }));
457
+ }
458
+
459
+ const { files, incomplete } = compareTrees(compared.from, compared.to);
460
+
461
+ return Promise.all(
462
+ files.map(async (file) => ({
463
+ ...file,
464
+ stat: incomplete.has(file.path)
465
+ ? { kind: "unknown" as const }
466
+ : await manifestStat(options.store, compared.from.files, compared.to.files, file),
467
+ })),
468
+ );
469
+ };
470
+
471
+ return {
472
+ before(run) {
473
+ const known = befores.get(run);
474
+
475
+ if (known !== undefined) return known;
476
+ const taken = take();
477
+ befores.set(run, taken);
478
+ taken.catch(() => befores.delete(run));
479
+
480
+ for (const old of befores.keys()) {
481
+ if (befores.size <= BEFORE_KEPT) break;
482
+ befores.delete(old);
483
+ }
484
+
485
+ return taken;
486
+ },
487
+ after: () => take(),
488
+ async diff(from, to): Promise<ChangeSet> {
489
+ const started = Date.now();
490
+ const files = await changed(from, to);
491
+ dev("snapshots", "diff", { entries: files.length, ms: Date.now() - started });
492
+
493
+ return { revision: to, files };
494
+ },
495
+ async patches(from, to, paths, diff?: DiffOptions) {
496
+ const compared = await pair(from, to);
497
+ const reported = new Map((await changed(from, to)).map((file) => [file.path, file]));
498
+
499
+ const wanted = [...new Set(paths)].flatMap((path) => {
500
+ const file = reported.get(path);
501
+
502
+ return file === undefined ? [] : [file];
503
+ });
504
+
505
+ if (wanted.length === 0) return [];
506
+
507
+ if (compared.kind === "tree") {
508
+ const { incomplete } = compareTrees(compared.from, compared.to);
509
+
510
+ return Promise.all(
511
+ wanted.map(async (file): Promise<PatchEntry> =>
512
+ incomplete.has(file.path)
513
+ ? {
514
+ path: file.path,
515
+ status: file.status,
516
+ digest: "",
517
+ kind: "failed",
518
+ reason: "incomplete",
519
+ }
520
+ : manifestEntry(options.store, compared.from.files, compared.to.files, file, diff),
521
+ ),
522
+ );
523
+ }
524
+
525
+ return readPatches(
526
+ wanted,
527
+ (files, limit) =>
528
+ git(
529
+ compared.repo.root,
530
+ [
531
+ "diff",
532
+ ...DIFF,
533
+ "-U3",
534
+ ...(diff?.ignoreWhitespace === true ? ["--ignore-all-space"] : []),
535
+ compared.from,
536
+ compared.to,
537
+ "--",
538
+ ...files.flatMap((file) =>
539
+ file.from === undefined ? [file.path] : [file.from, file.path],
540
+ ),
541
+ ],
542
+ { limit },
543
+ ),
544
+ (file) => sha256(`${to}\u0000${file.path}`),
545
+ );
546
+ },
547
+ };
548
+ }
549
+
550
+ async function storeEntry(
551
+ store: string,
552
+ path: string,
553
+ kind: "file" | "symlink",
554
+ stamps: Map<string, { readonly stamp: string; readonly hash: string }>,
555
+ ): Promise<string> {
556
+ const stats = await lstat(path);
557
+ const stamp = `${kind}:${stats.size}:${stats.mtimeMs}:${stats.ino}`;
558
+ const known = stamps.get(path);
559
+
560
+ if (known?.stamp === stamp) return known.hash;
561
+
562
+ if (kind === "symlink") {
563
+ const target = new TextEncoder().encode(await readlink(path));
564
+ const hash = sha256(target);
565
+ await keep(join(store, "objects", hash), target);
566
+ stamps.set(path, { stamp, hash });
567
+
568
+ return hash;
569
+ }
570
+
571
+ if (stats.size > PATCH_LIMIT) {
572
+ const hash = await hashFile(path);
573
+ stamps.set(path, { stamp, hash });
574
+
575
+ return hash;
576
+ }
577
+
578
+ const bytes = await readFile(path);
579
+ const hash = sha256(bytes);
580
+ await keep(join(store, "objects", hash), bytes);
581
+ stamps.set(path, { stamp, hash });
582
+
583
+ return hash;
584
+ }
585
+
586
+ async function hashFile(path: string): Promise<string> {
587
+ const hash = createHash("sha256");
588
+ const handle = await open(path, "r");
589
+
590
+ try {
591
+ const buffer = new Uint8Array(CHUNK);
592
+
593
+ for (;;) {
594
+ const { bytesRead } = await handle.read(buffer, 0, CHUNK, null);
595
+
596
+ if (bytesRead === 0) break;
597
+ hash.update(buffer.subarray(0, bytesRead));
598
+ }
599
+ } finally {
600
+ await handle.close();
601
+ }
602
+
603
+ return hash.digest("hex");
604
+ }
605
+
606
+ async function keep(path: string, contents: string | Uint8Array): Promise<void> {
607
+ if (
608
+ await lstat(path).then(
609
+ () => true,
610
+ () => false,
611
+ )
612
+ )
613
+ return;
614
+ await mkdir(join(path, ".."), { recursive: true });
615
+ const temporary = `${path}.${randomUUID()}.tmp`;
616
+
617
+ try {
618
+ await writeFile(temporary, contents);
619
+ await rename(temporary, path);
620
+ } finally {
621
+ await rm(temporary, { force: true });
622
+ }
623
+ }
624
+
625
+ async function readWalkedTree(store: string, id: SnapshotId): Promise<Tree> {
626
+ if (id === "") return { files: {}, incomplete: false };
627
+
628
+ if (!id.startsWith("tree:")) throw new SdkError("invalid", `${id} is not a walked snapshot.`);
629
+
630
+ const text = await readFile(
631
+ join(store, "trees", `${id.slice("tree:".length)}.json`),
632
+ "utf8",
633
+ ).catch(() => null);
634
+
635
+ if (text === null) throw new SdkError("unknown", `No snapshot ${id} is stored.`);
636
+ const value: unknown = JSON.parse(text);
637
+
638
+ if (
639
+ typeof value !== "object" ||
640
+ value === null ||
641
+ !("files" in value) ||
642
+ typeof value.files !== "object" ||
643
+ value.files === null ||
644
+ Array.isArray(value.files)
645
+ )
646
+ throw new SdkError("internal", `Snapshot ${id} is malformed.`);
647
+
648
+ return {
649
+ incomplete: "incomplete" in value && value.incomplete === true,
650
+ files: Object.fromEntries(
651
+ Object.entries(value.files).flatMap(([path, hash]) =>
652
+ typeof hash === "string" ? [[path, hash]] : [],
653
+ ),
654
+ ),
655
+ };
656
+ }
657
+
658
+ function compareTrees(before: Tree, after: Tree) {
659
+ const from = before.files;
660
+ const to = after.files;
661
+ const gone = Object.keys(from).filter((path) => !(path in to));
662
+ const fresh = Object.keys(to).filter((path) => !(path in from));
663
+
664
+ const incomplete = new Set([
665
+ ...(after.incomplete ? gone : []),
666
+ ...(before.incomplete ? fresh : []),
667
+ ]);
668
+
669
+ const deleted = after.incomplete ? [] : gone;
670
+ const added = before.incomplete ? [] : fresh;
671
+ const moved = new Map<string, string>();
672
+ const byHash = new Map<string, string[]>();
673
+
674
+ for (const path of deleted)
675
+ byHash.set(from[path] ?? "", [...(byHash.get(from[path] ?? "") ?? []), path]);
676
+
677
+ for (const path of added) {
678
+ const source = byHash.get(to[path] ?? "")?.shift();
679
+
680
+ if (source !== undefined) moved.set(path, source);
681
+ }
682
+
683
+ const sources = new Set(moved.values());
684
+
685
+ const files: Changed[] = [
686
+ ...deleted
687
+ .filter((path) => !sources.has(path))
688
+ .map((path): Changed => ({ path, status: "deleted" })),
689
+ ...added.map((path): Changed => {
690
+ const source = moved.get(path);
691
+
692
+ return source === undefined
693
+ ? { path, status: "added" }
694
+ : { path, status: "renamed", from: source };
695
+ }),
696
+ ...Object.keys(to)
697
+ .filter((path) => path in from && from[path] !== to[path])
698
+ .map((path): Changed => ({ path, status: "modified" })),
699
+ ...[...incomplete].map((path): Changed => ({ path, status: "modified" })),
700
+ ];
701
+
702
+ return {
703
+ files: files.sort((left, right) =>
704
+ left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
705
+ ),
706
+ incomplete,
707
+ };
708
+ }
709
+
710
+ async function sideText(
711
+ store: string,
712
+ hash: string | undefined,
713
+ ): Promise<string | null | undefined> {
714
+ if (hash === undefined) return null;
715
+ const bytes = await readFile(join(store, "objects", hash)).catch(() => undefined);
716
+
717
+ return bytes === undefined ? undefined : (decodeText(bytes) ?? undefined);
718
+ }
719
+
720
+ async function stored(
721
+ store: string,
722
+ hash: string | undefined,
723
+ ): Promise<"absent" | "text" | "binary" | "missing"> {
724
+ if (hash === undefined) return "absent";
725
+ const bytes = await readFile(join(store, "objects", hash)).catch(() => undefined);
726
+
727
+ if (bytes === undefined) return "missing";
728
+
729
+ return decodeText(bytes) === null ? "binary" : "text";
730
+ }
731
+
732
+ async function manifestStat(
733
+ store: string,
734
+ from: Manifest,
735
+ to: Manifest,
736
+ file: Changed,
737
+ ): Promise<LineStat> {
738
+ const before = from[file.from ?? file.path];
739
+ const after = to[file.path];
740
+ const kinds = await Promise.all([stored(store, before), stored(store, after)]);
741
+
742
+ if (kinds.includes("missing")) return { kind: "unknown" };
743
+
744
+ if (kinds.includes("binary")) return { kind: "binary" };
745
+
746
+ return lineStat((await sideText(store, before)) ?? "", (await sideText(store, after)) ?? "");
747
+ }
748
+
749
+ async function manifestEntry(
750
+ store: string,
751
+ from: Manifest,
752
+ to: Manifest,
753
+ file: Changed,
754
+ diff: DiffOptions | undefined,
755
+ ): Promise<PatchEntry> {
756
+ const before = from[file.from ?? file.path];
757
+ const after = to[file.path];
758
+ const kinds = await Promise.all([stored(store, before), stored(store, after)]);
759
+ const digest = sha256(`${before ?? ""}\u0000${after ?? ""}`);
760
+
761
+ if (kinds.includes("missing"))
762
+ return { path: file.path, status: file.status, digest, kind: "too-large", limit: PATCH_LIMIT };
763
+
764
+ if (kinds.includes("binary"))
765
+ return { path: file.path, status: file.status, digest, kind: "binary" };
766
+
767
+ return textEntry(
768
+ file.path,
769
+ file.status,
770
+ (await sideText(store, before)) ?? null,
771
+ (await sideText(store, after)) ?? null,
772
+ diff,
773
+ );
774
+ }
775
+
776
+ function generationName(number: number): string {
777
+ return `g${String(number).padStart(6, "0")}`;
778
+ }