@akanjs/devkit 3.1.0-rc.0 → 3.1.0-rc.10

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 (138) hide show
  1. package/abstractDoc.ts +1 -1
  2. package/agentsIndex.test.ts +4 -1
  3. package/akanConfig/akanConfig.test.ts +51 -5
  4. package/akanConfig/akanConfig.ts +33 -14
  5. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  6. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  7. package/akanContext.ts +4 -4
  8. package/akanMcpContract.ts +1 -1
  9. package/applicationBuildRunner.test.ts +57 -2
  10. package/applicationBuildRunner.ts +15 -6
  11. package/artifact/implicitRootLayout.test.ts +2 -0
  12. package/artifact/implicitRootLayout.ts +1 -0
  13. package/biome.base.json +1 -11
  14. package/biomeBase.ts +1 -1
  15. package/cloud/cloudApi.ts +25 -13
  16. package/cloud/cloudHttpError.test.ts +32 -0
  17. package/cloud/cloudHttpError.ts +54 -0
  18. package/cloud/cloudUpdates.ts +138 -0
  19. package/cloud/configLock.ts +2 -1
  20. package/cloud/globalConfig.test.ts +17 -0
  21. package/cloud/globalConfig.ts +9 -2
  22. package/cloud/index.ts +3 -0
  23. package/cloud/pipelineSecrets.ts +114 -0
  24. package/cloud/updatePackUpload.test.ts +151 -41
  25. package/cloud/updatePackUpload.ts +60 -23
  26. package/codeAgent/agent/CodeAgentProxy.ts +2 -2
  27. package/codeAgent/agent/akanCodeModel.ts +1 -1
  28. package/codeAgent/agent/akanContextFiles.test.ts +3 -1
  29. package/codeAgent/tools/WebToolPack.ts +4 -4
  30. package/codeAgent/tools/akanCodePlugins.test.ts +20 -16
  31. package/commandDecorators/argMeta.ts +11 -2
  32. package/commandDecorators/command.ts +45 -10
  33. package/commandDecorators/commandBuilder.ts +6 -1
  34. package/commandDecorators/commandDecorators.test.ts +205 -4
  35. package/commandDecorators/exitCodeError.ts +15 -0
  36. package/commandDecorators/index.ts +1 -0
  37. package/createTunnel.test.ts +14 -0
  38. package/createTunnel.ts +3 -1
  39. package/dependencyScanner.ts +1 -1
  40. package/devkitUtils.test.ts +1 -1
  41. package/executors.test.ts +131 -3
  42. package/executors.ts +56 -36
  43. package/frontendBuild/autoImportSync.test.ts +3 -1
  44. package/frontendBuild/clientEntryDiscovery.ts +1 -1
  45. package/frontendBuild/csrArtifactBuilder.test.ts +30 -0
  46. package/frontendBuild/csrArtifactBuilder.ts +23 -4
  47. package/frontendBuild/csrDevPatcher.test.ts +4 -1
  48. package/frontendBuild/cssBlockParser.test.ts +123 -0
  49. package/frontendBuild/cssBlockParser.ts +132 -0
  50. package/frontendBuild/cssCompiler.test.ts +53 -0
  51. package/frontendBuild/cssCompiler.ts +8 -4
  52. package/frontendBuild/fontOptimizer.ts +2 -1
  53. package/frontendBuild/frontendBuild.test.ts +14 -0
  54. package/frontendBuild/hmrWatcher.ts +1 -1
  55. package/frontendBuild/pagesBundleBuilder.test.ts +98 -0
  56. package/frontendBuild/pagesBundleBuilder.ts +64 -9
  57. package/frontendBuild/routeClientBuilder.ts +1 -1
  58. package/frontendBuild/sourceMtimeIndex.test.ts +1 -1
  59. package/frontendBuild/themeValidator.test.ts +83 -2
  60. package/frontendBuild/themeValidator.ts +37 -15
  61. package/getRelatedCnsts.ts +2 -1
  62. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  63. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  64. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  65. package/integration/devStabilityHarness.ts +5 -3
  66. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +1 -0
  67. package/lint/__fixtures__/no-deep-internal-import/good.tsx +1 -0
  68. package/lint/__fixtures__/no-import-client-functions/bad.tsx +1 -0
  69. package/lint/__fixtures__/no-import-client-functions/good.tsx +1 -0
  70. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +1 -0
  71. package/lint/__fixtures__/no-import-client-in-server/good.tsx +1 -0
  72. package/lint/__fixtures__/no-import-external-library/bad.tsx +1 -0
  73. package/lint/__fixtures__/no-import-external-library/good.tsx +1 -0
  74. package/lint/__fixtures__/no-import-external-library-in-test/bad.tsx +1 -0
  75. package/lint/__fixtures__/no-import-external-library-in-test/good.tsx +1 -0
  76. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +1 -0
  77. package/lint/__fixtures__/no-import-server-in-client/good.tsx +1 -0
  78. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -1
  79. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +2 -2
  80. package/mobile/desktopServerStage.test.ts +13 -0
  81. package/mobile/desktopServerStage.ts +5 -7
  82. package/mobile/index.ts +2 -0
  83. package/mobile/mobileTarget.ts +1 -2
  84. package/mobile/nativeApi.test.ts +26 -0
  85. package/mobile/nativeApi.ts +5 -2
  86. package/mobile/nativeApp.test.ts +135 -0
  87. package/mobile/nativeApp.ts +109 -63
  88. package/mobile/nativeConfig.test.ts +3 -0
  89. package/mobile/nativeConfig.ts +5 -3
  90. package/mobile/nativePublicEnv.test.ts +131 -0
  91. package/mobile/nativePublicEnv.ts +116 -0
  92. package/mobile/nativeWebDir.ts +3 -2
  93. package/mobile/serverBundleSecrets.test.ts +79 -0
  94. package/mobile/serverBundleSecrets.ts +60 -16
  95. package/mobile/updatePackFolder.test.ts +98 -0
  96. package/mobile/updatePackFolder.ts +131 -0
  97. package/mobile/updateUploader.test.ts +115 -22
  98. package/mobile/updateUploader.ts +77 -10
  99. package/package.json +20 -20
  100. package/piDependencyPins.test.ts +18 -19
  101. package/pipeline/PipelineCompiler.test.ts +157 -1
  102. package/pipeline/PipelineCompiler.ts +80 -46
  103. package/pipeline/PipelineDefinition.test.ts +9 -0
  104. package/pipeline/PipelineDefinition.ts +4 -3
  105. package/pipeline/PipelineDryRun.test.ts +27 -0
  106. package/pipeline/PipelineDryRun.ts +2 -2
  107. package/pipeline/PipelineInputReader.ts +157 -253
  108. package/pipeline/PipelineRules.ts +92 -0
  109. package/pipeline/index.ts +1 -0
  110. package/platformTest/LinuxDockerTarget.ts +2 -1
  111. package/platformTest/PlatformTestTarget.ts +7 -5
  112. package/platformTest/WindowsSshTarget.ts +2 -1
  113. package/qualityScanner.test.ts +5 -3
  114. package/qualityScanner.ts +1 -1
  115. package/recipeScanner.test.ts +9 -3
  116. package/scanInfo.ts +1 -1
  117. package/slicePlanner.ts +5 -3
  118. package/subspace.test.ts +90 -19
  119. package/subspace.ts +52 -500
  120. package/subspaceClone.ts +362 -0
  121. package/subspaceConfig.ts +1 -2
  122. package/subspaceReport.ts +162 -0
  123. package/testHelpers.ts +2 -0
  124. package/transforms/barrelImportsPlugin.ts +1 -1
  125. package/transforms/browserDirnamePlugin.test.ts +88 -0
  126. package/transforms/browserDirnamePlugin.ts +25 -0
  127. package/transforms/rscUseClientTransform.ts +2 -2
  128. package/transforms/serverEnvPlugin.test.ts +76 -0
  129. package/transforms/serverEnvPlugin.ts +9 -25
  130. package/transforms/serverEnvProfile.test.ts +189 -0
  131. package/transforms/serverEnvProfile.ts +324 -60
  132. package/transforms/transforms.test.ts +18 -2
  133. package/transforms/tsconfigPackageResolver.test.ts +9 -0
  134. package/typecheck/projectTypecheck.ts +6 -5
  135. package/types.ts +1 -0
  136. package/workflow/artifacts.ts +1 -1
  137. package/workflow/rolloutGate.test.ts +5 -1
  138. package/workflow/source.ts +2 -2
