@akanjs/devkit 2.4.0-rc.0 → 2.4.0-rc.2

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.
@@ -6,6 +6,8 @@ import {
6
6
  createBackendBuildStatus,
7
7
  isLegacyBackendFallbackFile,
8
8
  mergeBackendRestartReasons,
9
+ normalizeBackendReportedGeneration,
10
+ shouldAbandonBackendRecovery,
9
11
  shouldMarkBuildPhaseRecovered,
10
12
  shouldQueueBuildStatusReplay,
11
13
  shouldReplaceLastGoodMessage,
@@ -209,3 +211,28 @@ describe("legacy backend graph fallback", () => {
209
211
  expect(isLegacyBackendFallbackFile(`${root}/apps/akan/page/_index.tsx`, root)).toBe(false);
210
212
  });
211
213
  });
214
+
215
+ describe("backend recovery abandonment", () => {
216
+ test("keeps retrying below the attempt ceiling and abandons at it", () => {
217
+ expect(shouldAbandonBackendRecovery(0)).toBe(false);
218
+ expect(shouldAbandonBackendRecovery(4)).toBe(false);
219
+ expect(shouldAbandonBackendRecovery(5)).toBe(true);
220
+ expect(shouldAbandonBackendRecovery(9)).toBe(true);
221
+ });
222
+
223
+ test("honors a custom attempt ceiling", () => {
224
+ expect(shouldAbandonBackendRecovery(2, 3)).toBe(false);
225
+ expect(shouldAbandonBackendRecovery(3, 3)).toBe(true);
226
+ });
227
+ });
228
+
229
+ describe("normalizeBackendReportedGeneration", () => {
230
+ test("drops the gateway's unknown-generation sentinel", () => {
231
+ expect(normalizeBackendReportedGeneration(-1)).toBeUndefined();
232
+ });
233
+
234
+ test("keeps real generations including zero", () => {
235
+ expect(normalizeBackendReportedGeneration(0)).toBe(0);
236
+ expect(normalizeBackendReportedGeneration(7)).toBe(7);
237
+ });
238
+ });
@@ -8,9 +8,12 @@ import { IncrementalBuilderHost } from "../incrementalBuilder";
8
8
 
9
9
  const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route"]);
10
10
  const BACKEND_RESTART_DEBOUNCE_MS = 120;
11
- const BACKEND_GRACEFUL_TIMEOUT_MS = 3000;
11
+ // Must exceed the gateway's child-wait budget (AkanApp child shutdown, ~5s in dev) so the gateway
12
+ // is never SIGKILLed while its replicas are still shutting down — that's what strands orphans.
13
+ const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
12
14
  const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
13
15
  const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
16
+ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
14
17
  const BACKEND_STDERR_TAIL_LIMIT = 40;
15
18
  const BUILDER_READY_TIMEOUT_MS = 150000;
16
19
  const BUILDER_START_MAX_ATTEMPTS = 3;
@@ -39,6 +42,17 @@ export const shouldRestartBackendByDevPlan = (
39
42
  export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
40
43
  message.devPlan?.actions.includes("restart-builder") ?? false;
41
44
 
45
+ /**
46
+ * A backend that keeps dying isn't going to heal by retrying the same code; after this many
47
+ * consecutive attempts the host idles and the next server-side edit triggers a fresh restart.
48
+ */
49
+ export const shouldAbandonBackendRecovery = (attempts: number, maxAttempts = BACKEND_RECOVERY_MAX_ATTEMPTS): boolean =>
50
+ attempts >= maxAttempts;
51
+
52
+ /** The gateway reports backend failures with `generation: -1`; the host assigns its own counter then. */
53
+ export const normalizeBackendReportedGeneration = (generation: number): number | undefined =>
54
+ generation >= 0 ? generation : undefined;
55
+
42
56
  export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
43
57
  message.devPlan?.actions.includes("restart-dev-host") ?? message.kinds.includes("config");
44
58
 
@@ -339,6 +353,18 @@ export class AkanAppHost {
339
353
  this.#replayBuilderState();
340
354
  return;
341
355
  }
356
+ if (msg.type === "build-status") {
357
+ // The gateway reports replica boot failures (crash loops, port conflicts) this way so
358
+ // they reach the build-status log and the HMR overlay like any other build failure.
359
+ const status = this.#recordBackendBuildStatus({
360
+ generation: normalizeBackendReportedGeneration(msg.data.generation),
361
+ ok: msg.data.ok,
362
+ files: msg.data.files,
363
+ message: msg.data.message,
364
+ });
365
+ this.#sendOrQueueBuildStatus(status);
366
+ return;
367
+ }
342
368
  if (backendMsgTypeSet.has(msg.type)) this.#sendToBuilder(msg);
