@ai-development-environment/server 0.0.30 → 0.0.32

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 (192) hide show
  1. package/package.json +1 -1
  2. package/standalone/.next/BUILD_ID +1 -1
  3. package/standalone/.next/app-path-routes-manifest.json +1 -0
  4. package/standalone/.next/build-manifest.json +3 -3
  5. package/standalone/.next/prerender-manifest.json +3 -3
  6. package/standalone/.next/routes-manifest.json +9 -0
  7. package/standalone/.next/server/app/[locale]/actions/page.js +2 -1
  8. package/standalone/.next/server/app/[locale]/actions/page.js.nft.json +1 -1
  9. package/standalone/.next/server/app/[locale]/actions/page_client-reference-manifest.js +1 -1
  10. package/standalone/.next/server/app/[locale]/agents/[id]/page.js +2 -1
  11. package/standalone/.next/server/app/[locale]/agents/[id]/page.js.nft.json +1 -1
  12. package/standalone/.next/server/app/[locale]/agents/[id]/page_client-reference-manifest.js +1 -1
  13. package/standalone/.next/server/app/[locale]/agents/page.js +2 -1
  14. package/standalone/.next/server/app/[locale]/agents/page.js.nft.json +1 -1
  15. package/standalone/.next/server/app/[locale]/agents/page_client-reference-manifest.js +1 -1
  16. package/standalone/.next/server/app/[locale]/build-data/page.js +2 -1
  17. package/standalone/.next/server/app/[locale]/build-data/page.js.nft.json +1 -1
  18. package/standalone/.next/server/app/[locale]/build-data/page_client-reference-manifest.js +1 -1
  19. package/standalone/.next/server/app/[locale]/builds/[buildId]/coverage/page.js +2 -1
  20. package/standalone/.next/server/app/[locale]/builds/[buildId]/coverage/page.js.nft.json +1 -1
  21. package/standalone/.next/server/app/[locale]/builds/[buildId]/coverage/page_client-reference-manifest.js +1 -1
  22. package/standalone/.next/server/app/[locale]/builds/[buildId]/page.js +2 -1
  23. package/standalone/.next/server/app/[locale]/builds/[buildId]/page.js.nft.json +1 -1
  24. package/standalone/.next/server/app/[locale]/builds/[buildId]/page_client-reference-manifest.js +1 -1
  25. package/standalone/.next/server/app/[locale]/builds/page.js +2 -1
  26. package/standalone/.next/server/app/[locale]/builds/page.js.nft.json +1 -1
  27. package/standalone/.next/server/app/[locale]/builds/page_client-reference-manifest.js +1 -1
  28. package/standalone/.next/server/app/[locale]/codebases/[codebaseId]/page.js +2 -1
  29. package/standalone/.next/server/app/[locale]/codebases/[codebaseId]/page.js.nft.json +1 -1
  30. package/standalone/.next/server/app/[locale]/codebases/[codebaseId]/page_client-reference-manifest.js +1 -1
  31. package/standalone/.next/server/app/[locale]/codebases/page.js +2 -1
  32. package/standalone/.next/server/app/[locale]/codebases/page.js.nft.json +1 -1
  33. package/standalone/.next/server/app/[locale]/codebases/page_client-reference-manifest.js +1 -1
  34. package/standalone/.next/server/app/[locale]/codebases/repositories/[repositoryId]/page.js +2 -1
  35. package/standalone/.next/server/app/[locale]/codebases/repositories/[repositoryId]/page.js.nft.json +1 -1
  36. package/standalone/.next/server/app/[locale]/codebases/repositories/[repositoryId]/page_client-reference-manifest.js +1 -1
  37. package/standalone/.next/server/app/[locale]/comments/page.js +2 -1
  38. package/standalone/.next/server/app/[locale]/comments/page.js.nft.json +1 -1
  39. package/standalone/.next/server/app/[locale]/comments/page_client-reference-manifest.js +1 -1
  40. package/standalone/.next/server/app/[locale]/jira/cache/page.js +2 -1
  41. package/standalone/.next/server/app/[locale]/jira/cache/page.js.nft.json +1 -1
  42. package/standalone/.next/server/app/[locale]/jira/cache/page_client-reference-manifest.js +1 -1
  43. package/standalone/.next/server/app/[locale]/jira/cache/tickets/[issueKey]/page.js +2 -1
  44. package/standalone/.next/server/app/[locale]/jira/cache/tickets/[issueKey]/page.js.nft.json +1 -1
  45. package/standalone/.next/server/app/[locale]/jira/cache/tickets/[issueKey]/page_client-reference-manifest.js +1 -1
  46. package/standalone/.next/server/app/[locale]/jira/tickets/[issueKey]/page.js +2 -1
  47. package/standalone/.next/server/app/[locale]/jira/tickets/[issueKey]/page.js.nft.json +1 -1
  48. package/standalone/.next/server/app/[locale]/jira/tickets/[issueKey]/page_client-reference-manifest.js +1 -1
  49. package/standalone/.next/server/app/[locale]/jira/tickets/page.js +2 -1
  50. package/standalone/.next/server/app/[locale]/jira/tickets/page.js.nft.json +1 -1
  51. package/standalone/.next/server/app/[locale]/jira/tickets/page_client-reference-manifest.js +1 -1
  52. package/standalone/.next/server/app/[locale]/jobs/[jobId]/page.js +2 -1
  53. package/standalone/.next/server/app/[locale]/jobs/[jobId]/page.js.nft.json +1 -1
  54. package/standalone/.next/server/app/[locale]/jobs/[jobId]/page_client-reference-manifest.js +1 -1
  55. package/standalone/.next/server/app/[locale]/page.js +2 -1
  56. package/standalone/.next/server/app/[locale]/page.js.nft.json +1 -1
  57. package/standalone/.next/server/app/[locale]/page_client-reference-manifest.js +1 -1
  58. package/standalone/.next/server/app/[locale]/pull-requests/[owner]/[repository]/[number]/page.js +2 -1
  59. package/standalone/.next/server/app/[locale]/pull-requests/[owner]/[repository]/[number]/page.js.nft.json +1 -1
  60. package/standalone/.next/server/app/[locale]/pull-requests/[owner]/[repository]/[number]/page_client-reference-manifest.js +1 -1
  61. package/standalone/.next/server/app/[locale]/pull-requests/page.js +2 -1
  62. package/standalone/.next/server/app/[locale]/pull-requests/page.js.nft.json +1 -1
  63. package/standalone/.next/server/app/[locale]/pull-requests/page_client-reference-manifest.js +1 -1
  64. package/standalone/.next/server/app/[locale]/settings/page.js +2 -1
  65. package/standalone/.next/server/app/[locale]/settings/page.js.nft.json +1 -1
  66. package/standalone/.next/server/app/[locale]/settings/page_client-reference-manifest.js +1 -1
  67. package/standalone/.next/server/app/[locale]/skills/[skillId]/page.js +2 -1
  68. package/standalone/.next/server/app/[locale]/skills/[skillId]/page.js.nft.json +1 -1
  69. package/standalone/.next/server/app/[locale]/skills/[skillId]/page_client-reference-manifest.js +1 -1
  70. package/standalone/.next/server/app/[locale]/skills/groups/[groupId]/page.js +2 -1
  71. package/standalone/.next/server/app/[locale]/skills/groups/[groupId]/page.js.nft.json +1 -1
  72. package/standalone/.next/server/app/[locale]/skills/groups/[groupId]/page_client-reference-manifest.js +1 -1
  73. package/standalone/.next/server/app/[locale]/skills/groups/page.js +2 -1
  74. package/standalone/.next/server/app/[locale]/skills/groups/page.js.nft.json +1 -1
  75. package/standalone/.next/server/app/[locale]/skills/groups/page_client-reference-manifest.js +1 -1
  76. package/standalone/.next/server/app/[locale]/skills/page.js +2 -1
  77. package/standalone/.next/server/app/[locale]/skills/page.js.nft.json +1 -1
  78. package/standalone/.next/server/app/[locale]/skills/page_client-reference-manifest.js +1 -1
  79. package/standalone/.next/server/app/[locale]/skills/sync/[runId]/page.js +2 -1
  80. package/standalone/.next/server/app/[locale]/skills/sync/[runId]/page.js.nft.json +1 -1
  81. package/standalone/.next/server/app/[locale]/skills/sync/[runId]/page_client-reference-manifest.js +1 -1
  82. package/standalone/.next/server/app/[locale]/tools/page.js +2 -1
  83. package/standalone/.next/server/app/[locale]/tools/page.js.nft.json +1 -1
  84. package/standalone/.next/server/app/[locale]/tools/page_client-reference-manifest.js +1 -1
  85. package/standalone/.next/server/app/[locale]/usage/page.js +2 -1
  86. package/standalone/.next/server/app/[locale]/usage/page.js.nft.json +1 -1
  87. package/standalone/.next/server/app/[locale]/usage/page_client-reference-manifest.js +1 -1
  88. package/standalone/.next/server/app/[locale]/worktrees/[worktreeId]/page.js +2 -1
  89. package/standalone/.next/server/app/[locale]/worktrees/[worktreeId]/page.js.nft.json +1 -1
  90. package/standalone/.next/server/app/[locale]/worktrees/[worktreeId]/page_client-reference-manifest.js +1 -1
  91. package/standalone/.next/server/app/[locale]/worktrees/page.js +2 -1
  92. package/standalone/.next/server/app/[locale]/worktrees/page.js.nft.json +1 -1
  93. package/standalone/.next/server/app/[locale]/worktrees/page_client-reference-manifest.js +1 -1
  94. package/standalone/.next/server/app/_global-error.html +1 -1
  95. package/standalone/.next/server/app/_global-error.rsc +1 -1
  96. package/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  97. package/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  98. package/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  99. package/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  100. package/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  101. package/standalone/.next/server/app/_not-found.html +1 -1
  102. package/standalone/.next/server/app/_not-found.rsc +1 -1
  103. package/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  104. package/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  105. package/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  106. package/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  107. package/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  108. package/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  109. package/standalone/.next/server/app/api/build-artifact-uploads/[uploadId]/route.js +1 -1
  110. package/standalone/.next/server/app/api/build-artifact-uploads/[uploadId]/route.js.nft.json +1 -1
  111. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/manifest.plist/route/app-paths-manifest.json +3 -0
  112. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/manifest.plist/route/build-manifest.json +9 -0
  113. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/manifest.plist/route/server-reference-manifest.json +4 -0
  114. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/manifest.plist/route.js +11 -0
  115. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/manifest.plist/route.js.map +5 -0
  116. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/manifest.plist/route.js.nft.json +1 -0
  117. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/manifest.plist/route_client-reference-manifest.js +3 -0
  118. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/route.js +4 -3
  119. package/standalone/.next/server/app/api/builds/[buildId]/artifacts/[artifactId]/route.js.nft.json +1 -1
  120. package/standalone/.next/server/app/api/codebases/by-path/route.js.nft.json +1 -1
  121. package/standalone/.next/server/app/api/codebases/route.js.nft.json +1 -1
  122. package/standalone/.next/server/app/api/graphql/route.js.nft.json +1 -1
  123. package/standalone/.next/server/app/api/mcp/route.js.nft.json +1 -1
  124. package/standalone/.next/server/app/api/tools/call/route.js.nft.json +1 -1
  125. package/standalone/.next/server/app/api/tools/catalog/route.js.nft.json +1 -1
  126. package/standalone/.next/server/app/api/worktrees/[worktreeId]/diff-image/route.js +1 -1
  127. package/standalone/.next/server/app/api/worktrees/[worktreeId]/diff-image/route.js.nft.json +1 -1
  128. package/standalone/.next/server/app-paths-manifest.json +1 -0
  129. package/standalone/.next/server/chunks/07_e_builds_[buildId]_artifacts_[artifactId]_manifest_plist_route_actions_18mzaf3.js +3 -0
  130. package/standalone/.next/server/chunks/[root-of-the-server]__08mnmfs._.js +3 -0
  131. package/standalone/.next/server/chunks/[root-of-the-server]__0906ea6._.js +3 -0
  132. package/standalone/.next/server/chunks/{[root-of-the-server]__09skp6r._.js → [root-of-the-server]__0cz4ap5._.js} +1 -1
  133. package/standalone/.next/server/chunks/[root-of-the-server]__0nvcazp._.js +3 -0
  134. package/standalone/.next/server/chunks/{[root-of-the-server]__1wtng3p._.js → [root-of-the-server]__12807ia._.js} +1 -1
  135. package/standalone/.next/server/chunks/[root-of-the-server]__1csx222._.js +3 -0
  136. package/standalone/.next/server/chunks/{[root-of-the-server]__0zyqmd4._.js → [root-of-the-server]__1ix62p0._.js} +3 -3
  137. package/standalone/.next/server/chunks/[root-of-the-server]__1j-hk6b._.js +3 -0
  138. package/standalone/.next/server/chunks/{[root-of-the-server]__15aat79._.js → [root-of-the-server]__1j0u03u._.js} +1 -1
  139. package/standalone/.next/server/chunks/[root-of-the-server]__1qmvsu_._.js +3 -0
  140. package/standalone/.next/server/chunks/[root-of-the-server]__1w59u6o._.js +3 -0
  141. package/standalone/.next/server/chunks/{[root-of-the-server]__0-ypkf7._.js → [root-of-the-server]__1z-3x8k._.js} +1 -1
  142. package/standalone/.next/server/chunks/_0exkna-._.js +51 -1
  143. package/standalone/.next/server/chunks/_0jurkow._.js +1 -1
  144. package/standalone/.next/server/chunks/_0rx5rfm._.js +1 -1
  145. package/standalone/.next/server/chunks/_0tufgj0._.js +111 -61
  146. package/standalone/.next/server/chunks/_0w2eghf._.js +51 -1
  147. package/standalone/.next/server/chunks/_152rnkf._.js +3 -3
  148. package/standalone/.next/server/chunks/_16x77b_._.js +1 -1
  149. package/standalone/.next/server/chunks/_1dbqy8c._.js +3 -3
  150. package/standalone/.next/server/chunks/_1vrixpc._.js +3 -3
  151. package/standalone/.next/server/chunks/_1yfz-9u._.js +3 -3
  152. package/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_08x37wy.js +6 -0
  153. package/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_0eghv0a.js +3 -0
  154. package/standalone/.next/server/chunks/ssr/[root-of-the-server]__08bbv6z._.js +1 -1
  155. package/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ckj6wg._.js +1 -1
  156. package/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qy28ku._.js +3 -0
  157. package/standalone/.next/server/chunks/ssr/[root-of-the-server]__1potsqg._.js +1 -1
  158. package/standalone/.next/server/chunks/ssr/_0a1ligo._.js +1 -1
  159. package/standalone/.next/server/chunks/ssr/{_1n9rteq._.js → _1f5k6bj._.js} +2 -2
  160. package/standalone/.next/server/chunks/ssr/messages_de_json_[json]_cjs_1uig1ud._.js +1 -1
  161. package/standalone/.next/server/chunks/ssr/messages_en_json_[json]_cjs_206072-._.js +1 -1
  162. package/standalone/.next/server/chunks/ssr/messages_es_json_[json]_cjs_082yukd._.js +1 -1
  163. package/standalone/.next/server/chunks/ssr/messages_fr_json_[json]_cjs_0j6-w0i._.js +1 -1
  164. package/standalone/.next/server/chunks/ssr/node_modules_0obs0k7._.js +5 -0
  165. package/standalone/.next/server/chunks/ssr/node_modules_next_1ep9xf4._.js +3 -0
  166. package/standalone/.next/server/chunks/ssr/src_components_builds_build-detail-page_tsx_0q1lvps._.js +20 -10
  167. package/standalone/.next/server/functions-config-manifest.json +5 -2
  168. package/standalone/.next/server/middleware-build-manifest.js +3 -3
  169. package/standalone/.next/server/pages/404.html +1 -1
  170. package/standalone/.next/server/pages/500.html +1 -1
  171. package/standalone/.next/server/server-reference-manifest.js +1 -1
  172. package/standalone/.next/server/server-reference-manifest.json +1 -1
  173. package/standalone/.next/static/chunks/{1ut0xkaqsykm4.js → 0mq066ue0yufb.js} +1 -1
  174. package/standalone/.next/static/chunks/{041g9raf1naqv.js → 175269y15-grc.js} +1 -1
  175. package/standalone/.next/static/chunks/17t4ft9o1tqw3.js +35 -0
  176. package/standalone/.next/static/chunks/{3q0q6_ntq8aa5.js → 208k3moxxd_53.js} +1 -1
  177. package/standalone/.next/static/chunks/2zwijj9t6y7gn.js +7 -0
  178. package/standalone/.next/static/chunks/398uw5_4-ciaz.css +1 -0
  179. package/standalone/.next/static/chunks/{29zl8lgsa28tb.js → 3l2g83119oyl0.js} +1 -1
  180. package/standalone/src/services/builds/artifact-cache.test.ts +271 -0
  181. package/standalone/src/services/builds/artifact-cache.ts +272 -0
  182. package/standalone/src/services/builds/artifact-transfer.ts +2 -1
  183. package/standalone/src/services/builds/builds.service.ts +139 -56
  184. package/standalone/.next/server/chunks/[root-of-the-server]__02vqwey._.js +0 -3
  185. package/standalone/.next/server/chunks/[root-of-the-server]__0rwpsai._.js +0 -3
  186. package/standalone/.next/server/chunks/ssr/[root-of-the-server]__10-ftkj._.js +0 -3
  187. package/standalone/.next/server/chunks/ssr/node_modules_1n5939e._.js +0 -5
  188. package/standalone/.next/static/chunks/3p7wbgo7qd6_m.css +0 -1
  189. package/standalone/.next/static/chunks/3vg6y29m047x9.js +0 -31
  190. /package/standalone/.next/static/{ePu377E66yy0icQx3ykQJ → qUqJkVkzos1XoXFl7PSxx}/_buildManifest.js +0 -0
  191. /package/standalone/.next/static/{ePu377E66yy0icQx3ykQJ → qUqJkVkzos1XoXFl7PSxx}/_clientMiddlewareManifest.js +0 -0
  192. /package/standalone/.next/static/{ePu377E66yy0icQx3ykQJ → qUqJkVkzos1XoXFl7PSxx}/_ssgManifest.js +0 -0