@@ -13,13 +13,15 @@ interface Put {
13
13
  body: Uint8Array;
14
14
  }
15
15
 
16
- class FakeAkasys {
16
+ class FakeCloudHost {
17
17
  readonly server: ReturnType<typeof Bun.serve>;
18
18
  readonly requests: Record<string, unknown>[] = [];
19
19
  readonly puts: Put[] = [];
20
20
  readonly commits: string[] = [];
21
- putStatuses: number[] = [];
22
- commitAnswers: number[] = [];
21
+ readonly registrations: Record<string, string>[] = [];
22
+ putStatuses: (number | Response)[] = [];
23
+ stallPuts: boolean = false;
24
+ commitAnswers: (number | [number, string] | Response)[] = [];
23
25
  release: Record<string, unknown> = { id: "release-1", status: "published", problems: [] };
24
26
  requestRefusal: Response | null = null;
25
27
 
@@ -35,7 +37,9 @@ class FakeAkasys {
35
37
  this.requests.length = 0;
36
38
  this.puts.length = 0;
37
39
  this.commits.length = 0;
40
+ this.registrations.length = 0;
38
41
  this.putStatuses = [];
42
+ this.stallPuts = false;
39
43
  this.commitAnswers = [];
40
44
  this.requestRefusal = null;
41
45
  }
@@ -59,23 +63,42 @@ class FakeAkasys {
59
63
  headers: Object.fromEntries(request.headers),
60
64
  body: new Uint8Array(await request.arrayBuffer()),
61
65
  });
62
- return new Response(null, { status: this.putStatuses.shift() ?? 200 });
66
+ if (this.stallPuts) return await new Promise<Response>(() => undefined);
67
+ const answer = this.putStatuses.shift() ?? 200;
68
+ return answer instanceof Response ? answer : new Response(null, { status: answer });
63
69
  }
