@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.
- package/abstractDoc.ts +4 -1
- package/akanApp/akanApp.host.ts +23 -9
- package/akanConfig/akanConfig.test.ts +55 -30
- package/akanConfig/akanConfig.ts +29 -17
- package/akanConfig/types.ts +1 -0
- package/applicationBuildRunner.test.ts +5 -3
- package/applicationBuildRunner.ts +29 -3
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/capacitorApp.test.ts +2 -2
- package/cloud/constants.ts +23 -0
- package/cloud/globalConfig.ts +13 -0
- package/codeAgent/agent/AkanCodeServices.ts +22 -3
- package/codeAgent/agent/CodeAgent.ts +33 -18
- package/codeAgent/agent/CodeAgentAsks.ts +3 -1
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -1
- package/codeAgent/agent/CodeAgentProxy.ts +91 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +29 -9
- package/codeAgent/agent/CodeAgentRpcListener.ts +117 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +92 -0
- package/codeAgent/agent/akanCodeModel.ts +1 -1
- package/codeAgent/agent/akanContextFiles.test.ts +2 -1
- package/codeAgent/agent/codeAgentContract.test.ts +17 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/tools/mcpOAuth.test.ts +6 -2
- package/dependencyScanner.ts +4 -2
- package/devkitUtils.test.ts +1 -1
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +8 -0
- package/executors.ts +31 -8
- package/frontendBuild/autoImportSync.test.ts +13 -0
- package/frontendBuild/autoImportSync.ts +19 -1
- package/frontendBuild/buildRouteClient.test.ts +52 -1
- package/frontendBuild/cssImportResolver.ts +1 -1
- package/frontendBuild/fontOptimizer.test.ts +1 -1
- package/frontendBuild/frontendBuild.test.ts +19 -17
- package/frontendBuild/hmrWatcher.test.ts +1 -1
- package/frontendBuild/routeClientBuilder.ts +27 -10
- package/frontendBuild/sourceMtimeIndex.test.ts +6 -5
- package/getDirname.test.ts +4 -2
- package/incrementalBuilder/devWatchBatch.test.ts +7 -6
- package/incrementalBuilder/incrementalBuilder.proc.ts +1 -0
- package/integration/devStabilityHarness.test.ts +7 -2
- package/lint/gritRules.test.ts +2 -1
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/linter.ts +4 -1
- package/package.json +2 -2
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +64 -0
- package/platformTest/IdleWatchedProcess.ts +77 -0
- package/platformTest/LinuxDockerTarget.ts +120 -0
- package/platformTest/PlatformTestRun.test.ts +68 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +120 -0
- package/platformTest/TestSnapshot.test.ts +80 -0
- package/platformTest/TestSnapshot.ts +107 -0
- package/platformTest/WindowsSshTarget.ts +167 -0
- package/subspace.test.ts +5 -2
- package/subspace.ts +19 -8
- 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}`))
|
|
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);
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -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.
|
|
625
|
-
* `ps` otherwise (macOS has no `/proc`). Null when it cannot be read, which callers treat
|
|
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.#
|
|
639
|
+
return await AkanAppHost.#readRssKbVia(["ps", "-o", "rss=", "-p", String(pid)], (output) => Number(output.trim()));
|
|
635
640
|
}
|
|
636
641
|
/**
|
|
637
|
-
* `ps`, bounded. An absent
|
|
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 #
|
|
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(
|
|
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 =
|
|
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
|
|
253
|
-
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
expect(multipleConfig
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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).
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 [
|
|
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
|
|
532
|
-
return [...
|
|
527
|
+
getDatabaseModeRuntimePackages(databaseMode: DatabaseMode) {
|
|
528
|
+
return [...DatabaseModes.drivers[databaseMode]];
|
|
533
529
|
}
|
|
534
|
-
getMissingDatabaseModeDependencySpecs(databaseMode: DatabaseMode
|
|
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,
|
package/akanConfig/types.ts
CHANGED
|
@@ -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 =
|
|
20
|
-
const entry =
|
|
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:
|
|
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
|
|
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", () => {
|
package/capacitorApp.test.ts
CHANGED
|
@@ -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
|
|
package/cloud/constants.ts
CHANGED
|
@@ -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 {
|
package/cloud/globalConfig.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|