343
369
  },
344
370
  serialization: "advanced",
@@ -504,6 +530,17 @@ export class AkanAppHost {
504
530
  }
505
531
  #scheduleBackendRecovery(reason: string) {
506
532
  if (this.#backendRecoveryTimer || this.#backend) return;
533
+ if (shouldAbandonBackendRecovery(this.#backendRecoveryAttempts)) {
534
+ const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for a server-side edit to retry.`;
535
+ this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecoveryAttempts} recovery attempts`);
536
+ this.logger.error(`[backend-recovery] ${message}`);
537
+ if (this.#backendStderrTail.length > 0) {
538
+ this.logger.error(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
539
+ }
540
+ const abandonedStatus = this.#recordBackendBuildStatus({ ok: false, files: [], message });
541
+ this.#sendOrQueueBuildStatus(abandonedStatus);
542
+ return;
543
+ }
507
544
  this.#setBackendLifecycleState("recovering", reason);
508
545
  const attempt = this.#backendRecoveryAttempts;
509
546
  const delay = Math.min(BACKEND_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BACKEND_RECOVERY_MAX_DELAY_MS);
@@ -550,16 +587,23 @@ export class AkanAppHost {
550
587
  this.#sendToBackend(message);
551
588
  }
552
589
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
553
- if (shouldRestartBuilderByDevPlan(message)) {
590
+ this.#logDevPlan(message);
591
+ // Config changes subsume builder restarts: the dev-host restart recycles builder and backend
592
+ // AND re-runs the prepare step, so check it first when a batch carries both actions.
593
+ if (shouldRestartDevHostByDevPlan(message)) {
554
594
  try {
555
- await this.#restartDevChildren(message);
595
+ await this.#restartDevHost(message);
556
596
  } catch (err) {
557
- this.#recordDevHostRestartFailure(message, err);
597
+ this.#recordDevHostRestartFailure(message, err, "Config");
558
598
  }
559
599
  return;
560
600
  }
561
- if (shouldRestartDevHostByDevPlan(message)) {
562
- this.#recordDevHostRestartRequired(message);
601
+ if (shouldRestartBuilderByDevPlan(message)) {
602
+ try {
603
+ await this.#restartDevChildren(message);
604
+ } catch (err) {
605
+ this.#recordDevHostRestartFailure(message, err, "Runtime metadata");
606
+ }
563
607
  return;
564
608
  }
565
609
  if (await this.#shouldRestartBackend(message)) {
@@ -573,6 +617,27 @@ export class AkanAppHost {
573
617
  this.logger.warn(
574
618
  `[dev-host] recycling builder/backend for runtime metadata generation=${generation ?? "(unknown)"} files=${message.files.length}`,
575
619
  );
620
+ await this.#recycleDevChildren(message);
621
+ }
622
+ /**
623
+ * Controlled dev-host restart for config changes (akan.config.ts, tsconfig, package.json):
624
+ * re-runs the prepare step so env and codegen reflect the new config, then recycles the builder
625
+ * and backend. The config module is re-imported with a cache-busting query; modules it imports
626
+ * keep their cached instances, so a change inside an imported plugin file still needs a manual
627
+ * `akan start` restart.
628
+ */
629
+ async #restartDevHost(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
630
+ const generation = message.devPlan?.generation ?? message.generation;
631
+ this.logger.warn(
632
+ `[dev-host] config change detected; restarting dev host generation=${generation ?? "(unknown)"} files=${message.files.length}`,
633
+ );
634
+ await this.#recycleDevChildren(message, { refreshConfig: true });
635
+ }
636
+ async #recycleDevChildren(
637
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
638
+ { refreshConfig = false }: { refreshConfig?: boolean } = {},
639
+ ): Promise<void> {
640
+ const generation = message.devPlan?.generation ?? message.generation;
576
641
  if (this.#restartTimer) {
577
642
  clearTimeout(this.#restartTimer);
578
643
  this.#restartTimer = null;
@@ -587,6 +652,13 @@ export class AkanAppHost {
587
652
  this.#pendingBuildStatusReplay = [];
588
653
  await this.#stopBackend();
589
654
  this.#stopBuilder();
655
+ if (refreshConfig) {
656
+ await this.app.getConfig({ refresh: true });
657
+ // Merge instead of replace: start() enriched this.env with values prepare doesn't produce
658
+ // (e.g. REDIS_HOST from the tunnel), and the spawned children must keep seeing them.
659
+ const { env } = await this.app.prepareCommand("start");
660
+ Object.assign(this.env, env);
661
+ }
590
662
  await this.#backendGraph.refresh();
591
663
  await this.#startBuilder();
592
664
  this.#startBackend({ generation, files: message.files });
@@ -608,34 +680,20 @@ export class AkanAppHost {
608
680
  `[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`,
609
681
  );
610
682
  }
611
- #recordDevHostRestartRequired(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
612
- const generation = message.devPlan?.generation ?? message.generation;
613
- const detail = `generation=${generation ?? "(unknown)"} files=${message.files.length}`;
614
- this.logger.warn(
615
- `[dev-host] config change requires a manual restart until controlled dev-host restart is implemented (${detail})`,
616
- );
617
- if (typeof generation === "number") {
618
- const status: DevBuildStatus = {
619
- generation,
620
- phase: "scan",
621
- ok: false,
622
- files: message.files,
623
- message: "Config change requires restarting `akan start` to apply.",
624
- };
625
- this.#recordBuildStatus(status);
626
- this.#sendOrQueueBuildStatus(status);
627
- }
628
- }
629
- #recordDevHostRestartFailure(message: Extract<BuilderMessage, { type: "invalidate" }>, err: unknown): void {
683
+ #recordDevHostRestartFailure(
684
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
685
+ err: unknown,
686
+ kind: "Config" | "Runtime metadata",
687
+ ): void {
630
688
  const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
631
689
  const detail = err instanceof Error ? err.message : String(err);
632
- this.logger.warn(`[dev-host] runtime metadata restart failed generation=${generation}: ${detail}`);
690
+ this.logger.warn(`[dev-host] ${kind.toLowerCase()} restart failed generation=${generation}: ${detail}`);
633
691
  const status: DevBuildStatus = {
634
692
  generation,
635
693
  phase: "scan",
636
694
  ok: false,
637
695
  files: message.files,
638
- message: `Runtime metadata change requires restarting \`akan start\` to apply: ${detail}`,
696
+ message: `${kind} change requires restarting \`akan start\` to apply: ${detail}`,
639
697
  };
640
698
  this.#recordBuildStatus(status);
641
699
  this.#sendOrQueueBuildStatus(status);
@@ -666,13 +724,18 @@ export class AkanAppHost {
666
724
  this.#sendToBackend({ type: "build-status", data: status });
667
725
  }
668
726
  }
727
+ /** One log line per planned generation, regardless of which action branch handles it. */
728
+ #logDevPlan(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
729
+ if (!message.devPlan) return;
730
+ const { generation, roles, actions, reasonByFile } = message.devPlan;
731
+ this.logger.verbose(
732
+ `[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
733
+ );
734
+ }
735
+
669
736
  async #shouldRestartBackend(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<boolean> {
670
737
  if (message.kinds.length === 1 && message.kinds[0] === "css") return false;
671
738
  if (message.devPlan) {
672
- const { generation, roles, actions, reasonByFile } = message.devPlan;
673
- this.logger.verbose(
674
- `[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
675
- );
676
739
  const shouldRestart = shouldRestartBackendByDevPlan(message) ?? false;
677
740
  if (shouldRestart && message.kinds.includes("code")) await this.#backendGraph.refresh();
678
741
  return shouldRestart;
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import fs from "node:fs";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import type { PackageJson } from "../types";
@@ -344,3 +345,26 @@ describe("AkanLibConfig", () => {
344
345
  expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
345
346
  });
346
347
  });
348
+
349
+ describe("AkanAppConfig.importConfigModule", () => {
350
+ test("busting the import cache re-evaluates an edited config module", async () => {
351
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-bust-"));
352
+ try {
353
+ const configPath = path.join(root, "akan.config.ts");
354
+ fs.writeFileSync(configPath, "export default { basePaths: ['first'] };\n");
355
+ const first = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root);
356
+ expect(first.basePaths).toEqual(["first"]);
357
+
358
+ fs.writeFileSync(configPath, "export default { basePaths: ['second'] };\n");
359
+ const stale = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root);
360
+ expect(stale.basePaths).toEqual(["first"]);
361
+
362
+ const busted = await AkanAppConfig.importConfigModule<{ basePaths: string[] }>(root, {
363
+ bustImportCache: true,
364
+ });
365
+ expect(busted.basePaths).toEqual(["second"]);
366
+ } finally {
367
+ fs.rmSync(root, { recursive: true, force: true });
368
+ }
369
+ });
370
+ });
@@ -339,9 +339,26 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
339
339
  command,
340
340
  };