64
70
  if (url.pathname === "/api/commitUpdateUpload/project-1") {
65
71
  const { uploadId } = (await request.json()) as { uploadId: string };
66
72
  this.commits.push(uploadId);
67
- const answer = this.commitAnswers.shift();
68
- if (answer) return Response.json({ error: "release.error.uploadCommitInProgress" }, { status: answer });
73
+ const answer = this.#answer();
74
+ if (answer) return answer;
69
75
  return Response.json({ ...this.release, uploadId });
70
76
  }
77
+ if (url.pathname === "/api/registerAppBinary/project-1") {
78
+ const form = await request.formData();
79
+ this.registrations.push(
80
+ Object.fromEntries(
81
+ [...form.entries()].map(([key, value]) => [key, typeof value === "string" ? value : "<file>"]),
82
+ ),
83
+ );
84
+ return this.#answer() ?? Response.json({ id: "binary-1" });
85
+ }
71
86
  return new Response("not found", { status: 404 });
72
87
  }
88
+
89
+ #answer() {
90
+ const answer = this.commitAnswers.shift();
91
+ if (!answer) return null;
92
+ if (answer instanceof Response) return answer;
93
+ const [status, key] = typeof answer === "number" ? [answer, "release.error.uploadCommitInProgress"] : answer;
94
+ return Response.json({ error: key }, { status });
95
+ }
73
96
  }
74
97
 
75
98
  const makeDir = tempDirs("akan-update-upload-");
76
- const akasys = new FakeAkasys();
99
+ const cloudHost = new FakeCloudHost();
77
100
  const api = new CloudApi({} as Workspace, {
78
- host: akasys.host,
101
+ host: cloudHost.host,
79
102
  auth: { accessToken: { jwt: "jwt-1", refreshToken: null, expiresAt: null } },
80
103
  });
81
104
  const fields = { appName: "lu", target: "default", platform: "android" as const, channel: "develop", notes: null };
@@ -87,8 +110,8 @@ const archiveOf = async (bytes: string) => {
87
110
  return archive;
88
111
  };
89
112
 
90
- beforeEach(() => akasys.reset());
91
- afterAll(() => akasys.server.stop(true));
113
+ beforeEach(() => cloudHost.reset());
114
+ afterAll(() => cloudHost.server.stop(true));
92
115
 
