@akanjs/devkit 3.0.0-beta.18 → 3.0.0-beta.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 (63) hide show
  1. package/abstractDoc.ts +4 -1
  2. package/akanApp/akanApp.host.ts +23 -9
  3. package/akanConfig/akanConfig.test.ts +55 -30
  4. package/akanConfig/akanConfig.ts +29 -17
  5. package/akanConfig/types.ts +1 -0
  6. package/applicationBuildRunner.test.ts +5 -3
  7. package/applicationBuildRunner.ts +29 -3
  8. package/artifact/routeSeedIndex.test.ts +7 -6
  9. package/capacitorApp.test.ts +2 -2
  10. package/cloud/constants.ts +23 -0
  11. package/cloud/globalConfig.ts +13 -0
  12. package/codeAgent/agent/AkanCodeServices.ts +22 -3
  13. package/codeAgent/agent/CodeAgent.ts +33 -18
  14. package/codeAgent/agent/CodeAgentAsks.ts +3 -1
  15. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
  16. package/codeAgent/agent/CodeAgentProxy.ts +91 -0
  17. package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
  18. package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
  19. package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
  20. package/codeAgent/agent/akanCodeModel.ts +1 -1
  21. package/codeAgent/agent/akanContextFiles.test.ts +2 -1
  22. package/codeAgent/agent/codeAgentContract.test.ts +17 -0
  23. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  24. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  25. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  26. package/codeAgent/tools/mcpOAuth.test.ts +6 -2
  27. package/dependencyScanner.ts +4 -2
  28. package/devkitUtils.test.ts +1 -1
  29. package/executors.linkFallback.test.ts +103 -0
  30. package/executors.test.ts +8 -0
  31. package/executors.ts +31 -8
  32. package/frontendBuild/autoImportSync.test.ts +13 -0
  33. package/frontendBuild/autoImportSync.ts +19 -1
  34. package/frontendBuild/buildRouteClient.test.ts +52 -1
  35. package/frontendBuild/cssImportResolver.ts +1 -1
  36. package/frontendBuild/fontOptimizer.test.ts +1 -1
  37. package/frontendBuild/frontendBuild.test.ts +19 -17
  38. package/frontendBuild/hmrWatcher.test.ts +1 -1
  39. package/frontendBuild/routeClientBuilder.ts +27 -10
  40. package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
  41. package/getDirname.test.ts +4 -2
  42. package/incrementalBuilder/devWatchBatch.test.ts +7 -6
  43. package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
  44. package/integration/devStabilityHarness.test.ts +7 -2
  45. package/lint/gritRules.test.ts +2 -1
  46. package/lint/no-deep-internal-import.grit +2 -2
  47. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  48. package/linter.ts +4 -1
  49. package/package.json +2 -2
  50. package/platformTest/BunTestReport.test.ts +60 -0
  51. package/platformTest/BunTestReport.ts +65 -0
  52. package/platformTest/IdleWatchedProcess.test.ts +64 -0
  53. package/platformTest/IdleWatchedProcess.ts +77 -0
  54. package/platformTest/LinuxDockerTarget.ts +120 -0
  55. package/platformTest/PlatformTestRun.test.ts +68 -0
  56. package/platformTest/PlatformTestRun.ts +141 -0
  57. package/platformTest/PlatformTestTarget.ts +120 -0
  58. package/platformTest/TestSnapshot.test.ts +80 -0
  59. package/platformTest/TestSnapshot.ts +107 -0
  60. package/platformTest/WindowsSshTarget.ts +167 -0
  61. package/subspace.test.ts +5 -2
  62. package/subspace.ts +19 -8
  63. package/workflow/rolloutGate.test.ts +1 -1
package/abstractDoc.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import path from "node:path";
1
2
  import type { SysExecutor } from "./executors";
2
3
 
3
4
  export type AbstractKind = "domain" | "service" | "scalar" | "other";
@@ -25,7 +26,9 @@ export class AbstractDoc {
25
26
  return folder.startsWith("_") ? "service" : "domain";
26
27
  }