341
341
  }
342
- static async from(app: App) {
342
+ static #importGeneration = 0;
343
+ /**
344
+ * Bun caches dynamic imports by path, so a plain re-import after the user edits the config file
345
+ * returns the stale module. `bustImportCache` appends a fresh query string to force re-evaluation
346
+ * of the config module itself; modules it imports keep their cached instances.
347
+ */
348
+ static async importConfigModule<T = unknown>(
349
+ cwdPath: string,
350
+ { bustImportCache = false }: { bustImportCache?: boolean } = {},
351
+ ): Promise<T> {
352
+ const configPath = `${cwdPath}/akan.config.ts`;
353
+ const importPath = bustImportCache
354
+ ? `${configPath}?akanConfigGeneration=${++AkanAppConfig.#importGeneration}`
355
+ : configPath;
356
+ return (await import(importPath).then((mod: { default: T }) => mod.default)) as T;
357
+ }
358
+
359
+ static async from(app: App, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
343
360
  const [configImp, baseDevEnv, libs, rootPackageJson] = await Promise.all([
344
- import(`${app.cwdPath}/akan.config.ts`).then((mod) => mod.default),
361
+ AkanAppConfig.importConfigModule(app.cwdPath, { bustImportCache }),
345
362
  WorkspaceExecutor.getBaseDevEnv(path.join(app.workspace.workspaceRoot, ".env")),
346
363
  app.workspace.getLibs(),
347
364
  app.workspace.getPackageJson(),
package/executors.ts CHANGED
@@ -1463,7 +1463,9 @@ export class AppExecutor extends SysExecutor {
1463
1463
  #akanConfig: AkanAppConfig | null = null;
1464
1464
  override async getConfig({ refresh }: { refresh?: boolean } = {}) {
1465
1465
  if (this.#akanConfig && !refresh) return this.#akanConfig;
1466
- this.#akanConfig = await AkanAppConfig.from(this);
1466
+ // A refresh means the config file may have been edited; bust the import cache so the fresh
1467
+ // module is evaluated instead of Bun's cached instance.
1468
+ this.#akanConfig = await AkanAppConfig.from(this, { bustImportCache: refresh });
1467
1469
  return this.#akanConfig;
1468
1470
  }
1469
1471
 
@@ -308,15 +308,22 @@ class IncrementalBuilder {
308
308
  }
309
309
  if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
310
310
 
311
- if (kinds.includes("code") && (await this.batchMayChangePageKeys(appDir, expandedBatch))) {
311
+ // Server-only generations (e.g. a .service.ts or srvkit edit) must not rebuild or refresh the
312
+ // client: a fresh pages buildId would broadcast rsc-refresh to browsers for no visible change.
313
+ const rebuildClient = devPlan.actions.includes("rebuild-client");
314
+ if (kinds.includes("code") && !rebuildClient) {
315
+ this.#logger.verbose(`client rebuild skipped; devPlan actions=${devPlan.actions.join(",") || "(none)"}`);
316
+ }
317
+
318
+ if (kinds.includes("code") && rebuildClient && (await this.batchMayChangePageKeys(appDir, expandedBatch))) {
312
319
  const started = Date.now();
313
320
  await this.#app.getPageKeys({ refresh: true });
314
321
  this.#logger.verbose(`pageKeys updated, app pageKeys are refreshed (${Date.now() - started}ms)`);
315
- } else if (kinds.includes("code") && this.batchTouchesPagesTree(appDir, expandedBatch)) {
322
+ } else if (kinds.includes("code") && rebuildClient && this.batchTouchesPagesTree(appDir, expandedBatch)) {
316
323
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
317
324
  }
318
325
 
319
- if (kinds.includes("code") && this.#shouldRebuildCsr()) {
326
+ if (kinds.includes("code") && rebuildClient && this.#shouldRebuildCsr()) {
320
327
  try {
321
328
  const started = Date.now();
322
329
  await new CsrArtifactBuilder(this.#app).build();
@@ -327,13 +334,13 @@ class IncrementalBuilder {
327
334
  this.#logger.error(`csr-rebundle failed: ${message}`);
328
335
  this.#sendBuildStatus("csr", { generation, ok: false, files, message });
329
336
  }
330
- } else if (kinds.includes("code")) {
337
+ } else if (kinds.includes("code") && rebuildClient) {
331
338
  this.#logger.verbose(`csr-rebundle skipped; set AKAN_DEV_CSR_REBUILD=1 to enable per-save CSR rebuilds`);
332
339
  }
333
340
 
334
341
  process.send?.(event);
335
342
 
336
- if (kinds.includes("code")) {
343
+ if (kinds.includes("code") && rebuildClient) {
337
344
  try {
338
345
  const started = Date.now();
339
346
  const next = await new PagesBundleBuilder(this.#app).build();
@@ -349,7 +356,9 @@ class IncrementalBuilder {
349
356
  this.#sendBuildStatus("pages", { generation, ok: false, files, message });
350
357
  }
351
358
  }
352
- if (kinds.includes("code") || kinds.includes("css")) {
359
+ // Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
360
+ // client rebuild or a direct stylesheet edit can change the compiled CSS.
361
+ if (kinds.includes("css") || (kinds.includes("code") && rebuildClient)) {
353
362
  this.scheduleCssRebuild(artifactDir, { refresh: true, generation, changedFiles: files });
354
363
  this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
355
364
  }
@@ -368,6 +377,12 @@ class IncrementalBuilder {
368
377
  return;
369
378
  }
370
379
  });
380
+ // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
381
+ // as an orphaned watcher that keeps rebuilding for nobody.
382
+ process.on("disconnect", () => {
383
+ this.#logger.warn("host IPC channel closed; exiting builder");
384
+ process.exit(0);
385
+ });
371
386
  if (this.#watch) await this.installWatcher();
372
387
  process.send?.({ type: "builder-ready" });
373
388
  this.#logger.verbose(`ready (watch=${this.#watch})`);
@@ -45,6 +45,50 @@ const waitForFileIncludes = async (filePath: string, text: string, timeoutMs = 5
45
45
  return null;
46
46
  };
47
47
 
48
+ interface GatewayHealth {
49
+ status: string;
50
+ pid?: number;
51
+ children: Array<{ idx: number; role: string; status: string; ready: boolean; pid?: number }>;
52
+ }
53
+
54
+ const fetchGatewayHealth = async (port: number): Promise<GatewayHealth | null> => {
55
+ const res = await fetch(`http://127.0.0.1:${port}/_akan/app/health`).catch(() => null);
56
+ if (!res?.ok) return null;
57
+ return (await res.json()) as GatewayHealth;
58
+ };
59
+
60
+ const waitForGatewayHealth = async (
61
+ port: number,
62
+ predicate: (health: GatewayHealth) => boolean,
63
+ timeoutMs = 60_000,
64
+ ): Promise<GatewayHealth> => {
65
+ const started = Date.now();
66
+ while (Date.now() - started < timeoutMs) {
67
+ const health = await fetchGatewayHealth(port);
68
+ if (health && predicate(health)) return health;
69
+ await new Promise((resolve) => setTimeout(resolve, 200));
70
+ }
71
+ throw new Error(`Timed out waiting for gateway health on port ${port}`);
72
+ };
73
+
74
+ const isProcessAlive = (pid: number): boolean => {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ };
82
+
83
+ const waitForProcessesGone = async (pids: number[], timeoutMs = 15_000): Promise<boolean> => {
84
+ const started = Date.now();
85
+ while (Date.now() - started < timeoutMs) {
86
+ if (pids.every((pid) => !isProcessAlive(pid))) return true;
87
+ await new Promise((resolve) => setTimeout(resolve, 200));
88
+ }
89
+ return false;
90
+ };
91
+
48
92
  afterEach(async () => {
49
93
  await Promise.all(harnesses.splice(0).map((harness) => harness.cleanup()));
50
94
  });
@@ -159,6 +203,32 @@ export const dictionary = serviceDictionary(["en", "ko"])
159
203
  hmr?.close();
160
204
  });
161
205
 
206
+ integrationTest("config edits restart the dev host and keep serving", async () => {
207
+ const harness = await createHarness();
208
+ const host = await harness.startHost();
209
+ const initialHtml = await harness.tryWaitForHttpText("initial-shared-marker", 3_000);
210
+ if (!initialHtml) {
211
+ expect(host.proc.killed).toBe(false);
212
+ return;
213
+ }
214
+ const mark = host.markLog();
215
+
216
+ await harness.writeFile(
217
+ "akan.config.ts",
218
+ `import type { AppConfig } from "akanjs";
219
+
220
+ const config: AppConfig = { externalLibs: [] };
221
+ export default config;
222
+ `,
223
+ );
224
+
225
+ await host.waitForLogSince(mark, /\[dev-plan\].*actions=.*restart-dev-host/);
226
+ await host.waitForLogSince(mark, /\[dev-host\] config change detected; restarting dev host/);
227
+ await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
228
+ await harness.waitForHttpText("initial-shared-marker");
229
+ expect(host.proc.killed).toBe(false);
230
+ });
231
+
162
232
  integrationTest("client build failure reports error and recovers after fix", async () => {
163
233
  const harness = await createHarness();
164
234
  const host = await harness.startHost();
@@ -205,16 +275,20 @@ export const dictionary = serviceDictionary(["en", "ko"])
205
275
  integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
206
276
  const harness = await createHarness();
207
277
  const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
208
- const facets = ["common", "ui"] as const;
209
-
210
- for (const facet of facets) {
211
- const exportName = `${facet}TmpExample`;
278
+ // `common` barrels export camelCase names; `ui` barrels export PascalCase component names. Each facet's
279
+ // fixture file must follow its own casing convention or the barrel deliberately skips it.
280
+ const facets = [
281
+ { facet: "common", moduleName: "tmpExample", fileName: "tmpExample.ts", exportName: "commonTmpExample" },
282
+ { facet: "ui", moduleName: "TmpExample", fileName: "TmpExample.tsx", exportName: "TmpExample" },
283
+ ] as const;
284
+
285
+ for (const { facet, moduleName, fileName, exportName } of facets) {
212
286
  const indexPath = `${facet}/index.ts`;
213
- const absChangedFile = `${harness.appDir}/${facet}/tmpExample.ts`;
287
+ const absChangedFile = `${harness.appDir}/${facet}/${fileName}`;
214
288
  const absIndexPath = `${harness.appDir}/${indexPath}`;
215
289
 
216
290
  await harness.writeFile(
217
- `${facet}/tmpExample.ts`,
291
+ `${facet}/${fileName}`,
218
292
  `export const ${exportName} = "added-${facet}-example";
219
293
  `,
220
294
  );
@@ -222,21 +296,105 @@ export const dictionary = serviceDictionary(["en", "ko"])
222
296
  const added = await sync.syncForBatch([absChangedFile]);
223
297
  expect(added.errors).toEqual([]);
224
298
  expect(added.changedFiles).toContain(absIndexPath);
225
- const addedIndex = await waitForFileIncludes(absIndexPath, "tmpExample");
299
+ const addedIndex = await waitForFileIncludes(absIndexPath, moduleName);
226
300
  expect(addedIndex).not.toBeNull();
227
- expect(addedIndex ?? "").toContain("tmpExample");
301
+ expect(addedIndex ?? "").toContain(moduleName);
228
302
 
229
- await harness.removeFile(`${facet}/tmpExample.ts`);
303
+ await harness.removeFile(`${facet}/${fileName}`);
230
304
  const removed = await sync.syncForBatch([absChangedFile]);
231
305
  expect(removed.errors).toEqual([]);
232
306
  expect(removed.changedFiles).toContain(absIndexPath);
233
- const deletedIndex = await waitForFileIncludes(absIndexPath, "tmpExample", 1_000);
234
- if (deletedIndex) throw new Error(`${indexPath} still contains tmpExample after delete`);
307
+ const deletedIndex = await waitForFileIncludes(absIndexPath, moduleName, 1_000);
308
+ if (deletedIndex) throw new Error(`${indexPath} still contains ${moduleName} after delete`);
235
309
  const finalIndex = await Bun.file(absIndexPath).text();
236
310
  expect(finalIndex).toBeString();
237
311
  }
238
312
  });
239
313
 
314
+ integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
315
+ const harness = await createHarness();
316
+ const host = await harness.startHost();
317
+ const failureMark = host.markLog();
318
+
319
+ // The service file is part of the generated server graph (`akan start` regenerates server.ts
320
+ // from lib/), so a module-level throw here breaks every replica boot.
321
+ await harness.writeFile(
322
+ "lib/_fixture/fixture.service.ts",
323
+ `import { serve } from "akanjs/service";
324
+
325
+ export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
326
+
327
+ throw new Error("intentional-backend-boot-crash");
328
+ `,
329
+ );
330
+
331
+ // The gateway abandons the replica after three failed boots instead of retrying forever...
332
+ await host.waitForLogSince(failureMark, /\[child-crash-loop\].*failed 3 consecutive boots/);
333
+ // ...and the failure reaches the dev host's build-status pipeline (HMR overlay path).
334
+ await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
335
+ expect(host.proc.killed).toBe(false);
336
+
337
+ const recoveryMark = host.markLog();
338
+ await harness.writeFile(
339
+ "lib/_fixture/fixture.service.ts",
340
+ `import { serve } from "akanjs/service";
341
+
342
+ export class FixtureService extends serve("fixture" as const, { serverMode: "batch" }, () => ({})) {}
343
+ `,
344
+ );
345
+
346
+ await host.waitForLogSince(recoveryMark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
347
+ expect(host.proc.killed).toBe(false);
348
+ });
349
+
350
+ integrationTest("SIGKILL'd gateway leaves no orphaned replicas and the host recovers", async () => {
351
+ const harness = await createHarness();
352
+ const host = await harness.startHost();
353
+ const port = await harness.resolvePort();
354
+
355
+ const healthy = await waitForGatewayHealth(
356
+ port,
357
+ (health) =>
358
+ typeof health.pid === "number" &&
359
+ health.children.length > 0 &&
360
+ health.children.every((child) => child.ready && typeof child.pid === "number"),
361
+ );
362
+ const gatewayPid = healthy.pid as number;
363
+ const childPids = healthy.children.map((child) => child.pid as number);
364
+ const mark = host.markLog();
365
+
366
+ process.kill(gatewayPid, "SIGKILL");
367
+
368
+ // Children must notice the closed IPC channel and exit instead of orphaning (they would
369
+ // otherwise keep holding their ws ports and break every subsequent boot).
370
+ expect(await waitForProcessesGone(childPids)).toBe(true);
371
+
372
+ await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
373
+ const recovered = await waitForGatewayHealth(
374
+ port,
375
+ (health) => typeof health.pid === "number" && health.pid !== gatewayPid && health.children.some((c) => c.ready),
376
+ );
377
+ expect(recovered.pid).not.toBe(gatewayPid);
378
+ expect(host.proc.killed).toBe(false);
379
+ });
380
+
381
+ integrationTest("occupied preferred ws port falls back to an ephemeral port and stays bootable", async () => {
382
+ const harness = await createHarness();
383
+ const port = await harness.resolvePort();
384
+ // The gateway assigns child 0 the deterministic ws port `port + 10_000`; occupy it up front
385
+ // the way an orphaned replica from a killed run would.
386
+ const blocker = Bun.serve({ port: port + 10_000, fetch: () => new Response("occupied") });
387
+ try {
388
+ const host = await harness.startHost();
389
+ await host.waitForLog(/falling back to an ephemeral port/);
390
+ const health = await waitForGatewayHealth(port, (h) => h.children.some((child) => child.ready));
391
+ expect(health.children.some((child) => child.ready)).toBe(true);
392
+ expect(host.proc.killed).toBe(false);
393
+ } finally {
394
+ blocker.stop(true);
395
+ }
396
+ });
397
+
240
398
  integrationTest("route and css phase-5 scope remains smoke-level in this harness", async () => {
241
399
  const manualSmoke = [
242
400
  "route add/delete should be covered by a later browser-driven test",
@@ -259,6 +259,9 @@ export const dictionary = serviceDictionary(["en", "ko"])
259
259
  env: {
260
260
  ...process.env,
261
261
  AKAN_VERBOSE: "1",
262
+ // The dev-plan/hmr assertions match verbose-level Logger lines; without this the log
263
+ // stream only carries info/warn/error and those waits time out with empty tails.
264
+ AKAN_PUBLIC_LOG_LEVEL: "verbose",
262
265
  NODE_NO_WARNINGS: "1",
263
266
  PORT_OFFSET: String(this.portOffset),
264
267
  },
@@ -422,9 +425,21 @@ export const dictionary = serviceDictionary(["en", "ko"])
422
425
  }
423
426
 
424
427
  async resolvePort(): Promise<number> {
425
- const apps = await readdir(path.join(this.workspaceRoot, "apps")).catch(() => []);
426
- const appIndex = Math.max([...new Set([...apps, this.appName])].sort().indexOf(this.appName), 0);
427
- return 8282 + appIndex + this.portOffset;
428
+ // Mirror the CLI's `getDevPort()` exactly (apps = directories containing akan.config.ts,
429
+ // locale-sorted): counting stray entries like .DS_Store would put us one port off the gateway.
430
+ const appsDir = path.join(this.workspaceRoot, "apps");
431
+ const entries = await readdir(appsDir, { withFileTypes: true }).catch(() => []);
432
+ const checked = await Promise.all(
433
+ entries
434
+ .filter((entry) => entry.isDirectory())
435
+ .map(async (entry) =>
436
+ (await Bun.file(path.join(appsDir, entry.name, "akan.config.ts")).exists()) ? entry.name : null,
437
+ ),
438
+ );
439
+ const apps = [...new Set([...checked.filter((name): name is string => name !== null), this.appName])].sort((a, b) =>
440
+ a.localeCompare(b),
441
+ );
442
+ return 8282 + Math.max(apps.indexOf(this.appName), 0) + this.portOffset;
428
443
  }
429
444
  }
430
445
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.0-rc.0",
3
+ "version": "2.4.0-rc.2",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.4.0-rc.0",
35
+ "akanjs": "2.4.0-rc.2",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",
package/scanInfo.ts CHANGED
@@ -1,5 +1,5 @@
1
- import path from "node:path";
2
1
  import { rm } from "node:fs/promises";
2
+ import path from "node:path";
3
3
  import type {
4
4
  AppConfigResult,
5
5
  AppScanResult,
@@ -71,6 +71,7 @@ const appRootAllowedDirs = new Set([
71
71
  "srvkit",
72
72
  "webkit",
73
73
  "common",
74
+ "secrets",
74
75
  ]);
75
76
  const libRootAllowedFiles = new Set([
76
77
  "cnst.ts",