93
116
  describe("UpdatePackUpload", () => {
94
117
  test("announces the archive's size and digest, PUTs it with nothing but its content type, then commits", async () => {
@@ -97,7 +120,7 @@ describe("UpdatePackUpload", () => {
97
120
  const release = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
98
121
 
99
122
  expect(release).toEqual({ id: "release-1", status: "published", problems: [], uploadId: "upload-1" });
100
- expect(akasys.requests).toEqual([
123
+ expect(cloudHost.requests).toEqual([
101
124
  {
102
125
  ...fields,
103
126
  size: 10,
@@ -105,91 +128,178 @@ describe("UpdatePackUpload", () => {
105
128
  authorization: "Bearer jwt-1",
106
129
  },
107
130
  ]);
108
- const [put] = akasys.puts;
131
+ const [put] = cloudHost.puts;
109
132
  expect(new TextDecoder().decode(put?.body)).toBe("pack bytes");
110
133
  expect(put?.headers["content-type"]).toBe("application/gzip");
111
134
  expect(put?.headers["content-length"]).toBe("10");
112
135
  expect(put?.headers.authorization).toBeUndefined();
113
136
  expect(Object.keys(put?.headers ?? {}).filter((name) => name.startsWith("x-amz-"))).toEqual([]);
114
- expect(akasys.commits).toEqual(["upload-1"]);
137
+ expect(cloudHost.commits).toEqual(["upload-1"]);
115
138
  });
116
139
 
117
140
  test("takes one fresh ticket when storage answers 403, and gives up on the second without naming the URL", async () => {
118
141
  const archive = await archiveOf("pack");
119
- akasys.putStatuses = [403];
142
+ cloudHost.putStatuses = [403];
120
143
 
121
144
  await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
122
- expect(akasys.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-2"]);
123
- expect(akasys.commits).toEqual(["upload-2"]);
145
+ expect(cloudHost.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-2"]);
146
+ expect(cloudHost.commits).toEqual(["upload-2"]);
124
147
 
125
- akasys.reset();
126
- akasys.putStatuses = [403, 403];
148
+ cloudHost.reset();
149
+ cloudHost.putStatuses = [403, 403];
127
150
  const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
128
151
  expect(String(refusal)).toContain("HTTP 403");
129
152
  expect(String(refusal)).not.toContain("secret-signature");
130
- expect(akasys.commits).toEqual([]);
153
+ expect(cloudHost.commits).toEqual([]);
131
154
  });
132
155
 
133
156
  test("retries a PUT the way curl --retry does, and stops at a refusal that is not transient", async () => {
134
157
  const archive = await archiveOf("pack");
135
- akasys.putStatuses = [503, 502];
158
+ cloudHost.putStatuses = [503, 502];
136
159
  await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
137
- expect(akasys.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-1", "upload-1"]);
160
+ expect(cloudHost.puts.map(({ uploadId }) => uploadId)).toEqual(["upload-1", "upload-1", "upload-1"]);
138
161
 
139
- akasys.reset();
140
- akasys.putStatuses = [400];
162
+ cloudHost.reset();
163
+ cloudHost.putStatuses = [400];
141
164
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("HTTP 400");
142
- expect(akasys.puts).toHaveLength(1);
165
+ expect(cloudHost.puts).toHaveLength(1);
143
166
  });
144
167
 
145
168
  test("asks again with the same id while the commit is cut off or still running, but not past a real answer", async () => {
146
169
  const archive = await archiveOf("pack");
147
- akasys.commitAnswers = [524, 409, 502];
170
+ cloudHost.commitAnswers = [524, 409, 502];
148
171
  await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
149
- expect(akasys.commits).toEqual(["upload-1", "upload-1", "upload-1", "upload-1"]);
172
+ expect(cloudHost.commits).toEqual(["upload-1", "upload-1", "upload-1", "upload-1"]);
150
173
 
151
- akasys.reset();
152
- akasys.commitAnswers = [404];
174
+ cloudHost.reset();
175
+ cloudHost.commitAnswers = [404];
153
176
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("404");
154
- expect(akasys.commits).toEqual(["upload-1"]);
177
+ expect(cloudHost.commits).toEqual(["upload-1"]);
155
178
 
156
- akasys.reset();
157
- akasys.commitAnswers = Array.from({ length: 100 }, () => 524);
179
+ cloudHost.reset();
180
+ cloudHost.commitAnswers = Array.from({ length: 100 }, () => 524);
158
181
  const cutOff = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 35, commitLimitMs: 10_000 };
159
182
  await expect(new UpdatePackUpload(api, "project-1", cutOff).send(archive, fields)).rejects.toThrow("524");
160
- expect(akasys.commits.length).toBeLessThan(6);
183
+ expect(cloudHost.commits.length).toBeLessThan(6);
161
184
  });
162
185
 
163
186
  test("keeps waiting on a live claim past the cut-off window, up to the overall limit", async () => {
164
187
  const archive = await archiveOf("pack");
165
- akasys.commitAnswers = [...Array.from({ length: 6 }, () => 409), 524, ...Array.from({ length: 6 }, () => 409)];
188
+ cloudHost.commitAnswers = [...Array.from({ length: 6 }, () => 409), 524, ...Array.from({ length: 6 }, () => 409)];
166
189
  const timing = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 25, commitLimitMs: 10_000 };
167
190
  await new UpdatePackUpload(api, "project-1", timing).send(archive, fields);
168
- expect(akasys.commits).toHaveLength(14);
191
+ expect(cloudHost.commits).toHaveLength(14);
169
192
 
170
- akasys.reset();
171
- akasys.commitAnswers = Array.from({ length: 100 }, () => 409);
193
+ cloudHost.reset();
194
+ cloudHost.commitAnswers = Array.from({ length: 100 }, () => 409);
172
195
  const limited = { putRetryMs: 1, commitRetryMs: 10, cutOffWindowMs: 10_000, commitLimitMs: 35 };
173
196
  await expect(new UpdatePackUpload(api, "project-1", limited).send(archive, fields)).rejects.toThrow("409");
174
- expect(akasys.commits.length).toBeLessThan(6);
197
+ expect(cloudHost.commits.length).toBeLessThan(6);
198
+ });
199
+
200
+ test("gives up a stalled PUT after a time its size sets, and tries it again as a dropped one", async () => {
201
+ const archive = await archiveOf("pack");
202
+ cloudHost.stallPuts = true;
203
+
204
+ await expect(
205
+ new UpdatePackUpload(api, "project-1", { ...quick, putTimeoutMs: 50 }).send(archive, fields),
206
+ ).rejects.toThrow("no response");
207
+ expect(cloudHost.puts).toHaveLength(4);
208
+ expect(cloudHost.commits).toEqual([]);
209
+ });
210
+
211
+ test("asks again past a gateway's 503, but not past one the server answers with its own refusal", async () => {
212
+ const archive = await archiveOf("pack");
213
+ cloudHost.commitAnswers = [
214
+ new Response("No healthy federation child is ready", { status: 503 }),
215
+ [503, "base.error.serverUnavailable"],
216
+ ];
217
+ await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
218
+ expect(cloudHost.commits).toEqual(["upload-1", "upload-1", "upload-1"]);
219
+
220
+ cloudHost.reset();
221
+ cloudHost.commitAnswers = [[503, "release.error.signingUnavailable"]];
222
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow(
223
+ "release.error.signingUnavailable",
224
+ );
225
+ expect(cloudHost.commits).toEqual(["upload-1"]);
175
226
  });
176
227
 
177
228
  test("tells a server without staged uploads apart from one whose 404 is a refusal", async () => {
178
229
  const archive = await archiveOf("pack");
179
- akasys.requestRefusal = new Response("Not Found", { status: 404 });
230
+ cloudHost.requestRefusal = new Response("Not Found", { status: 404 });
180
231
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow(
181
232
  "does not take direct pack uploads yet",
182
233
  );
183
234
 
184
- akasys.requestRefusal = Response.json({ error: "devProject.error.appNotFound", statusCode: 404 }, { status: 404 });
235
+ cloudHost.requestRefusal = Response.json(
236
+ { error: "devProject.error.appNotFound", statusCode: 404 },
237
+ { status: 404 },
238
+ );
185
239
  const refusal = await new UpdatePackUpload(api, "project-1", quick).send(archive, fields).catch((e: Error) => e);
186
240
  expect(String(refusal)).toContain("devProject.error.appNotFound");
187
- expect(akasys.puts).toEqual([]);
241
+ expect(cloudHost.puts).toEqual([]);
242
+ });
243
+
244
+ test("stops at once on a 409 that is not a commit in progress: the upload is already used", async () => {
245
+ const archive = await archiveOf("pack");
246
+ cloudHost.commitAnswers = [[409, "release.error.uploadEmbedded"]];
247
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("uploadEmbedded");
248
+ expect(cloudHost.commits).toHaveLength(1);
249
+ });
250
+
251
+ test("stages a binary's pack and registers it by upload id, asking again while the server is still at it", async () => {
252
+ const archive = await archiveOf("pack");
253
+ cloudHost.commitAnswers = [524, 409];
254
+ const upload = new UpdatePackUpload(api, "project-1", quick);
255
+ const binary = await upload.stage(archive, fields).then((packUploadId) =>
256
+ upload.settle(() =>
257
+ api.registerAppBinary("project-1", {
258
+ appName: "lu",
259
+ target: "default",
260
+ distribution: "playTesting",
261
+ bundle: new Blob(["{}"]),
262
+ packUploadId,
263
+ channel: "develop",
264
+ }),
265
+ ),
266
+ );
267
+
268
+ expect(binary).toEqual({ id: "binary-1" });
269
+ expect(cloudHost.commits).toEqual([]);
270
+ expect(cloudHost.registrations).toHaveLength(3);
271
+ expect(cloudHost.registrations[2]).toEqual({
272
+ appName: "lu",
273
+ target: "default",
274
+ distribution: "playTesting",
275
+ channel: "develop",
276
+ bundle: "<file>",
277
+ packUploadId: "upload-1",
278
+ });
279
+ });
280
+
281
+ test("waits as long as storage's Retry-After asks before trying a PUT again", async () => {
282
+ const archive = await archiveOf("pack");
283
+ cloudHost.putStatuses = [new Response(null, { status: 429, headers: { "Retry-After": "1" } })];
284
+ const startedAt = performance.now();
285
+
286
+ await new UpdatePackUpload(api, "project-1", quick).send(archive, fields);
287
+
288
+ expect(performance.now() - startedAt).toBeGreaterThanOrEqual(950);
289
+ expect(cloudHost.puts).toHaveLength(2);
290
+ });
291
+
292
+ test("never asks again sooner than the server's Retry-After, nor past the limit it would wait to", async () => {
293
+ const archive = await archiveOf("pack");
294
+ cloudHost.commitAnswers = [new Response("busy", { status: 503, headers: { "Retry-After": "120" } })];
295
+
296
+ await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("503");
297
+ expect(cloudHost.commits).toEqual(["upload-1"]);
188
298
  });
189
299
 
190
300
  test("refuses an empty archive before asking for a ticket", async () => {
191
301
  const archive = await archiveOf("");
192
302
  await expect(new UpdatePackUpload(api, "project-1", quick).send(archive, fields)).rejects.toThrow("0 bytes");
193
- expect(akasys.requests).toEqual([]);
303
+ expect(cloudHost.requests).toEqual([]);
194
304
  });
195
305
  });
@@ -1,22 +1,30 @@
1
1
  import { Logger, sleep } from "akanjs/common";
2
2
  import type { BunFile } from "bun";
3
- import { CloudApi, type CloudRecord, type UpdateUploadRequest } from "./cloudApi";
3
+ import type { CloudApi, CloudRecord, UpdateUploadRequest } from "./cloudApi";
4
+ import { CloudHttpError } from "./cloudHttpError";
4
5
 
5
6
  interface UpdatePackUploadOptions {
6
7
  putRetryMs?: number;
8
+ putTimeoutMs?: number;
7
9
  commitRetryMs?: number;
8
10
  cutOffWindowMs?: number;
9
11
  commitLimitMs?: number;
10
12
  }
11
13
 
12
14
  //* akasys sits behind Cloudflare's 100 s request cut, so the pack goes straight to its staging bucket and akasys
13
- //* only sees two small JSON calls: request a presigned PUT, then commit the object, which akasys re-hashes.
15
+ //* only sees small calls: request a presigned PUT, then commit the object, or register a binary with it as its
16
+ //* embedded pack; either re-hashes it, and an upload is used once, one way.
14
17
  export class UpdatePackUpload {
15
18
  static readonly maxBytes = 200 * 1024 * 1024;
16
19
  static readonly #transientPut = new Set([0, 408, 429, 500, 502, 503, 504]);
20
+ //? A stalled PUT never fails on its own, so each try ends once the pack would be through even at this crawl.
21
+ static readonly #putFloorBytesPerSecond = 64 * 1024;
22
+ //? Storage's Retry-After is honoured up to this: past it the presigned URL may lapse, and a fresh one is asked for.
23
+ static readonly #maxPutRetryAfterMs = 60_000;
17
24
  readonly #api: CloudApi;
18
25
  readonly #devProjectId: string;
19
26
  readonly #putRetryMs: number;
27
+ readonly #putTimeoutMs: number;
20
28
  readonly #commitRetryMs: number;
21
29
  readonly #cutOffWindowMs: number;
22
30
  readonly #commitLimitMs: number;
@@ -26,6 +34,7 @@ export class UpdatePackUpload {
26
34
  devProjectId: string,
27
35
  {
28
36
  putRetryMs = 1_000,
37
+ putTimeoutMs = 60_000,
29
38
  commitRetryMs = 10_000,
30
39
  cutOffWindowMs = 15 * 60_000,
31
40
  commitLimitMs = 60 * 60_000,
@@ -34,12 +43,19 @@ export class UpdatePackUpload {
34
43
  this.#api = api;
35
44
  this.#devProjectId = devProjectId;
36
45
  this.#putRetryMs = putRetryMs;
46
+ this.#putTimeoutMs = putTimeoutMs;
37
47
  this.#commitRetryMs = commitRetryMs;
38
48
  this.#cutOffWindowMs = cutOffWindowMs;
39
49
  this.#commitLimitMs = commitLimitMs;
40
50
  }
41
51
 
42
52
  async send(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<CloudRecord> {
53
+ const uploadId = await this.stage(archive, fields);
54
+ return await this.settle(() => this.#api.commitUpdateUpload(this.#devProjectId, uploadId));
55
+ }
56
+
57
+ /** Puts the pack in the project's staging storage and answers the upload id a commit or a registration names. */
58
+ async stage(archive: string, fields: Omit<UpdateUploadRequest, "size" | "sha256">): Promise<string> {
43
59
  const file = Bun.file(archive);
44
60
  if (file.size < 1 || file.size > UpdatePackUpload.maxBytes)
45
61
  throw new Error(`The pack archive is ${file.size} bytes; a project takes 1 byte to 200 MiB.`);
@@ -50,7 +66,7 @@ export class UpdatePackUpload {
50
66
  for (let ticket = 1; ; ticket++) {
51
67
  const { uploadId, uploadUrl, contentType } = await this.#request(upload);
52
68
  const status = await this.#put(uploadUrl, contentType, file);
53
- if (status >= 200 && status < 300) return await this.#commit(uploadId);
69
+ if (status >= 200 && status < 300) return uploadId;
54
70
  if (status !== 403 || ticket === 2)
55
71
  throw new Error(`The storage refused the pack upload (${status ? `HTTP ${status}` : "no response"}).`);
56
72
  }
@@ -59,10 +75,9 @@ export class UpdatePackUpload {
59
75
  async #request(upload: UpdateUploadRequest) {
60
76
  return await this.#api.requestUpdateUpload(this.#devProjectId, upload).catch((error: unknown) => {
61
77
  //? A server older than staged uploads has no such route: its 404 is a page, where every refusal is an Err key.
62
- const message = error instanceof Error ? error.message : String(error);
63
- if (CloudApi.statusOf(error) === 404 && !/"error"\s*:\s*"[\w$-]+\.error\.[\w$-]+"/.test(message))
78
+ if (error instanceof CloudHttpError && error.status === 404 && !error.errKey)
64
79
  throw new Error(
65
- "The project's server does not take direct pack uploads yet: update akasys and try again, or upload the pack from the project's release screen.",
80
+ "The project's server does not take direct pack uploads yet: update Akan Cloud and try again, or upload the pack from the project's release screen.",
66
81
  );
67
82
  throw error;
68
83
  });
@@ -71,45 +86,67 @@ export class UpdatePackUpload {
71
86
  // The URL signs only its host: an unsigned x-amz-* header (a checksum included) makes storage refuse the PUT.
72
87
  // A Bun fetch error names the URL, a bearer credential, so nothing but the status leaves this method.
73
88
  async #put(url: string, contentType: string, file: BunFile) {
89
+ const timeoutMs = this.#putTimeoutMs + Math.ceil((file.size / UpdatePackUpload.#putFloorBytesPerSecond) * 1000);
74
90
  for (let retry = 0; ; retry++) {
75
- const status = await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": contentType } })
91
+ const { status, retryAfterMs } = await fetch(url, {
92
+ method: "PUT",
93
+ body: file,
94
+ headers: { "Content-Type": contentType },
95
+ signal: AbortSignal.timeout(timeoutMs),
96
+ })
76
97
  .then(async (response) => {
77
98
  await response.arrayBuffer().catch(() => undefined);
78
- return response.status;
99
+ return {
100
+ status: response.status,
101
+ retryAfterMs: CloudHttpError.retryAfterMs(response.headers.get("retry-after")),
102
+ };
79
103
  })
80
- .catch(() => 0);
104
+ .catch(() => ({ status: 0, retryAfterMs: null }));
81
105
  if (retry === 3 || !UpdatePackUpload.#transientPut.has(status)) return status;
82
- await sleep(this.#putRetryMs * (retry + 1));
106
+ await sleep(
107
+ Math.max(this.#putRetryMs * (retry + 1), Math.min(retryAfterMs ?? 0, UpdatePackUpload.#maxPutRetryAfterMs)),
108
+ );
83
109
  }
84
110
  }
85
111
 
86
- // The server finishes a commit whoever stopped listening, and the same id answers the same release afterwards.
87
- // A 409 is a live claim the server heartbeats, so only the overall limit bounds it; a run of cut-offs with no 409
88
- // between them may be a server that died, whose claim lapses within minutes, so that run gets the shorter window.
89
- async #commit(uploadId: string) {
112
+ /**
113
+ * Sends `call`, which consumes a staged upload, until the server answers it: the server finishes whoever stopped
114
+ * listening, and the same upload id answers the same record afterwards.
115
+ */
116
+ // `uploadCommitInProgress` is a live claim the server heartbeats, so only the overall limit bounds it; a run of
117
+ // cut-offs with none between them may be a server that died, whose claim lapses within minutes: the shorter window.
118
+ async settle<T>(call: () => Promise<T>): Promise<T> {
90
119
  const limit = performance.now() + this.#commitLimitMs;
91
120
  let cutOffSince: number | null = null;
92
121
  for (;;) {
93
122
  try {
94
- return await this.#api.commitUpdateUpload(this.#devProjectId, uploadId);
123
+ return await call();
95
124
  } catch (error) {
96
125
  const now = performance.now();
97
- if (CloudApi.statusOf(error) === 409) cutOffSince = null;
126
+ if (UpdatePackUpload.#inProgress(error)) cutOffSince = null;
98
127
  else if (UpdatePackUpload.#cutOff(error)) cutOffSince ??= now;
99
128
  else throw error;
100
129
  const deadline = cutOffSince === null ? limit : Math.min(limit, cutOffSince + this.#cutOffWindowMs);
101
- if (now + this.#commitRetryMs > deadline) throw error;
102
- Logger.rawLog(
103
- `The project is still taking the pack; asking again in ${Math.round(this.#commitRetryMs / 1000)}s.`,
104
- );
105
- await sleep(this.#commitRetryMs);
130
+ const waitMs = Math.max(this.#commitRetryMs, (error instanceof CloudHttpError && error.retryAfterMs) || 0);
131
+ if (now + waitMs > deadline) throw error;
132
+ Logger.rawLog(`The project is still taking the pack; asking again in ${Math.round(waitMs / 1000)}s.`);
133
+ await sleep(waitMs);
106
134
  }
107
135
  }
108
136
  }
109
137
 
138
+ //? Not every 409: an upload already committed or embedded is answered 409 too, and asking again changes nothing.
139
+ static #inProgress(error: unknown) {
140
+ return (
141
+ error instanceof CloudHttpError && error.status === 409 && !!error.errKey?.endsWith(".uploadCommitInProgress")
142
+ );
143
+ }
144
+
110
145
  static #cutOff(error: unknown) {
111
- const status = CloudApi.statusOf(error);
112
- if (status === null) return !(error instanceof SyntaxError);
146
+ if (!(error instanceof CloudHttpError)) return !(error instanceof SyntaxError);
147
+ const { status, errKey } = error;
148
+ //? A gateway with no replica ready answers 503; the server's own 503 names an Err key and is a final refusal.
149
+ if (status === 503) return errKey === null || errKey === "base.error.serverUnavailable";
113
150
  return status === 502 || status === 504 || (status >= 520 && status <= 527);
114
151
  }
115
152
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
- import type { CodeAgentProfile } from "akanjs/common";
3
+ import { type CodeAgentProfile, trimTrailingSlashes } from "akanjs/common";
4
4
  import { akanCodeDefaultModel } from "./akanCodeModel";
5
5
 
6
6
  interface PreparedRequest {
@@ -27,7 +27,7 @@ export class CodeAgentProxy {
27
27
  readonly #token?: string;
28
28
 
29
29
  constructor({ baseUrl, providers, tokenFile, token }: CodeAgentProxyOptions) {
30
- this.baseUrl = baseUrl.replace(/\/+$/, "");
30
+ this.baseUrl = trimTrailingSlashes(baseUrl);
31
31
  this.providers = providers;
32
32
  this.#tokenFile = tokenFile;
33
33
  this.#token = token;
@@ -8,7 +8,7 @@ export interface CodeAgentModelRef {
8
8
  id: string;
9
9
  }
10
10
 
11
- /** DeepSeek's million-token window is what makes a 26k-token `AGENTS.md` affordable on every turn. */
11
+ /** DeepSeek's million-token window is what makes the always-loaded `AGENTS.md` affordable on every turn. */
12
12
  export const akanCodeDefaultModel: CodeAgentModelRef = { provider: "deepseek", id: "deepseek-flash" };
13
13
 
14
14
  // Declared text-only by the catalogue, yet verified against the provider to see images.
@@ -73,7 +73,9 @@ describe("workspace context files", () => {
73
73
  test("a rule with no frontmatter at all is unconditional, and so is .cursorrules", () => {
74
74
  const root = workspace({ ".cursor/rules/plain.mdc": "just do it", ".cursorrules": "the legacy file" });
75
75
  const files = AkanContextFiles.extend(loaded(root), { workspaceRoot: root, cwd: root });
76
- expect(namesOf(files, root).sort()).toEqual([".cursorrules", ".cursor/rules/plain.mdc", "AGENTS.md"].sort());
76
+ expect(namesOf(files, root).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))).toEqual(
77
+ [".cursorrules", ".cursor/rules/plain.mdc", "AGENTS.md"].sort(),
78
+ );
77
79
  });
78
80
 
79
81
  test("rules nested under the rules directory are found, in a stable order", () => {
@@ -132,14 +132,14 @@ export class WebToolPack {
132
132
  /** Enough to read a documentation page; a full HTML parser is not worth a dependency for that. */
133
133
  static #readable(html: string) {
134
134
  return html
135
- .replace(/<script[\s\S]*?<\/script>/gi, " ")
136
- .replace(/<style[\s\S]*?<\/style>/gi, " ")
137
- .replace(/<[^>]+>/g, " ")
135
+ .replace(/<script[\s\S]*?<\/script[^<>]*>/gi, " ")
136
+ .replace(/<style[\s\S]*?<\/style[^<>]*>/gi, " ")
137
+ .replace(/<[^<>]+>/g, " ")
138
138
  .replace(/&nbsp;/g, " ")
139
- .replace(/&amp;/g, "&")
140
139
  .replace(/&lt;/g, "<")
141
140
  .replace(/&gt;/g, ">")
142
141
  .replace(/&quot;/g, '"')
142
+ .replace(/&amp;/g, "&")
143
143
  .replace(/[ \t]+/g, " ")
144
144
  .replace(/\n{3,}/g, "\n\n")
145
145
  .trim();
@@ -247,22 +247,26 @@ describe("PreviewView", () => {
247
247
  expect(typeof PreviewView.available).toBe("boolean");
248
248
  });
249
249
 
250
- test.skipIf(!PreviewView.available)("finds an empty body and a console error on a real page", async () => {
251
- const server = Bun.serve({
252
- port: 0,
253
- fetch: () =>
254
- new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
255
- headers: { "content-type": "text/html" },
256
- }),
257
- });
258
- try {
259
- const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
260
- expect(probe.pageErrors.join(" ")).toContain("rendered empty");
261
- expect(probe.consoleErrors.join(" ")).toContain("boom");
262
- } finally {
263
- server.stop(true);
264
- }
265
- });
250
+ test.skipIf(!PreviewView.available)(
251
+ "finds an empty body and a console error on a real page",
252
+ async () => {
253
+ const server = Bun.serve({
254
+ port: 0,
255
+ fetch: () =>
256
+ new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
257
+ headers: { "content-type": "text/html" },
258
+ }),
259
+ });
260
+ try {
261
+ const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
262
+ expect(probe.pageErrors.join(" ")).toContain("rendered empty");
263
+ expect(probe.consoleErrors.join(" ")).toContain("boom");
264
+ } finally {
265
+ server.stop(true);
266
+ }
267
+ },
268
+ 30_000,
269
+ );
266
270
  });
267
271
 
268
272
  describe("SubagentPool", () => {
@@ -49,11 +49,15 @@ export interface ArgMeta<Context = CommandContext> {
49
49
  idx: number;
50
50
  type: ArgType;
51
51
  }
52
+ export interface InternalArgOption {
53
+ nullable?: boolean;
54
+ onlyApp?: boolean;
55
+ }
52
56
  export interface InternalArgMeta {
53
57
  key: string;
54
58
  idx: number;
55
59
  type: InternalArgType;
56
- option?: { nullable?: boolean };
60
+ option?: InternalArgOption;
57
61
  }
58
62
 
59
63
  export const getArgMetas = (
@@ -69,9 +73,11 @@ export const getArgMetas = (
69
73
  export interface InternalArgToken<T = unknown, Type extends InternalArgType = InternalArgType> {
70
74
  type: Type;
71
75
  _value: T;
76
+ option?: InternalArgOption;
72
77
  }
73
78
 
74
- const createInternalArgToken = <T, Type extends InternalArgType>(type: Type) => ({ type }) as InternalArgToken<T, Type>;
79
+ const createInternalArgToken = <T, Type extends InternalArgType>(type: Type, option?: InternalArgOption) =>
80
+ (option ? { type, option } : { type }) as InternalArgToken<T, Type>;
75
81
 
76
82
  export const normalizePrimitiveArgType = (type: PrimitiveArgType): NormalizedPrimitiveArgType => {
77
83
  if (type === String) return "string";
@@ -93,6 +99,9 @@ export type Lib = LibExecutor;
93
99
  export const Sys = createInternalArgToken<SysExecutor, "Sys">("Sys");
94
100
  export type Sys = SysExecutor;
95
101
 
102
+ /** Sys, except that a one-app workspace given no name means its app, as with App. */
103
+ export const SysOrOnlyApp = createInternalArgToken<SysExecutor, "Sys">("Sys", { onlyApp: true });
104
+
96
105
  export const Exec = createInternalArgToken<Executor, "Exec">("Exec");
97
106
  export type Exec = Executor;
98
107