27
28
  static async findAll(sys: SysExecutor, { module }: { module?: string | null } = {}) {
28
- const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`)).sort();
29
+ const filePaths = (await sys.getAllFiles(`${AbstractDoc.#facetRoots}/**/*${AbstractDoc.suffix}`))
30
+ .map((filePath) => filePath.split(path.sep).join("/"))
31
+ .sort();
29
32
  const docs = await Promise.all(filePaths.map((filePath) => AbstractDoc.read(sys, filePath)));
30
33
  if (!module) return docs;
31
34
  return docs.filter((doc) => doc.moduleName === module || doc.folderName === module);
@@ -621,28 +621,37 @@ export class AkanAppHost {
621
621
  );
622
622
  }
623
623
  /**
624
- * Another process's RSS, read from the OS rather than asked of the process. `/proc` where it exists,
625
- * `ps` otherwise (macOS has no `/proc`). Null when it cannot be read, which callers treat as
626
- * "no new information" rather than as zero.
624
+ * Another process's RSS, read from the OS rather than asked of the process. `tasklist` on Windows, `/proc`
625
+ * where it exists, `ps` otherwise (macOS has no `/proc`). Null when it cannot be read, which callers treat
626
+ * as "no new information" rather than as zero.
627
627
  */
628
628
  static async readProcessRssBytes(pid: number): Promise<number | null> {
629
+ if (process.platform === "win32")
630
+ return await AkanAppHost.#readRssKbVia(
631
+ ["tasklist", "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"],
632
+ AkanAppHost.#tasklistRssKb,
633
+ );
629
634
  const status = await Bun.file(`/proc/${pid}/status`)
630
635
  .text()
631
636
  .catch(() => null);
632
637
  const vmRssKb = status === null ? null : /VmRSS:\s+(\d+) kB/.exec(status)?.[1];
633
638
  if (vmRssKb) return Number(vmRssKb) * 1024;
634
- return await AkanAppHost.#readRssViaPs(pid);
639
+ return await AkanAppHost.#readRssKbVia(["ps", "-o", "rss=", "-p", String(pid)], (output) => Number(output.trim()));
635
640
  }
636
641
  /**
637
- * `ps`, bounded. An absent `ps` is already handled — it answers `null`, which callers read as "no new
638
- * information" — but a `ps` that never answers was not: the only caller awaits it after a 20s settle,
642
+ * `ps` or `tasklist`, bounded. An absent tool is already handled — it answers `null`, which callers read as
643
+ * "no new information" — but a `ps` that never answers was not: the only caller awaits it after a 20s settle,
639
644
  * so the recycle it was about to commit simply never happened, silently. The harness has hit exactly
640
645
  * this hang while shelling out to `ps` under load.
641
646
  */
642
- static async #readRssViaPs(pid: number, timeoutMs = PS_RSS_TIMEOUT_MS): Promise<number | null> {
647
+ static async #readRssKbVia(
648
+ command: string[],
649
+ parseKb: (output: string) => number,
650
+ timeoutMs = PS_RSS_TIMEOUT_MS,
651
+ ): Promise<number | null> {
643
652
  let proc: Bun.Subprocess<"ignore", "pipe", "ignore">;
644
653
  try {
645
- proc = Bun.spawn(["ps", "-o", "rss=", "-p", String(pid)], { stdio: ["ignore", "pipe", "ignore"] });
654
+ proc = Bun.spawn(command, { stdio: ["ignore", "pipe", "ignore"] });
646
655
  } catch {
647
656
  return null;
648
657
  }
@@ -650,7 +659,7 @@ export class AkanAppHost {
650
659
  try {
651
660
  const output = await new Response(proc.stdout).text();
652
661
  await proc.exited;
653
- const rssKb = Number(output.trim());
662
+ const rssKb = parseKb(output);
654
663
  return Number.isFinite(rssKb) && rssKb > 0 ? rssKb * 1024 : null;
655
664
  } catch {
656
665
  return null;
@@ -658,6 +667,11 @@ export class AkanAppHost {
658
667
  clearTimeout(killer);
659
668
  }
660
669
  }
670
+ /** `Mem Usage` is the working set — what `process.memoryUsage.rss()` reports on Windows — in locale-grouped KB. */
671
+ static #tasklistRssKb(output: string): number {
672
+ const memUsage = /"([^"]*)"\s*$/m.exec(output)?.[1];
673
+ return memUsage ? Number(memUsage.replace(/\D/g, "")) : Number.NaN;
674
+ }
661
675
  #cancelRssRecycle(): void {
662
676
  this.#rssRecycleReason = null;
663
677
  this.#rssRecycleOver = null;