@@ -0,0 +1,271 @@
1
+ import {
2
+ readFile,
3
+ readdir,
4
+ rm,
5
+ stat,
6
+ utimes,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
13
+
14
+ const getServerServices = vi.fn();
15
+ vi.mock("@/services/server-services", () => ({
16
+ getServerServices: () => getServerServices(),
17
+ }));
18
+
19
+ const fsMocks = vi.hoisted(() => ({ rename: vi.fn() }));
20
+ vi.mock("node:fs/promises", async (importOriginal) => {
21
+ const actual = await importOriginal<typeof import("node:fs/promises")>();
22
+ fsMocks.rename.mockImplementation(actual.rename);
23
+ return { ...actual, rename: fsMocks.rename };
24
+ });
25
+
26
+ import { receiveArtifactTransfer } from "./artifact-transfer";
27
+
28
+ // Isolated per process so concurrent runs cannot evict each other's entries.
29
+ const CACHE_DIRECTORY = join(
30
+ tmpdir(),
31
+ `ade-artifact-cache-test-${process.pid}`,
32
+ );
33
+ process.env.ARTIFACT_CACHE_DIRECTORY = CACHE_DIRECTORY;
34
+
35
+ type ArtifactRow = {
36
+ id: string;
37
+ kind: string;
38
+ relativePath: string;
39
+ sizeBytes: number | null;
40
+ checksum: string | null;
41
+ createdAt: string;
42
+ metadata: Record<string, unknown>;
43
+ };
44
+
45
+ let artifactRow: ArtifactRow;
46
+ let downloads: number;
47
+ let uploadBody: string;
48
+ /** Resolves once prepareArtifactDownload has been entered, per call. */
49
+ let downloadStarted: (() => void)[];
50
+
51
+ function services() {
52
+ return {
53
+ buildsService: {
54
+ artifactForInstall: vi.fn(async () => artifactRow),
55
+ getBuild: vi.fn(async () => ({ id: "build-1", agentId: "agent-1" })),
56
+ prepareArtifactDownload: vi.fn(
57
+ async (
58
+ _buildId: string,
59
+ _artifactId: string,
60
+ uploadId: string,
61
+ ): Promise<void> => {
62
+ downloads += 1;
63
+ downloadStarted.shift()?.();
64
+ // Stand in for the agent posting the bytes back to the control plane.
65
+ await receiveArtifactTransfer(
66
+ uploadId,
67
+ "agent-1",
68
+ new Request("http://control.test/upload", {
69
+ method: "POST",
70
+ body: uploadBody,
71
+ headers: {
72
+ "content-length": String(uploadBody.length),
73
+ "content-type": "application/octet-stream",
74
+ "x-artifact-filename": encodeURIComponent("App.ipa"),
75
+ },
76
+ }),
77
+ );
78
+ },
79
+ ),
80
+ },
81
+ };
82
+ }
83
+
84
+ async function importCache() {
85
+ return import("./artifact-cache");
86
+ }
87
+
88
+ beforeEach(async () => {
89
+ vi.resetModules();
90
+ downloads = 0;
91
+ downloadStarted = [];
92
+ uploadBody = "ipa-bytes";
93
+ artifactRow = {
94
+ id: "artifact-1",
95
+ kind: "IPA",
96
+ relativePath: "exports/export-1/App.ipa",
97
+ sizeBytes: 9,
98
+ checksum: "checksum-1",
99
+ createdAt: "2026-07-19T00:00:00.000Z",
100
+ metadata: {},
101
+ };
102
+ getServerServices.mockImplementation(services);
103
+ fsMocks.rename.mockClear();
104
+ await rm(CACHE_DIRECTORY, { recursive: true, force: true });
105
+ });
106
+
107
+ afterEach(async () => {
108
+ await rm(CACHE_DIRECTORY, { recursive: true, force: true });
109
+ });
110
+
111
+ describe("materializeArtifact", () => {
112
+ test("transfers from the agent on a miss and serves the bytes", async () => {
113
+ const { materializeArtifact } = await importCache();
114
+ const artifact = await materializeArtifact("build-1", "artifact-1");
115
+
116
+ expect(downloads).toBe(1);
117
+ expect(artifact).toMatchObject({
118
+ filename: "App.ipa",
119
+ contentType: "application/octet-stream",
120
+ size: 9,
121
+ });
122
+ await expect(readFile(artifact.path, "utf8")).resolves.toBe("ipa-bytes");
123
+ });
124
+
125
+ test("copies across filesystems and removes transfer staging files", async () => {
126
+ fsMocks.rename.mockRejectedValueOnce(
127
+ Object.assign(new Error("Cross-device link"), { code: "EXDEV" }),
128
+ );
129
+ const { materializeArtifact } = await importCache();
130
+ const artifact = await materializeArtifact("build-1", "artifact-1");
131
+ const source = String(fsMocks.rename.mock.calls[0]?.[0]);
132
+
133
+ await expect(readFile(artifact.path, "utf8")).resolves.toBe("ipa-bytes");
134
+ await expect(stat(source)).rejects.toThrow();
135
+ await expect(readdir(CACHE_DIRECTORY)).resolves.not.toContainEqual(
136
+ expect.stringContaining(".incoming"),
137
+ );
138
+ });
139
+
140
+ test("serves a second request from cache without contacting the agent", async () => {
141
+ const { materializeArtifact } = await importCache();
142
+ const first = await materializeArtifact("build-1", "artifact-1");
143
+ const second = await materializeArtifact("build-1", "artifact-1");
144
+
145
+ expect(downloads).toBe(1);
146
+ expect(second.path).toBe(first.path);
147
+ expect(second.etag).toBe(first.etag);
148
+ await expect(readFile(second.path, "utf8")).resolves.toBe("ipa-bytes");
149
+ });
150
+
151
+ test("collapses concurrent requests into a single transfer", async () => {
152
+ const { materializeArtifact } = await importCache();
153
+ const entered = new Promise<void>((resolve) => {
154
+ downloadStarted.push(resolve);
155
+ });
156
+
157
+ const first = materializeArtifact("build-1", "artifact-1");
158
+ await entered;
159
+ const second = materializeArtifact("build-1", "artifact-1");
160
+ const [left, right] = await Promise.all([first, second]);
161
+
162
+ expect(downloads).toBe(1);
163
+ expect(right.path).toBe(left.path);
164
+ });
165
+
166
+ test("re-transfers when the checksum changes", async () => {
167
+ const { materializeArtifact } = await importCache();
168
+ await materializeArtifact("build-1", "artifact-1");
169
+
170
+ artifactRow = { ...artifactRow, checksum: "checksum-2" };
171
+ uploadBody = "new-bytes";
172
+ artifactRow.sizeBytes = uploadBody.length;
173
+ const updated = await materializeArtifact("build-1", "artifact-1");
174
+
175
+ expect(downloads).toBe(2);
176
+ await expect(readFile(updated.path, "utf8")).resolves.toBe("new-bytes");
177
+ });
178
+
179
+ test("falls back to createdAt when the artifact has no checksum", async () => {
180
+ artifactRow = { ...artifactRow, checksum: null };
181
+ const { materializeArtifact } = await importCache();
182
+ await materializeArtifact("build-1", "artifact-1");
183
+ await materializeArtifact("build-1", "artifact-1");
184
+
185
+ expect(downloads).toBe(1);
186
+ });
187
+
188
+ test("re-transfers when the cached body no longer matches its sidecar", async () => {
189
+ const { materializeArtifact } = await importCache();
190
+ const artifact = await materializeArtifact("build-1", "artifact-1");
191
+
192
+ // A truncated body must not be served as if it were complete.
193
+ await writeFile(artifact.path, "short");
194
+ await materializeArtifact("build-1", "artifact-1");
195
+
196
+ expect(downloads).toBe(2);
197
+ });
198
+
199
+ test("re-transfers when the sidecar is missing", async () => {
200
+ const { materializeArtifact } = await importCache();
201
+ const artifact = await materializeArtifact("build-1", "artifact-1");
202
+
203
+ await rm(`${artifact.path}.json`, { force: true });
204
+ await materializeArtifact("build-1", "artifact-1");
205
+
206
+ expect(downloads).toBe(2);
207
+ });
208
+
209
+ test("evicts the least recently used entries past the size budget", async () => {
210
+ process.env.ARTIFACT_CACHE_MAX_BYTES = "12";
211
+ try {
212
+ vi.resetModules();
213
+ const { materializeArtifact } = await importCache();
214
+ const first = await materializeArtifact("build-1", "artifact-1");
215
+
216
+ // Age the first entry so the prune sees it as least recently used.
217
+ const old = new Date(Date.now() - 60_000);
218
+ await utimes(first.path, old, old);
219
+
220
+ artifactRow = { ...artifactRow, checksum: "checksum-2" };
221
+ await materializeArtifact("build-1", "artifact-1");
222
+
223
+ await expect(stat(first.path)).rejects.toThrow();
224
+ } finally {
225
+ delete process.env.ARTIFACT_CACHE_MAX_BYTES;
226
+ }
227
+ });
228
+
229
+ test("rejects when the artifact does not exist", async () => {
230
+ getServerServices.mockImplementation(() => ({
231
+ buildsService: {
232
+ ...services().buildsService,
233
+ artifactForInstall: vi.fn(async () => null),
234
+ },
235
+ }));
236
+ const { materializeArtifact } = await importCache();
237
+ await expect(materializeArtifact("build-1", "missing")).rejects.toThrow(
238
+ "not found",
239
+ );
240
+ });
241
+
242
+ test("does not leave a failed transfer in flight", async () => {
243
+ getServerServices.mockImplementation(() => ({
244
+ buildsService: {
245
+ ...services().buildsService,
246
+ prepareArtifactDownload: vi.fn(async () => {
247
+ throw new Error("Build agent is offline");
248
+ }),
249
+ },
250
+ }));
251
+ const { materializeArtifact } = await importCache();
252
+
253
+ await expect(materializeArtifact("build-1", "artifact-1")).rejects.toThrow(
254
+ "offline",
255
+ );
256
+ // A retry must be attempted rather than resolving the rejected promise again.
257
+ await expect(materializeArtifact("build-1", "artifact-1")).rejects.toThrow(
258
+ "offline",
259
+ );
260
+ });
261
+ });
262
+
263
+ describe("cache directory", () => {
264
+ test("is created with restrictive permissions", async () => {
265
+ const { materializeArtifact } = await importCache();
266
+ await materializeArtifact("build-1", "artifact-1");
267
+
268
+ const information = await stat(CACHE_DIRECTORY);
269
+ expect(information.mode & 0o777).toBe(0o700);
270
+ });
271
+ });
@@ -0,0 +1,272 @@
1
+ import "server-only";
2
+
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import {
5
+ copyFile,
6
+ mkdir,
7
+ readdir,
8
+ readFile,
9
+ rename,
10
+ rm,
11
+ stat,
12
+ utimes,
13
+ writeFile,
14
+ } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ import {
19
+ cancelArtifactTransfer,
20
+ expectArtifactTransfer,
21
+ } from "./artifact-transfer";
22
+ import { getServerServices } from "@/services/server-services";
23
+
24
+ export type MaterializedArtifact = {
25
+ path: string;
26
+ filename: string;
27
+ contentType: string;
28
+ size: number;
29
+ etag: string;
30
+ };
31
+
32
+ type Sidecar = {
33
+ filename: string;
34
+ contentType: string;
35
+ size: number;
36
+ };
37
+
38
+ // Overridable so the cache can be pointed at a volume with room for large
39
+ // packages, rather than whatever the temporary directory happens to allow.
40
+ const CACHE_DIRECTORY =
41
+ process.env.ARTIFACT_CACHE_DIRECTORY?.trim() ||
42
+ join(tmpdir(), "ade-build-artifact-cache");
43
+ const MAX_CACHE_BYTES =
44
+ Number(process.env.ARTIFACT_CACHE_MAX_BYTES) || 5 * 1024 ** 3;
45
+ const MAX_CACHE_AGE_MS = 6 * 60 * 60 * 1_000;
46
+
47
+ const globalCache = globalThis as typeof globalThis & {
48
+ buildArtifactMaterializations?: Map<string, Promise<MaterializedArtifact>>;
49
+ };
50
+ const inFlight =
51
+ globalCache.buildArtifactMaterializations ??
52
+ (globalCache.buildArtifactMaterializations = new Map());
53
+
54
+ /**
55
+ * Transfers are billed against wall-clock time on a link we do not control, so
56
+ * the ceiling scales with the artifact: two minutes of overhead plus roughly a
57
+ * second per megabyte, capped so a stalled agent cannot hold a request forever.
58
+ */
59
+ function transferTimeout(sizeBytes: number | null): number {
60
+ return Math.min(30 * 60_000, 120_000 + (sizeBytes ?? 0) / 1_000);
61
+ }
62
+
63
+ /**
64
+ * Uses an atomic rename when possible. A configured cache volume can live on a
65
+ * different filesystem from the upload receiver's temporary directory, in
66
+ * which case the bytes are staged on the cache filesystem before the final
67
+ * rename.
68
+ */
69
+ async function moveIntoCache(source: string, destination: string) {
70
+ try {
71
+ await rename(source, destination);
72
+ return;
73
+ } catch (error) {
74
+ if ((error as NodeJS.ErrnoException).code !== "EXDEV") throw error;
75
+ }
76
+
77
+ const staging = `${destination}.${randomUUID()}.incoming`;
78
+ try {
79
+ await copyFile(source, staging);
80
+ await rename(staging, destination);
81
+ await rm(source, { force: true });
82
+ } finally {
83
+ await rm(staging, { force: true }).catch(() => {});
84
+ }
85
+ }
86
+
87
+ function cacheKey(artifactId: string, identity: string): string {
88
+ return createHash("sha1").update(`${artifactId}:${identity}`).digest("hex");
89
+ }
90
+
91
+ async function readSidecar(path: string): Promise<Sidecar | null> {
92
+ try {
93
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
94
+ if (!parsed || typeof parsed !== "object") return null;
95
+ const { filename, contentType, size } = parsed as Record<string, unknown>;
96
+ if (
97
+ typeof filename !== "string" ||
98
+ typeof contentType !== "string" ||
99
+ typeof size !== "number"
100
+ ) {
101
+ return null;
102
+ }
103
+ return { filename, contentType, size };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ async function readCached(key: string): Promise<MaterializedArtifact | null> {
110
+ const path = join(CACHE_DIRECTORY, key);
111
+ const sidecar = await readSidecar(`${path}.json`);
112
+ if (!sidecar) return null;
113
+ try {
114
+ const information = await stat(path);
115
+ if (!information.isFile() || information.size !== sidecar.size) return null;
116
+ const now = new Date();
117
+ await utimes(path, now, now).catch(() => {});
118
+ return { path, etag: `"${key}"`, ...sidecar };
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Drops the least recently used entries once the cache exceeds its budget, and
126
+ * anything past the maximum age. Entries are several hundred megabytes each, so
127
+ * skipping this would fill the temporary directory within a handful of builds.
128
+ *
129
+ * `keep` is the entry the caller is about to serve. It is exempt so that an
130
+ * artifact larger than the whole budget is still returned rather than deleted
131
+ * out from under the response that just fetched it.
132
+ */
133
+ async function prune(keep: string): Promise<void> {
134
+ let entries;
135
+ try {
136
+ entries = await readdir(CACHE_DIRECTORY);
137
+ } catch {
138
+ return;
139
+ }
140
+ const files: {
141
+ path: string;
142
+ size: number;
143
+ mtimeMs: number;
144
+ keep: boolean;
145
+ }[] = [];
146
+ const expired = Date.now() - MAX_CACHE_AGE_MS;
147
+ for (const entry of entries) {
148
+ if (entry.endsWith(".json")) continue;
149
+ const path = join(CACHE_DIRECTORY, entry);
150
+ try {
151
+ const information = await stat(path);
152
+ if (!information.isFile()) continue;
153
+ // Cross-filesystem moves are copied into these staging files. Ignore an
154
+ // active copy, but clean up one left behind by a terminated process.
155
+ if (entry.endsWith(".incoming")) {
156
+ if (information.mtimeMs < expired) {
157
+ await rm(path, { force: true }).catch(() => {});
158
+ }
159
+ continue;
160
+ }
161
+ files.push({
162
+ path,
163
+ size: information.size,
164
+ mtimeMs: information.mtimeMs,
165
+ keep: entry === keep,
166
+ });
167
+ } catch {
168
+ continue;
169
+ }
170
+ }
171
+ files.sort((left, right) => left.mtimeMs - right.mtimeMs);
172
+ // The kept entry counts against the budget but is never a deletion candidate.
173
+ let total = files.reduce((sum, file) => sum + file.size, 0);
174
+ for (const file of files) {
175
+ if (file.keep) continue;
176
+ if (total <= MAX_CACHE_BYTES && file.mtimeMs >= expired) continue;
177
+ await rm(file.path, { force: true }).catch(() => {});
178
+ await rm(`${file.path}.json`, { force: true }).catch(() => {});
179
+ total -= file.size;
180
+ }
181
+ }
182
+
183
+ async function transfer(
184
+ buildId: string,
185
+ artifactId: string,
186
+ key: string,
187
+ sizeBytes: number | null,
188
+ ): Promise<MaterializedArtifact> {
189
+ const uploadId = randomUUID();
190
+ const services = getServerServices();
191
+ const build = await services.buildsService.getBuild(buildId);
192
+ if (!build?.agentId) throw new Error("Build agent is unavailable");
193
+
194
+ const timeoutMs = transferTimeout(sizeBytes);
195
+ let receivedPath: string | null = null;
196
+ try {
197
+ const [received] = await Promise.all([
198
+ expectArtifactTransfer(uploadId, build.agentId, timeoutMs),
199
+ services.buildsService.prepareArtifactDownload(
200
+ buildId,
201
+ artifactId,
202
+ uploadId,
203
+ timeoutMs,
204
+ ),
205
+ ]);
206
+ receivedPath = received.path;
207
+ await mkdir(CACHE_DIRECTORY, { recursive: true, mode: 0o700 });
208
+ const path = join(CACHE_DIRECTORY, key);
209
+ await moveIntoCache(received.path, path);
210
+ receivedPath = null;
211
+ const size = (await stat(path)).size;
212
+ const sidecar: Sidecar = {
213
+ // The agent chooses the filename — it appends .tar.gz when it packages a
214
+ // directory — so that decision has to survive into later cache hits.
215
+ filename: received.filename,
216
+ contentType: received.contentType,
217
+ size,
218
+ };
219
+ // Written after the rename so a torn write cannot leave a valid-looking entry.
220
+ await writeFile(`${path}.json`, JSON.stringify(sidecar), { mode: 0o600 });
221
+ // Awaited so the budget actually holds by the time the response is served;
222
+ // a directory scan is negligible next to the transfer that just finished.
223
+ await prune(key).catch(() => {});
224
+ return { path, etag: `"${key}"`, ...sidecar };
225
+ } catch (error) {
226
+ cancelArtifactTransfer(uploadId, error);
227
+ if (receivedPath) {
228
+ await rm(receivedPath, { force: true }).catch(() => {});
229
+ }
230
+ throw error;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Returns a local copy of a build artifact, fetching it from the agent only when
236
+ * it is not already cached.
237
+ *
238
+ * The agent holds the only copy, and every fetch costs a full upload of the
239
+ * whole file. iOS issues a probe plus several ranged requests per install, so
240
+ * serving those directly from the relay would re-transfer the package several
241
+ * times over. Caching first makes range requests answerable at all, and collapses
242
+ * concurrent requests for the same artifact into one transfer.
243
+ */
244
+ export async function materializeArtifact(
245
+ buildId: string,
246
+ artifactId: string,
247
+ ): Promise<MaterializedArtifact> {
248
+ const services = getServerServices();
249
+ const artifact = await services.buildsService.artifactForInstall(
250
+ buildId,
251
+ artifactId,
252
+ );
253
+ if (!artifact) throw new Error("Build artifact not found");
254
+
255
+ const key = cacheKey(artifactId, artifact.checksum ?? artifact.createdAt);
256
+ const cached = await readCached(key);
257
+ if (cached) return cached;
258
+
259
+ const existing = inFlight.get(key);
260
+ if (existing) return existing;
261
+
262
+ const pending = transfer(
263
+ buildId,
264
+ artifactId,
265
+ key,
266
+ artifact.sizeBytes,
267
+ ).finally(() => {
268
+ inFlight.delete(key);
269
+ });
270
+ inFlight.set(key, pending);
271
+ return pending;
272
+ }
@@ -31,12 +31,13 @@ const transfers =
31
31
  export function expectArtifactTransfer(
32
32
  uploadId: string,
33
33
  agentId: string,
34
+ timeoutMs = 170_000,
34
35
  ): Promise<ArtifactTransfer> {
35
36
  return new Promise((resolve, reject) => {
36
37
  const timeout = setTimeout(() => {
37
38
  transfers.delete(uploadId);
38
39
  reject(new Error("The agent did not upload the artifact in time"));
39
- }, 170_000);
40
+ }, timeoutMs);
40
41
  timeout.unref();
41
42
  transfers.set(uploadId, { agentId, resolve, reject, timeout });
42
43
  });