@@ -249,31 +249,65 @@ describe("AkanAppConfig", () => {
249
249
  ...akanPackageJson.dependencies,
250
250
  ...akanPackageJson.peerDependencies,
251
251
  };
252
- const singleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "single" }, baseDevEnv);
253
- const multipleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "multiple" }, baseDevEnv);
254
- const clusterConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "cluster" }, baseDevEnv);
255
-
256
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("ioredis");
257
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("bullmq");
258
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
259
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("postgres");
260
- expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("protobufjs");
261
-
262
- expect(multipleConfig.getProductionPackageJson().dependencies).toMatchObject({
263
- "@libsql/client": runtimeDependencies["@libsql/client"],
252
+ const configOf = (database: { modes: ("single" | "multiple" | "cluster")[] }) =>
253
+ new AkanAppConfig(app, [], packageJson, { database }, baseDevEnv);
254
+ const dependenciesOf = (config: AkanAppConfig) => config.getProductionPackageJson().dependencies ?? {};
255
+ const singleConfig = configOf({ modes: ["single"] });
256
+ const multipleConfig = configOf({ modes: ["multiple"] });
257
+ const clusterConfig = configOf({ modes: ["cluster"] });
258
+ const edgeAndCloud = configOf({ modes: ["single", "cluster"] });
259
+
260
+ for (const driver of ["ioredis", "bullmq", "postgres", "@libsql/client", "protobufjs"])
261
+ expect(dependenciesOf(singleConfig)).not.toHaveProperty(driver);
262
+ expect(dependenciesOf(multipleConfig)).toMatchObject({
264
263
  bullmq: runtimeDependencies.bullmq,
265
264
  ioredis: runtimeDependencies.ioredis,
266
- protobufjs: runtimeDependencies.protobufjs,
267
265
  });
268
- expect(multipleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("postgres");
269
-
270
- expect(clusterConfig.getProductionPackageJson().dependencies).toMatchObject({
266
+ expect(dependenciesOf(clusterConfig)).toMatchObject({
271
267
  bullmq: runtimeDependencies.bullmq,
272
268
  ioredis: runtimeDependencies.ioredis,
273
269
  postgres: runtimeDependencies.postgres,
274
- protobufjs: runtimeDependencies.protobufjs,
275
270
  });
276
- expect(clusterConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
271
+ // multiple opens the same SQLite as single and Redis replaced protobuf on the wire, so neither ships by default.
272
+ for (const config of [multipleConfig, clusterConfig])
273
+ for (const driver of ["@libsql/client", "protobufjs"]) expect(dependenciesOf(config)).not.toHaveProperty(driver);
274
+ expect(dependenciesOf(multipleConfig)).not.toHaveProperty("postgres");
275
+ // One image for an edge site and a cloud cluster carries both modes' drivers, and says which it carries.
276
+ expect(dependenciesOf(edgeAndCloud)).toMatchObject({ postgres: runtimeDependencies.postgres });
277
+ expect(edgeAndCloud.dockerfile).toContain("ENV AKAN_DATABASE_MODES=single,cluster");
278
+ expect(singleConfig.dockerfile).toContain("ENV AKAN_DATABASE_MODES=single");
279
+ });
280
+
281
+ test("runs single when the app declares no mode", () => {
282
+ expect(new AkanAppConfig(app, [], packageJson, {}, baseDevEnv).database.modes).toEqual(["single"]);
283
+ });
284
+
285
+ test("refuses a mode name that is not one of the three, naming the file", () => {
286
+ expect(
287
+ () => new AkanAppConfig(app, [], packageJson, { database: { modes: ["clsuter" as "cluster"] } }, baseDevEnv),
288
+ ).toThrow("database.modes in apps/portal/akan.config.ts");
289
+ });
290
+
291
+ test("resolves a command's mode within the declared ones", () => {
292
+ const previous = process.env.AKAN_DATABASE_MODE;
293
+ try {
294
+ const config = new AkanAppConfig(
295
+ app,
296
+ [],
297
+ packageJson,
298
+ { database: { modes: ["single", "cluster"] } },
299
+ baseDevEnv,
300
+ );
301
+ delete process.env.AKAN_DATABASE_MODE;
302
+ expect(config.resolveDatabaseMode()).toBe("single");
303
+ process.env.AKAN_DATABASE_MODE = "cluster";
304
+ expect(config.resolveDatabaseMode()).toBe("cluster");
305
+ process.env.AKAN_DATABASE_MODE = "multiple";
306
+ expect(() => config.resolveDatabaseMode()).toThrow('Add "multiple" to database.modes');
307
+ } finally {
308
+ if (previous === undefined) delete process.env.AKAN_DATABASE_MODE;
309
+ else process.env.AKAN_DATABASE_MODE = previous;
310
+ }
277
311
  });
278
312
 
279
313
  test("resolves database mode runtime packages and missing install specs", () => {
@@ -300,20 +334,11 @@ describe("AkanAppConfig", () => {
300
334
  );
301
335
 
302
336
  expect(config.getDatabaseModeRuntimePackages("single")).toEqual([]);
303
- expect(config.getDatabaseModeRuntimePackages("multiple")).toEqual([
304
- "@libsql/client",
305
- "bullmq",
306
- "ioredis",
307
- "protobufjs",
308
- ]);
309
- expect(config.getDatabaseModeRuntimePackages("cluster")).toEqual(["bullmq", "ioredis", "postgres", "protobufjs"]);
310
- expect(config.getMissingDatabaseModeDependencySpecs("multiple")).toEqual([
311
- `@libsql/client@${runtimeDependencies["@libsql/client"]}`,
312
- `protobufjs@${runtimeDependencies.protobufjs}`,
313
- ]);
337
+ expect(config.getDatabaseModeRuntimePackages("multiple")).toEqual(["bullmq", "ioredis"]);
338
+ expect(config.getDatabaseModeRuntimePackages("cluster")).toEqual(["bullmq", "ioredis", "postgres"]);
339
+ expect(config.getMissingDatabaseModeDependencySpecs("multiple")).toEqual([]);
314
340
  expect(config.getMissingDatabaseModeDependencySpecs("cluster")).toEqual([
315
341
  `postgres@${runtimeDependencies.postgres}`,
316
- `protobufjs@${runtimeDependencies.protobufjs}`,
317
342
  ]);
318
343
  // The workspace-root install covers the toolchain/runtime plus every app Capacitor plugin
319
344
  // (deduped — "@capacitor/core" appears in both source lists).
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import type { AkanPlugin } from "akanjs";
5
- import { normalizeRoutePrefix } from "akanjs/base";
5
+ import { DatabaseModes, normalizeRoutePrefix } from "akanjs/base";
6
6
  import { type AkanI18nConfig, resolveAkanI18nConfig } from "akanjs/common";
7
7
  import type { AkanImageConfig } from "akanjs/server";
8
8
  import type { App, Lib } from "../commandDecorators";
@@ -11,6 +11,7 @@ import type { BaseDevEnv, PackageJson } from "../types";
11
11
  import {
12
12
  type AkanApiConfig,
13
13
  type AkanAssetsConfig,
14
+ type AkanDatabaseConfig,
14
15
  type AkanMobileConfig,
15
16
  type AkanMobileTargetConfig,
16
17
  type AkanRouteConfig,
@@ -93,16 +94,6 @@ const MOBILE_APP_CAPACITOR_PLUGINS = [
93
94
  "@capacitor/push-notifications",
94
95
  "capacitor-plugin-safe-area",
95
96
  ] as const;
96
- const DATABASE_MODE_RUNTIME_PACKAGES = {
97
- single: [],
98
- multiple: ["@libsql/client", "bullmq", "ioredis", "protobufjs"],
99
- cluster: ["bullmq", "ioredis", "postgres", "protobufjs"],
100
- } satisfies Record<DatabaseMode, readonly string[]>;
101
- const AKAN_RUNTIME_PACKAGES = new Set<string>([
102
- ...SSR_RUNTIME_PACKAGES,
103
- ...MOBILE_RUNTIME_PACKAGES,
104
- ...Object.values(DATABASE_MODE_RUNTIME_PACKAGES).flat(),
105
- ]);
106
97
  const DEFAULT_AKAN_IMAGE_CONFIG: AkanImageConfig = {
107
98
  deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
108
99
  imageSizes: [32, 48, 64, 96, 128, 256, 384],
@@ -211,7 +202,7 @@ export class AkanAppConfig implements AppConfigResult {
211
202
  docker: DockerConfig;
212
203
  /** The Dockerfile `akan build` writes: the declared string verbatim, or one assembled from the parts. */
213
204
  dockerfile: string;
214
- defaultDatabaseMode: DatabaseMode;
205
+ database: AkanDatabaseConfig;
215
206
  web: AkanWebConfig;
216
207
  externalLibs: string[];
217
208
  barrelImports: string[];
@@ -250,7 +241,7 @@ export class AkanAppConfig implements AppConfigResult {
250
241
  this.baseDevEnv = baseDevEnv;
251
242
  this.plugins = plugins;
252
243
  this.#applyRoutes(config?.routes);
253
- this.defaultDatabaseMode = config?.defaultDatabaseMode ?? "single";
244
+ this.database = AkanAppConfig.#database(app, config);
254
245
  this.externalLibs = [...new Set([...(config?.externalLibs ?? []), ...libContributions.externalLibs])];
255
246
  this.barrelImports = [
256
247
  ...DEFAULT_BARREL_IMPORTS,
@@ -458,6 +449,7 @@ ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
458
449
  ENV AKAN_PUBLIC_API_PREFIX=${this.api.prefix}
459
450
  ENV AKAN_PUBLIC_WS_PREFIX=${this.api.websocketPrefix}
460
451
  ENV AKAN_PUBLIC_OPERATION_MODE=cloud
452
+ ENV AKAN_DATABASE_MODES=${this.database.modes.join(",")}
461
453
  ENV AKAN_LOG_TO_FILE=0
462
454
  ${webEnvLines}
463
455
  CMD [${command.map((c) => `"${c}"`).join(",")}]`;
@@ -526,14 +518,31 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
526
518
  return akanPackageJson.dependencies?.[lib] ?? akanPackageJson.peerDependencies?.[lib];
527
519
  }
528
520
  #getProductionRuntimePackages() {
529
- return [...this.externalLibs, ...SSR_RUNTIME_PACKAGES, ...this.getDatabaseModeRuntimePackages()];
521
+ return [
522
+ ...this.externalLibs,
523
+ ...SSR_RUNTIME_PACKAGES,
524
+ ...this.database.modes.flatMap((mode) => this.getDatabaseModeRuntimePackages(mode)),
525
+ ];
530
526
  }
531
- getDatabaseModeRuntimePackages(databaseMode: DatabaseMode = this.defaultDatabaseMode) {
532
- return [...DATABASE_MODE_RUNTIME_PACKAGES[databaseMode]];
527
+ getDatabaseModeRuntimePackages(databaseMode: DatabaseMode) {
528
+ return [...DatabaseModes.drivers[databaseMode]];
533
529
  }
534
- getMissingDatabaseModeDependencySpecs(databaseMode: DatabaseMode = this.defaultDatabaseMode) {
530
+ getMissingDatabaseModeDependencySpecs(databaseMode: DatabaseMode) {
535
531
  return this.#getMissingDependencySpecs(this.getDatabaseModeRuntimePackages(databaseMode));
536
532
  }
533
+ /** The mode a CLI command runs this app in: the shell's `AKAN_DATABASE_MODE` if the app declares it, else the first. */
534
+ resolveDatabaseMode() {
535
+ return DatabaseModes.resolve({
536
+ requested: process.env.AKAN_DATABASE_MODE,
537
+ declared: this.database.modes.join(","),
538
+ local: true,
539
+ });
540
+ }
541
+ static #database(app: App, config: AppConfigDeclaration): AkanDatabaseConfig {
542
+ const declared = config?.database?.modes?.filter((mode): mode is DatabaseMode => !!mode);
543
+ const modes = declared?.length ? declared : ["single"];
544
+ return { modes: DatabaseModes.parseList(modes.join(","), `database.modes in apps/${app.name}/akan.config.ts`) };
545
+ }
537
546
  getMobileRuntimePackages() {
538
547
  // The app Capacitor plugins are installed at the workspace root too, so bun can resolve the
539
548
  // app's "*" declarations to a hoisted, peer-pinned version instead of fetching latest.
@@ -558,6 +567,9 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
558
567
  return `${lib}@${version}`;
559
568
  });
560
569
  }
570
+ get akanVersion() {
571
+ return getAkanPackageJson().version;
572
+ }
561
573
  getProductionPackageJson(data: Partial<PackageJson> = {}): PackageJson {
562
574
  return {
563
575
  name: this.app.name,
@@ -2,6 +2,7 @@ export type {
2
2
  AkanApiConfig,
3
3
  AkanAssetsConfig,
4
4
  AkanConfigFile,
5
+ AkanDatabaseConfig,
5
6
  AkanExecutor,
6
7
  AkanMobileConfig,
7
8
  AkanMobileTargetConfig,
@@ -1,5 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
3
5
  import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS } from "./applicationBuildRunner";
4
6
 
5
7
  describe("ApplicationBuildRunner", () => {
@@ -16,8 +18,8 @@ describe("ApplicationBuildRunner", () => {
16
18
  });
17
19
 
18
20
  test("a bundler that mangles identifiers loses the class name a Logger reads", async () => {
19
- const dir = `${process.env.TMPDIR ?? "/tmp"}/akan-minify-${Bun.randomUUIDv7()}`;
20
- const entry = `${dir}/entry.ts`;
21
+ const dir = path.join(os.tmpdir(), `akan-minify-${Bun.randomUUIDv7()}`);
22
+ const entry = path.join(dir, "entry.ts");
21
23
  await Bun.write(
22
24
  entry,
23
25
  `const serve = () => class Service { readonly name = this.constructor.name; };
@@ -26,7 +28,7 @@ console.info(new SampleService().name);
26
28
  `,
27
29
  );
28
30
  const build = async (minify: Bun.BuildConfig["minify"]) => {
29
- const result = await Bun.build({ entrypoints: [entry], target: "bun", minify, outdir: `${dir}/out` });
31
+ const result = await Bun.build({ entrypoints: [entry], target: "bun", minify, outdir: path.join(dir, "out") });
30
32
  expect(result.success).toBe(true);
31
33
  const proc = Bun.spawn([process.execPath, result.outputs[0].path], { stdout: "pipe" });
32
34
  return (await new Response(proc.stdout).text()).trim();
@@ -191,9 +191,29 @@ export class ApplicationBuildRunner {
191
191
  await Promise.all([
192
192
  this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
193
193
  this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, akanConfig.dockerfile),
194
+ this.#app.dist.writeJson("akan.build.json", {
195
+ buildId: await this.#resolveBuildId(),
196
+ akanVersion: akanConfig.akanVersion,
197
+ builtAt: new Date().toISOString(),
198
+ }),
194
199
  ]);
195
200
  }
196
201
 
202
+ //* Read back at runtime as `buildId` on the ops channel; a deployment's own AKAN_BUILD_ID still wins over it there.
203
+ async #resolveBuildId() {
204
+ const fromEnv = process.env.AKAN_BUILD_ID?.trim();
205
+ if (fromEnv) return fromEnv;
206
+ const root = this.#app.workspace.workspaceRoot;
207
+ const sha = Bun.spawnSync(["git", "rev-parse", "--short=12", "HEAD"], { cwd: root, stderr: "ignore" });
208
+ if (sha.exitCode !== 0) return null;
209
+ const dirty = Bun.spawnSync(["git", "status", "--porcelain", "--untracked-files=no"], {
210
+ cwd: root,
211
+ stderr: "ignore",
212
+ });
213
+ const suffix = dirty.exitCode === 0 && dirty.stdout.toString().trim() ? "-dirty" : "";
214
+ return `${sha.stdout.toString().trim()}${suffix}`;
215
+ }
216
+
197
217
  async #buildBackend() {
198
218
  const akanConfig = await this.#app.getConfig();
199
219
  const backendExternals = [
@@ -204,15 +224,21 @@ export class ApplicationBuildRunner {
204
224
  for (const entrypoint of backendEntryPoints) {
205
225
  if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
206
226
  }
207
- const backendResult = await this.#buildOrThrow("backend", {
208
- entrypoints: backendEntryPoints,
227
+ const backendConfig = {
209
228
  outdir: this.#app.dist.cwdPath,
210
229
  target: "bun",
211
230
  minify: AKAN_BACKEND_MINIFY,
212
231
  naming: { entry: "[name].[ext]", chunk: "chunk-[hash].[ext]" },
213
232
  define: { "process.env.NODE_ENV": JSON.stringify("production") },
214
233
  plugins: backendExternals.length > 0 ? [this.#createExternalSpecifiersPlugin(backendExternals)] : [],
215
- });
234
+ } satisfies Omit<Bun.BuildConfig, "entrypoints">;
235
+ //* Built apart so main.js keeps its own module copies; splitting moves lazy vendor `import()`s out of the boot
236
+ //* parse (minimal: server.js import 79ms → 24ms, 59MB → 36MB RSS).
237
+ const [mainResult, serverResult] = [
238
+ await this.#buildOrThrow("backend", { ...backendConfig, entrypoints: [backendEntryPoints[0]] }),
239
+ await this.#buildOrThrow("backend", { ...backendConfig, entrypoints: [backendEntryPoints[1]], splitting: true }),
240
+ ];
241
+ const backendResult = { outputs: [...mainResult.outputs, ...serverResult.outputs] };
216
242
  // Nothing spawns the RSC worker without SSR, so an api-only image does not carry it.
217
243
  const rscWorkerResult = web.ssr
218
244
  ? await this.#buildOrThrow("rsc-worker", {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
2
3
  import { computeRouteSeedIndex, serializeRouteSeedIndexForArtifact } from "./routeSeedIndex";
3
4
 
4
5
  describe("computeRouteSeedIndex", () => {
@@ -19,10 +20,10 @@ describe("computeRouteSeedIndex", () => {
19
20
  ]);
20
21
 
21
22
  const leaf = index.entries.find((entry) => entry.routeId === "/:lang/foo");
22
- expect(leaf?.seeds).toContain("/app/page/foo.tsx");
23
- expect(leaf?.seeds).not.toContain("/app/page/foo/_layout.tsx");
24
- expect(leaf?.seeds).toContain("/app/.akan/generated/root-layouts/__root_layout.tsx");
25
- expect(leaf?.seeds).toContain("/app/page/_layout.tsx");
23
+ expect(leaf?.seeds).toContain(path.resolve("/app/page/foo.tsx"));
24
+ expect(leaf?.seeds).not.toContain(path.resolve("/app/page/foo/_layout.tsx"));
25
+ expect(leaf?.seeds).toContain(path.resolve("/app/.akan/generated/root-layouts/__root_layout.tsx"));
26
+ expect(leaf?.seeds).toContain(path.resolve("/app/page/_layout.tsx"));
26
27
 
27
28
  expect(() =>
28
29
  computeRouteSeedIndex([
@@ -42,8 +43,8 @@ describe("computeRouteSeedIndex", () => {
42
43
  { key: "./robots.txt.tsx", moduleAbsPath: "/app/page/robots.txt.tsx" },
43
44
  ]);
44
45
  const robots = index.entries.find((entry) => entry.routeId === "/robots.txt");
45
- expect(robots?.seeds).not.toContain("/app/.akan/generated/root-layouts/__root_layout.tsx");
46
- expect(robots?.seeds).not.toContain("/app/page/_layout.tsx");
46
+ expect(robots?.seeds).not.toContain(path.resolve("/app/.akan/generated/root-layouts/__root_layout.tsx"));
47
+ expect(robots?.seeds).not.toContain(path.resolve("/app/page/_layout.tsx"));
47
48
  });
48
49
 
49
50
  test("serializes seed paths relative to the artifact directory", () => {
@@ -235,7 +235,7 @@ describe("iOS native run helpers", () => {
235
235
  expect(deviceCommand.xcodebuildArgs).toContain("id=device-1");
236
236
  expect(deviceCommand.xcodebuildArgs).toContain("App QA");
237
237
  expect(deviceCommand.appPath).toBe(
238
- "/repo/apps/minimal/ios/DerivedData/device-1/Build/Products/Debug-iphoneos/App.app",
238
+ path.join("/repo/apps/minimal/ios/DerivedData/device-1/Build/Products/Debug-iphoneos/App.app"),
239
239
  );
240
240
 
241
241
  const simulatorCommand = buildIosNativeRunCommand({
@@ -244,7 +244,7 @@ describe("iOS native run helpers", () => {
244
244
  });
245
245
  expect(simulatorCommand.xcodebuildArgs).toContain("platform=iOS Simulator,id=sim-1");
246
246
  expect(simulatorCommand.appPath).toBe(
247
- "/repo/apps/minimal/ios/DerivedData/sim-1/Build/Products/Debug-iphonesimulator/App.app",
247
+ path.join("/repo/apps/minimal/ios/DerivedData/sim-1/Build/Products/Debug-iphonesimulator/App.app"),
248
248
  );
249
249
  });
250
250
 
@@ -24,13 +24,36 @@ export interface RemoteEnvServerConfig {
24
24
  username?: string;
25
25
  port?: number;
26
26
  }
27
+ export type PlatformGatePolicy = "gate" | "warn";
28
+ export interface LinuxTestTargetConfig {
29
+ cpus?: number;
30
+ memory?: string;
31
+ policy?: PlatformGatePolicy;
32
+ }
33
+ export interface WindowsTestTargetConfig {
34
+ host: string;
35
+ user: string;
36
+ identityFile: string;
37
+ knownHostsFile?: string;
38
+ utmVm?: string;
39
+ workRoot?: string;
40
+ tolerateScriptFailures?: string[];
41
+ policy?: PlatformGatePolicy;
42
+ greenStreak?: number;
43
+ }
44
+ export interface TestTargetsConfig {
45
+ linux?: LinuxTestTargetConfig;
46
+ windows?: WindowsTestTargetConfig;
47
+ }
27
48
  export interface AkanGlobalConfig {
28
49
  cloudHost: { [key: string]: HostConfigDto };
29
50
  remoteEnvServers: Record<string, RemoteEnvServerConfig>;
51
+ testTargets: TestTargetsConfig;
30
52
  }
31
53
  export const defaultAkanGlobalConfig: AkanGlobalConfig = {
32
54
  cloudHost: {},
33
55
  remoteEnvServers: {},
56
+ testTargets: {},
34
57
  };
35
58
 
36
59
  export interface AccessTokenDto {
@@ -12,6 +12,7 @@ import {
12
12
  type HostConfig,
13
13
  type HostConfigDto,
14
14
  type RemoteEnvServerConfig,
15
+ type TestTargetsConfig,
15
16
  } from "./constants";
16
17
 
17
18
  export class GlobalConfig {
@@ -27,6 +28,7 @@ export class GlobalConfig {
27
28
  ...akanConfig,
28
29
  cloudHost: akanConfig.cloudHost ?? defaultAkanGlobalConfig.cloudHost,
29
30
  remoteEnvServers: akanConfig.remoteEnvServers ?? defaultAkanGlobalConfig.remoteEnvServers,
31
+ testTargets: akanConfig.testTargets ?? defaultAkanGlobalConfig.testTargets,
30
32
  };
31
33
  }
32
34
  /**
@@ -71,6 +73,17 @@ export class GlobalConfig {
71
73
  remoteEnvServers,
72
74
  });
73
75
  }
76
+ static async getTestTargets(): Promise<TestTargetsConfig> {
77
+ const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
78
+ return akanConfig.testTargets;
79
+ }
80
+ static async setTestTargets(testTargets: TestTargetsConfig) {
81
+ const akanConfig = await GlobalConfig.#getAkanGlobalConfig();
82
+ await GlobalConfig.#setAkanGlobalConfig({
83
+ ...akanConfig,
84
+ testTargets: { ...akanConfig.testTargets, ...testTargets },
85
+ });
86
+ }
74
87
  static needRefreshToken(accessToken: AccessToken): boolean {
75
88
  return !!accessToken?.expiresAt?.isBefore(dayjs().add(1, "hour"));
76
89
  }
@@ -1,10 +1,13 @@
1
- import { existsSync, mkdirSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
2
4
  import { type InlineExtension, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
3
5
  import type { CodeAgentProfile } from "akanjs/common";
4
6
  import { AkanEnvKeys } from "../tools/AkanEnvKeys";
5
7
  import { type AkanContextFile, AkanContextFiles } from "./AkanContextFiles";
6
8
  import { akanCodePaths } from "./akanCodePaths";
7
9
  import { akanSystemPrompt } from "./akanSystemPrompt";
10
+ import type { CodeAgentProxy } from "./CodeAgentProxy";
8
11
  import { CodeSessionIndex } from "./CodeSessionIndex";
9
12
 
10
13
  export interface AkanCodeServicesOptions {
@@ -32,6 +35,10 @@ export class AkanCodeServices {
32
35
  enableInstallTelemetry: false,
33
36
  enableAnalytics: false,
34
37
  quietStartup: true,
38
+ // Twice the engine's 16,384. The reserve is all the room the response at the threshold gets — a request
39
+ // clamps its output to the window less the prompt less 4,096 — and 80% of it caps the summary. At 16k a
40
+ // reasoning model stops mid-thought there, and the recovery re-sends a near-full window to retry the turn.
41
+ compaction: { reserveTokens: 32_768 },
35
42
  });
36
43
  }
37
44
 
@@ -42,8 +49,18 @@ export class AkanCodeServices {
42
49
  * locks and rewrites that file itself, so a second `akan code` refreshing an OAuth token at the same moment
43
50
  * cannot clobber the first. Nothing lands in `~/.pi/`, and a user who removes akan takes their keys with them.
44
51
  */
45
- static async runtime(workspaceRoot: string) {
52
+ static async runtime(workspaceRoot: string, proxy: CodeAgentProxy | null = null) {
46
53
  mkdirSync(akanCodePaths.globalDir(), { recursive: true, mode: 0o700 });
54
+ if (proxy) {
55
+ //* Behind a proxy neither `auth.json` nor an env key may reach a request, so credentials live in a throwaway
56
+ //* file and the only key the engine ever sends is the proxy token.
57
+ const runtime = await ModelRuntime.create({
58
+ authPath: path.join(mkdtempSync(path.join(tmpdir(), "akan-code-proxy-")), "auth.json"),
59
+ modelsPath: akanCodePaths.modelsFile(),
60
+ });
61
+ await proxy.apply(runtime);
62
+ return runtime;
63
+ }
47
64
  const runtime = await ModelRuntime.create({
48
65
  authPath: akanCodePaths.authFile(),
49
66
  modelsPath: akanCodePaths.modelsFile(),
@@ -53,7 +70,9 @@ export class AkanCodeServices {
53
70
  }
54
71
 
55
72
  static sessions(workspaceRoot: string, cwd: string, profile: CodeAgentProfile, resume?: string) {
56
- if (profile.session.store !== "file") return SessionManager.inMemory(cwd);
73
+ //* `remote` has no store of its own yet, so it keeps a file session rather than none: a pod's worker is long-lived
74
+ //* and its suspended asks and resume need the file more than a laptop does.
75
+ if (profile.session.store === "memory") return SessionManager.inMemory(cwd);
57
76
  const dir = akanCodePaths.sessionsDir(workspaceRoot);
58
77
  mkdirSync(dir, { recursive: true });
59
78
  const manager = SessionManager.create(cwd, dir);