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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +238 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/createTunnel.test.ts +12 -2
  44. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  45. package/errorCodeReference.test.ts +282 -0
  46. package/errorCodeReference.ts +71 -0
  47. package/errorCodeScan.ts +551 -0
  48. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  49. package/frontendBuild/allRoutesBuilder.ts +24 -6
  50. package/frontendBuild/buildRouteClient.test.ts +43 -0
  51. package/frontendBuild/bundleDefine.ts +2 -0
  52. package/frontendBuild/clientBuildTypes.ts +5 -2
  53. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  54. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  55. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  56. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  57. package/frontendBuild/csrDevBundler.ts +12 -2
  58. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  59. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  60. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  61. package/frontendBuild/csrDevPatcher.ts +47 -7
  62. package/frontendBuild/csrDevTypes.ts +16 -0
  63. package/frontendBuild/cssCompiler.test.ts +63 -1
  64. package/frontendBuild/devBarrelImports.ts +132 -0
  65. package/frontendBuild/devRegistryBundler.ts +19 -4
  66. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  67. package/frontendBuild/nativeCore.test.ts +148 -0
  68. package/frontendBuild/precompressArtifacts.ts +1 -1
  69. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  70. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  71. package/frontendBuild/ssrDevBundler.ts +13 -2
  72. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  73. package/frontendBuild/vendorImportScanner.ts +25 -10
  74. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  75. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  76. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  77. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  78. package/lint/no-import-common-err.grit +31 -0
  79. package/lint/no-init-fetch-in-client.grit +1 -1
  80. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  81. package/maintainabilityBaseline.test.ts +49 -0
  82. package/maintainabilityBaseline.ts +73 -8
  83. package/maintainabilityContract.test.ts +302 -1
  84. package/maintainabilityContract.ts +87 -22
  85. package/maintainabilityExceptionScanner.test.ts +14 -0
  86. package/maintainabilityExceptionScanner.ts +5 -1
  87. package/maintainabilityLimits.ts +12 -0
  88. package/maintainabilityReport.ts +20 -3
  89. package/moduleFieldNames.test.ts +56 -0
  90. package/moduleFieldNames.ts +25 -0
  91. package/package.json +3 -3
  92. package/preview/PreviewChrome.ts +3 -3
  93. package/preview/PreviewView.ts +48 -9
  94. package/preview/previewView.test.ts +154 -3
  95. package/routeJsBudget.test.ts +47 -11
  96. package/routeJsBudget.ts +88 -25
  97. package/transforms/barrelAnalyzer.ts +46 -1
  98. package/transforms/barrelImportsPlugin.ts +61 -45
  99. package/transforms/transforms.test.ts +67 -1
  100. package/typecheck/projectTypecheck.ts +5 -1
  101. package/typecheck/typecheck.proc.ts +3 -2
  102. package/typecheck/typecheckFailure.ts +6 -1
  103. package/workflow/artifacts.ts +25 -7
  104. package/workflow/fieldType.ts +92 -0
  105. package/workflow/index.ts +2 -0
  106. package/workflow/plainName.ts +28 -0
  107. package/workflow/plan.ts +67 -22
  108. package/workflow/source.ts +0 -5
  109. package/workspaceGraph.test.ts +200 -3
  110. package/workspaceGraph.ts +46 -8
@@ -143,3 +143,130 @@ describe("a gateway whose replica crash-loops", () => {
143
143
  ]);
144
144
  });
145
145
  });
146
+
147
+ describe("a builder request while no builder is running", () => {
148
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
149
+ const track = tempRoots();
150
+ const stoppedBuilder = {
151
+ status: "stopped",
152
+ patcherOff: false,
153
+ start: ({ onReady }: { onReady?: () => void }) => onReady?.(),
154
+ send: () => false,
155
+ stop: () => undefined,
156
+ } as unknown as IncrementalBuilderHost;
157
+
158
+ test("is answered at once and marked unavailable, so the backend can tell it from a failed build", async () => {
159
+ const { root, app } = track(await createTempApp("demo"));
160
+ const replyPath = path.join(root, "reply.json");
161
+ await writeText(
162
+ path.join(root, "apps/demo/main.ts"),
163
+ [
164
+ 'import fs from "node:fs";',
165
+ `const replyPath = ${JSON.stringify(replyPath)};`,
166
+ 'process.on("message", (message) => {',
167
+ ' if (message?.type !== "build-graph-res") return;',
168
+ ' fs.writeFileSync(replyPath + ".tmp", JSON.stringify(message));',
169
+ ' fs.renameSync(replyPath + ".tmp", replyPath);',
170
+ "});",
171
+ 'process.send?.({ type: "backend-ready", pid: process.pid });',
172
+ 'process.send?.({ type: "build-graph", id: 7 });',
173
+ "setInterval(() => undefined, 1_000);",
174
+ "",
175
+ ].join("\n"),
176
+ );
177
+ const create = IncrementalBuilderHost.create;
178
+ IncrementalBuilderHost.create = async () => stoppedBuilder;
179
+ const host = new AkanAppHost(app, { env: { ...process.env } as Record<string, string> });
180
+ try {
181
+ await host.start();
182
+ const deadline = performance.now() + 5_000;
183
+ while (!(await Bun.file(replyPath).exists()) && performance.now() < deadline) await Bun.sleep(25);
184
+ expect(await Bun.file(replyPath).json()).toEqual({
185
+ type: "build-graph-res",
186
+ id: 7,
187
+ ok: false,
188
+ error: "builder is stopped; reload after the builder is ready",
189
+ kind: "unavailable",
190
+ });
191
+ } finally {
192
+ await host.stop();
193
+ IncrementalBuilderHost.create = create;
194
+ }
195
+ });
196
+ });
197
+
198
+ describe("a stop while the builder is starting", () => {
199
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
200
+ const track = tempRoots();
201
+ const fakeBuilder = (onStart: (options: { onReady?: () => void; onExit?: () => void }) => void = () => undefined) => {
202
+ const builder = {
203
+ status: "starting",
204
+ patcherOff: false,
205
+ started: 0,
206
+ stopped: 0,
207
+ start: (options: { onReady?: () => void; onExit?: () => void }) => {
208
+ builder.started += 1;
209
+ onStart(options);
210
+ },
211
+ send: () => false,
212
+ stop: () => {
213
+ builder.stopped += 1;
214
+ },
215
+ };
216
+ return builder;
217
+ };
218
+
219
+ test("stops a builder created after the stop, and never starts it", async () => {
220
+ const { app } = track(await createTempApp("demo"));
221
+ const builder = fakeBuilder();
222
+ const created = Promise.withResolvers<IncrementalBuilderHost>();
223
+ const create = IncrementalBuilderHost.create;
224
+ let creates = 0;
225
+ IncrementalBuilderHost.create = async () => {
226
+ creates += 1;
227
+ return await created.promise;
228
+ };
229
+ const host = new AkanAppHost(app, { env: { ...process.env } as Record<string, string> });
230
+ try {
231
+ const starting = host.start();
232
+ await Bun.sleep(50);
233
+ await host.stop();
234
+ created.resolve(builder as unknown as IncrementalBuilderHost);
235
+ await expect(starting).rejects.toThrow("dev server stopped before the builder started");
236
+ expect({ creates, started: builder.started, stopped: builder.stopped }).toEqual({
237
+ creates: 1,
238
+ started: 0,
239
+ stopped: 1,
240
+ });
241
+ } finally {
242
+ IncrementalBuilderHost.create = create;
243
+ }
244
+ });
245
+
246
+ test("gives up a builder still booting at once instead of retrying after its ready timeout", async () => {
247
+ const { app } = track(await createTempApp("demo"));
248
+ let exitLate: (() => void) | undefined;
249
+ const builder = fakeBuilder(({ onExit }) => {
250
+ exitLate = onExit;
251
+ });
252
+ const create = IncrementalBuilderHost.create;
253
+ let creates = 0;
254
+ IncrementalBuilderHost.create = async () => {
255
+ creates += 1;
256
+ return builder as unknown as IncrementalBuilderHost;
257
+ };
258
+ const host = new AkanAppHost(app, { env: { ...process.env } as Record<string, string> });
259
+ try {
260
+ const starting = host.start();
261
+ await Bun.sleep(50);
262
+ expect(builder.started).toBe(1);
263
+ await host.stop();
264
+ await expect(starting).rejects.toThrow("dev server stopped before the builder was ready");
265
+ exitLate?.();
266
+ await Bun.sleep(50);
267
+ expect({ creates, stopped: builder.stopped }).toEqual({ creates: 1, stopped: 1 });
268
+ } finally {
269
+ IncrementalBuilderHost.create = create;
270
+ }
271
+ });
272
+ });
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { Logger } from "akanjs/common";
2
+ import { Logger, RestartSchedule } from "akanjs/common";
3
3
  import type { BuilderMessage, BuilderMetrics, BuildPhase, ChangeBatch, DevBuildStatus } from "akanjs/server";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { createTunnel } from "../createTunnel";
@@ -15,6 +15,7 @@ import {
15
15
  type BackendLifecycleState,
16
16
  type BackendRestartReason,
17
17
  BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS,
18
+ backendRecoveryPolicy,
18
19
  backendRestartReasonFromMessage,
19
20
  buildStatusReplaySequence,
20
21
  createBackendBuildStatus,
@@ -35,7 +36,6 @@ import {
35
36
  redisTunnelPolicy,
36
37
  resolveIdleSuspendMs,
37
38
  type SourceFingerprints,
38
- shouldAbandonBackendRecovery,
39
39
  shouldHoldForReturningBuilder,
40
40
  shouldKeepBuildFailure,
41
41
  shouldMarkBuildPhaseRecovered,
@@ -59,10 +59,6 @@ const BACKEND_RESTART_DEBOUNCE_MS = 120;
59
59
  // Above the gateway's ~5s child-shutdown wait: a gateway SIGKILLed mid-shutdown strands orphan replicas.
60
60
  const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
61
61
 
62
- const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
63
-
64
- const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
65
-
66
62
  const BACKEND_STDERR_TAIL_LIMIT = 40;
67
63
 
68
64
  const BUILDER_READY_TIMEOUT_MS = 150000;
@@ -77,11 +73,6 @@ const BUILDER_RSS_SETTLE_MS = 20_000;
77
73
 
78
74
  const PS_RSS_TIMEOUT_MS = 2_000;
79
75
 
80
- // The builder is the file watcher, so unlike the backend its recovery never gives up; it only backs off.
81
- const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
82
-
83
- const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
84
-
85
76
  interface LastGoodFrontendState {
86
77
  pages?: Extract<BuilderMessage, { type: "pages-updated" }>;
87
78
  css?: Extract<BuilderMessage, { type: "css-updated" }>;
@@ -101,8 +92,7 @@ export class AkanAppHost {
101
92
  #backendReady = false;
102
93
  #plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
103
94
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
104
- #backendRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
105
- #backendRecoveryAttempts = 0;
95
+ readonly #backendRecovery = new RestartSchedule(backendRecoveryPolicy);
106
96
  #backendGaveUp = false;
107
97
  #backendLifecycleState: BackendLifecycleState = "stopped";
108
98
  #pendingRestartReason: BackendRestartReason | null = null;
@@ -119,8 +109,9 @@ export class AkanAppHost {
119
109
  //? its recovery, reported past the failure's generation so the scan overlay clears.
120
110
  #restartFailedAfterApply: number | null = null;
121
111
  #stopping = false;
122
- #builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
123
- #builderRecoveryAttempts = 0;
112
+ //? The builder is the file watcher, so unlike the backend its recovery never gives up; it only backs off.
113
+ readonly #builderRecovery = new RestartSchedule({ min: 2_000, max: 60_000 });
114
+ #abandonBuilderStart: ((error: Error) => void) | null = null;
124
115
  #backendStartStatus: { generation?: number; files: string[] } | null = null;
125
116
  #backendBuildStatusGeneration = 0;
126
117
  #backendStderrTail: string[] = [];
@@ -186,6 +177,8 @@ export class AkanAppHost {
186
177
  this.#cancelIdleSuspend();
187
178
  this.#stopIdleWatcher();
188
179
  this.#clearRestartTimers();
180
+ //? A stopped builder sends no exit: a start waiting for its ready would sit out the ready timeout, then retry.
181
+ this.#abandonBuilderStart?.(new Error("dev server stopped before the builder was ready"));
189
182
  this.#pendingRestartReason = null;
190
183
  // Before the backend goes away, while it can still receive the answer.
191
184
  this.#failPendingBuilderMessages("dev server is shutting down");
@@ -195,9 +188,10 @@ export class AkanAppHost {
195
188
  return this;
196
189
  }
197
190
  #clearRestartTimers() {
198
- for (const timer of [this.#restartTimer, this.#backendRecoveryTimer, this.#builderRecoveryTimer])
199
- if (timer) clearTimeout(timer);
200
- this.#restartTimer = this.#backendRecoveryTimer = this.#builderRecoveryTimer = null;
191
+ if (this.#restartTimer) clearTimeout(this.#restartTimer);
192
+ this.#restartTimer = null;
193
+ this.#backendRecovery.cancel();
194
+ this.#builderRecovery.cancel();
201
195
  }
202
196
  kill() {
203
197
  void this.stop();
@@ -239,7 +233,7 @@ export class AkanAppHost {
239
233
  if (!msg || typeof msg !== "object") return;
240
234
  if (msg.type === "backend-ready") {
241
235
  this.#backendReady = true;
242
- this.#backendRecoveryAttempts = 0;
236
+ this.#backendRecovery.reset();
243
237
  this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
244
238
  this.#recordBackendReadyStatus();
245
239
  this.logger.verbose(`backend ready pid=${msg.pid}`);
@@ -402,10 +396,7 @@ export class AkanAppHost {
402
396
  "restart-pending",
403
397
  `generation=${pending.generation ?? "(unknown)"} files=${pending.files.length} roles=${pending.roles.join(",") || "(none)"}`,
404
398
  );
405
- if (this.#backendRecoveryTimer) {
406
- clearTimeout(this.#backendRecoveryTimer);
407
- this.#backendRecoveryTimer = null;
408
- }
399
+ this.#backendRecovery.cancel();
409
400
  if (this.#restartTimer) clearTimeout(this.#restartTimer);
410
401
  this.#restartTimer = setTimeout(() => {
411
402
  this.#restartTimer = null;
@@ -426,16 +417,16 @@ export class AkanAppHost {
426
417
  this.logger.verbose(
427
418
  `[backend-reload] restarting backend generation=${reason.generation ?? "(unknown)"} files=${reason.files.length} roles=${reason.roles.join(",") || "(none)"}`,
428
419
  );
429
- this.#backendRecoveryAttempts = 0;
420
+ this.#backendRecovery.reset();
430
421
  await Promise.all([this.#stopBackend(), this.#backendGraph.refresh()]);
431
422
  this.#startBackend({ generation: reason.generation, files: reason.files });
432
423
  }
433
424
  #scheduleBackendRecovery(reason: string) {
434
- if (this.#backendRecoveryTimer || this.#backend) return;
435
- if (shouldAbandonBackendRecovery(this.#backendRecoveryAttempts)) {
436
- const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for an edit or a green build to retry.`;
425
+ if (this.#backendRecovery.pending || this.#backend) return;
426
+ if (this.#backendRecovery.exhausted) {
427
+ const message = `Backend exited ${this.#backendRecovery.attempts} times in a row (${reason}); waiting for an edit or a green build to retry.`;
437
428
  this.#backendGaveUp = true;
438
- this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecoveryAttempts} recovery attempts`);
429
+ this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecovery.attempts} recovery attempts`);
439
430
  this.logger.error(`[backend-recovery] ${message}`);
440
431
  if (this.#backendStderrTail.length > 0) {
441
432
  this.logger.error(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
@@ -445,9 +436,7 @@ export class AkanAppHost {
445
436
  return;
446
437
  }
447
438
  this.#setBackendLifecycleState("recovering", reason);
448
- const attempt = this.#backendRecoveryAttempts;
449
- const delay = Math.min(BACKEND_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BACKEND_RECOVERY_MAX_DELAY_MS);
450
- this.#backendRecoveryAttempts = attempt + 1;
439
+ const delay = this.#backendRecovery.next();
451
440
  const failureStatus = this.#recordBackendBuildStatus({
452
441
  ok: false,
453
442
  files: [],
@@ -455,18 +444,17 @@ export class AkanAppHost {
455
444
  });
456
445
  this.#sendOrQueueBuildStatus(failureStatus);
457
446
  this.logger.warn(
458
- `[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecoveryAttempts})`,
447
+ `[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecovery.attempts})`,
459
448
  );
460
449
  if (this.#backendStderrTail.length > 0) {
461
450
  this.logger.warn(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
462
451
  }
463
- this.#backendRecoveryTimer = setTimeout(() => {
464
- this.#backendRecoveryTimer = null;
452
+ this.#backendRecovery.arm(delay, () => {
465
453
  if (this.#backend) return;
466
454
  void this.#backendGraph.refresh().finally(() => {
467
455
  if (!this.#backend) this.#startBackend({ generation: failureStatus.generation, files: failureStatus.files });
468
456
  });
469
- }, delay);
457
+ });
470
458
  }
471
459
  #enqueueBuilderMessage(message: BuilderMessage) {
472
460
  this.#builderMessageQueue = this.#builderMessageQueue
@@ -763,8 +751,8 @@ export class AkanAppHost {
763
751
  this.#pendingRecycle ||
764
752
  this.#restartTimer ||
765
753
  this.#backendRestart ||
766
- this.#backendRecoveryTimer ||
767
- this.#builderRecoveryTimer ||
754
+ this.#backendRecovery.pending ||
755
+ this.#builderRecovery.pending ||
768
756
  this.#rssRecycleReason
769
757
  );
770
758
  }
@@ -922,11 +910,11 @@ export class AkanAppHost {
922
910
  }
923
911
  #failBuilderRequest(message: BuilderMessage, error: string): boolean {
924
912
  if (message.type === "build-route")
925
- this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error });
913
+ this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error, kind: "unavailable" });
926
914
  else if (message.type === "build-csr")
927
- this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error });
915
+ this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error, kind: "unavailable" });
928
916
  else if (message.type === "build-graph")
929
- this.#sendToBackend({ type: "build-graph-res", id: message.id, ok: false, error });
917
+ this.#sendToBackend({ type: "build-graph-res", id: message.id, ok: false, error, kind: "unavailable" });
930
918
  else return false;
931
919
  return true;
932
920
  }
@@ -1024,28 +1012,25 @@ export class AkanAppHost {
1024
1012
  this.#scheduleBuilderRecovery({ generation, files: message.files });
1025
1013
  }
1026
1014
  #scheduleBuilderRecovery(reason: { generation?: number; files: string[] }): void {
1027
- if (this.#builderRecoveryTimer || this.#builder) return;
1028
- const attempt = this.#builderRecoveryAttempts;
1029
- const delay = Math.min(BUILDER_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BUILDER_RECOVERY_MAX_DELAY_MS);
1030
- this.#builderRecoveryAttempts = attempt + 1;
1015
+ if (this.#stopping || this.#builderRecovery.pending || this.#builder) return;
1016
+ const delay = this.#builderRecovery.schedule(() => {
1017
+ if (this.#stopping || this.#builder) return;
1018
+ void this.#recoverBuilder(reason);
1019
+ });
1031
1020
  this.logger.warn(
1032
- `[builder-recovery] builder is down; retrying start in ${delay}ms (attempt ${this.#builderRecoveryAttempts})`,
1021
+ `[builder-recovery] builder is down; retrying start in ${delay}ms (attempt ${this.#builderRecovery.attempts})`,
1033
1022
  );
1034
- this.#builderRecoveryTimer = setTimeout(() => {
1035
- this.#builderRecoveryTimer = null;
1036
- if (this.#builder) return;
1037
- void this.#recoverBuilder(reason);
1038
- }, delay);
1039
1023
  }
1040
1024
  async #recoverBuilder(reason: { generation?: number; files: string[] }): Promise<void> {
1041
1025
  try {
1042
1026
  await this.#startBuilder();
1043
1027
  } catch (err) {
1028
+ if (this.#stopping) return;
1044
1029
  this.logger.warn(`[builder-recovery] builder start failed: ${err instanceof Error ? err.message : String(err)}`);
1045
1030
  this.#scheduleBuilderRecovery(reason);
1046
1031
  return;
1047
1032
  }
1048
- this.#builderRecoveryAttempts = 0;
1033
+ this.#builderRecovery.reset();
1049
1034
  this.logger.info("[builder-recovery] builder recovered");
1050
1035
  void this.#restartBackendForGapChanges();
1051
1036
  const failedAfterApply = this.#restartFailedAfterApply;
@@ -1062,14 +1047,14 @@ export class AkanAppHost {
1062
1047
  };
1063
1048
  this.#recordBuildStatus(status);
1064
1049
  this.#sendOrQueueBuildStatus(status);
1065
- if (!this.#backend && !this.#backendRecoveryTimer) {
1050
+ if (!this.#backend && !this.#backendRecovery.pending) {
1066
1051
  this.#startBackend({ generation: reason.generation, files: reason.files });
1067
1052
  }
1068
1053
  }
1069
1054
  #reviveBackendAfterGreenBuild(status: DevBuildStatus): void {
1070
- if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecoveryTimer) return;
1055
+ if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecovery.pending) return;
1071
1056
  this.logger.info(`[backend-recovery] build went green (generation=${status.generation}); retrying backend`);
1072
- this.#backendRecoveryAttempts = 0;
1057
+ this.#backendRecovery.reset();
1073
1058
  this.#startBackend({ generation: status.generation, files: status.files });
1074
1059
  }
1075
1060
  async #restartDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
@@ -1097,7 +1082,7 @@ export class AkanAppHost {
1097
1082
  ): Promise<void> {
1098
1083
  const generation = message.devPlan?.generation ?? message.generation;
1099
1084
  this.#clearRestartTimers();
1100
- this.#builderRecoveryAttempts = 0;
1085
+ this.#builderRecovery.reset();
1101
1086
  this.#pendingRestartReason = null;
1102
1087
  await this.#backendRestart;
1103
1088
  this.#lastGoodFrontend = {};
@@ -1232,7 +1217,7 @@ export class AkanAppHost {
1232
1217
  this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
1233
1218
  let lastError: unknown;
1234
1219
  for (let attempt = 1; attempt <= BUILDER_START_MAX_ATTEMPTS; attempt++) {
1235
- this.#builder = await IncrementalBuilderHost.create(
1220
+ const builder = await IncrementalBuilderHost.create(
1236
1221
  this.app,
1237
1222
  this.env,
1238
1223
  (msg) => {
@@ -1244,6 +1229,11 @@ export class AkanAppHost {
1244
1229
  patcherOff: this.#patcherOff,
1245
1230
  },
1246
1231
  );
1232
+ if (this.#stopping) {
1233
+ builder.stop();
1234
+ throw new Error("dev server stopped before the builder started");
1235
+ }
1236
+ this.#builder = builder;
1247
1237
  try {
1248
1238
  await this.#waitForBuilderReady(attempt, { announceBootState });
1249
1239
  this.app.verbose(`[cli] base build ready in ${Date.now() - startTime}ms — starting backend`);
@@ -1251,7 +1241,7 @@ export class AkanAppHost {
1251
1241
  } catch (err) {
1252
1242
  lastError = err;
1253
1243
  this.#stopBuilder();
1254
- if (attempt >= BUILDER_START_MAX_ATTEMPTS) break;
1244
+ if (attempt >= BUILDER_START_MAX_ATTEMPTS || this.#stopping) break;
1255
1245
  this.app.verbose(`[cli] builder failed before ready; retrying (${attempt + 1}/${BUILDER_START_MAX_ATTEMPTS})`);
1256
1246
  }
1257
1247
  }
@@ -1269,8 +1259,11 @@ export class AkanAppHost {
1269
1259
  if (settled) return;
1270
1260
  settled = true;
1271
1261
  clearTimeout(timeout);
1262
+ if (this.#abandonBuilderStart === abandon) this.#abandonBuilderStart = null;
1272
1263
  fn();
1273
1264
  };
1265
+ const abandon = (error: Error) => settle(() => reject(error));
1266
+ this.#abandonBuilderStart = abandon;
1274
1267
  const timeout = setTimeout(() => {
1275
1268
  settle(() => reject(new Error("[cli] builder timed out before emitting builder-ready")));
1276
1269
  }, BUILDER_READY_TIMEOUT_MS);
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { RestartSchedule } from "akanjs/common";
2
3
  import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeAction } from "akanjs/server";
3
4
  //? The source itself, not the package: the published akanjs exports no such subpath, and tests are not bundled.
4
5
  import { DevBuildRecovery } from "../../../akanjs/server/artifact/devBuildRecovery";
5
6
  import {
7
+ backendRecoveryPolicy,
6
8
  backendRestartReasonFromMessage,
7
9
  buildStatusReplaySequence,
8
10
  createBackendBuildStatus,
@@ -18,7 +20,6 @@ import {
18
20
  normalizeBackendReportedGeneration,
19
21
  redisTunnelPolicy,
20
22
  resolveIdleSuspendMs,
21
- shouldAbandonBackendRecovery,
22
23
  shouldHoldForReturningBuilder,
23
24
  shouldKeepBuildFailure,
24
25
  shouldMarkBuildPhaseRecovered,
@@ -489,16 +490,15 @@ describe("legacy backend graph fallback", () => {
489
490
  });
490
491
 
491
492
  describe("backend recovery abandonment", () => {
492
- test("keeps retrying below the attempt ceiling and abandons at it", () => {
493
- expect(shouldAbandonBackendRecovery(0)).toBe(false);
494
- expect(shouldAbandonBackendRecovery(4)).toBe(false);
495
- expect(shouldAbandonBackendRecovery(5)).toBe(true);
496
- expect(shouldAbandonBackendRecovery(9)).toBe(true);
497
- });
498
-
499
- test("honors a custom attempt ceiling", () => {
500
- expect(shouldAbandonBackendRecovery(2, 3)).toBe(false);
501
- expect(shouldAbandonBackendRecovery(3, 3)).toBe(true);
493
+ test("backs off from 1s and gives up after five failed restarts in a row", () => {
494
+ const recovery = new RestartSchedule(backendRecoveryPolicy);
495
+ const delays = [1, 2, 3, 4].map(() => recovery.next());
496
+ expect(recovery.exhausted).toBe(false);
497
+ delays.push(recovery.next());
498
+ expect(delays).toEqual([1_000, 2_000, 4_000, 8_000, 16_000]);
499
+ expect(recovery.exhausted).toBe(true);
500
+ recovery.reset();
501
+ expect(recovery.exhausted).toBe(false);
502
502
  });
503
503
  });
504
504
 
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import type { RestartPolicy } from "akanjs/common";
2
3
  import type {
3
4
  BuilderMessage,
4
5
  BuildPhase,
@@ -20,7 +21,7 @@ export const redisTunnelPolicy = ({
20
21
  }): "none" | "optional" | "required" =>
21
22
  environment === "local" ? "none" : databaseMode === "single" ? "optional" : "required";
22
23
 
23
- const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
24
+ export const backendRecoveryPolicy: RestartPolicy = { min: 1_000, max: 30_000, maxAttempts: 5 };
24
25
 
25
26
  // A page load asks for several routes, but a builder that never returns must not grow the hold queue unbounded.
26
27
  const HELD_BUILDER_REQUEST_LIMIT = 64;
@@ -57,9 +58,6 @@ export const shouldRestartBackendByDevPlan = (
57
58
  export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
58
59
  message.devPlan?.actions.includes("restart-builder") ?? false;
59
60
 
60
- export const shouldAbandonBackendRecovery = (attempts: number, maxAttempts = BACKEND_RECOVERY_MAX_ATTEMPTS): boolean =>
61
- attempts >= maxAttempts;
62
-
63
61
  /** The gateway reports backend failures with `generation: -1`; the host assigns its own counter then. */
64
62
  export const normalizeBackendReportedGeneration = (generation: number): number | undefined =>
65
63
  generation >= 0 ? generation : undefined;
package/akanContext.ts CHANGED
@@ -4,6 +4,7 @@ import { capitalize, isRecord, parseRouteModuleKey } from "akanjs/common";
4
4
  import { extractBlockVersion, readDevkitVersion } from "./agentsIndex";
5
5
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
6
6
  import { FileSys } from "./fileSys";
7
+ import { unlabeledFieldNames } from "./moduleFieldNames";
7
8
  import { collectRecipeSources, findInlineRecipeDuplicates, scanRecipes } from "./recipeScanner";
8
9
  import type { PackageJson } from "./types";
9
10
  import {
@@ -337,9 +338,6 @@ const moduleShapeFiles = (module: AkanModuleContext) => {
337
338
  ];
338
339
  };
339
340
 
340
- const constantFieldNames = (content: string) =>
341
- [...content.matchAll(/\b([A-Za-z_$][\w$]*)\s*:\s*field\(/g)].map((match) => match[1]).filter(Boolean);
342
-
343
341
  const safeReadDir = async (dirPath: string) =>
344
342
  (await readdir(dirPath, { withFileTypes: true }).catch(() => [])).sort((a, b) => a.name.localeCompare(b.name));
345
343
 
@@ -853,8 +851,7 @@ export class AkanContextAnalyzer {
853
851
  safeReadText(dictionaryPath),
854
852
  ]);
855
853
  if (constantContent && dictionaryContent) {
856
- for (const fieldName of constantFieldNames(constantContent)) {
857
- if (new RegExp(`\\b${fieldName}\\s*:`).test(dictionaryContent)) continue;
854
+ for (const fieldName of unlabeledFieldNames(constantContent, dictionaryContent)) {
858
855
  report(
859
856
  {
860
857
  severity: "warning",
@@ -2,6 +2,7 @@ import { lstatSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isRecord } from "akanjs/common";
4
4
  import { AkanContextAnalyzer, type AkanDiagnostic, type AkanModuleContext } from "./akanContext";
5
+ import { AkanMcpToolError } from "./akanMcpToolError";
5
6
  import type { Workspace } from "./commandDecorators";
6
7
  import type {
7
8
  AkanContextEvidence,
@@ -172,7 +173,8 @@ export const workflowArtifactPath = (workspace: Workspace, filePath: string) =>
172
173
  ? path.join(root, "runs", `${filePath}.json`)
173
174
  : path.resolve(workspace.workspaceRoot, filePath);
174
175
  if (!isInside(root, resolved) || !staysInside(workspace.workspaceRoot, root, resolved))
175
- throw new Error(
176
+ throw new AkanMcpToolError(
177
+ "invalidArgument",
176
178
  `MCP workflow files live under ${workflowArtifactDir}/ of the workspace, not ${JSON.stringify(filePath)}.`,
177
179
  );
178
180
  return resolved;
@@ -199,7 +201,7 @@ export const defaultWorkflowPlanPath = (workflow: string, inputs: WorkflowPlanIn
199
201
  export const applyFirstPolicy = {
200
202
  mode: "apply-first",
201
203
  directSourceEdits: "fallback-only",
202
- applyRequiredWhen: ["plan_workflow returns planPath", "plan_workflow returns next.tool=apply_workflow"],
204
+ applyRequiredWhen: ["plan_workflow returns next.tool=apply_workflow"],
203
205
  approvalMeaning:
204
206
  "requiresApproval is an agent/user review signal before apply_workflow mutates files; it is not a separate MCP permission gate.",
205
207
  validationRequiredAfterApply: ["validationTarget", "applyReportPath"],
@@ -213,7 +215,8 @@ export const applyFirstPolicy = {
213
215
 
214
216
  export const stringArg = (args: Record<string, unknown>, key: string) => {
215
217
  const value = args[key];
216
- if (typeof value !== "string" || !value) throw new Error(`MCP tool argument "${key}" is required.`);
218
+ if (typeof value !== "string" || !value)
219
+ throw new AkanMcpToolError("invalidArgument", `MCP tool argument "${key}" is required.`);
217
220
  return value;
218
221
  };
219
222
 
@@ -226,13 +229,20 @@ export const workflowInputsArg = (args: Record<string, unknown>) => {
226
229
  export const inspectAkanContextPropsArg = (args: Record<string, unknown>): InspectAkanContextProps => {
227
230
  const question = stringArg(args, "question");
228
231
  const review = stringArg(args, "review");
229
- if (!isRecord(args.draft)) throw new Error('MCP tool argument "draft" is required.');
230
- if (!isRecord(args.request)) throw new Error('MCP tool argument "request" is required.');
232
+ if (!isRecord(args.draft)) throw new AkanMcpToolError("invalidArgument", 'MCP tool argument "draft" is required.');
233
+ if (!isRecord(args.request))
234
+ throw new AkanMcpToolError("invalidArgument", 'MCP tool argument "request" is required.');
231
235
  if (!isInspectAkanContextRequestType(args.draft.type)) {
232
- throw new Error('MCP tool argument "draft.type" must be a supported inspect_akan_context request type.');
236
+ throw new AkanMcpToolError(
237
+ "invalidArgument",
238
+ 'MCP tool argument "draft.type" must be a supported inspect_akan_context request type.',
239
+ );
233
240
  }
234
241
  if (!isInspectAkanContextRequestType(args.request.type)) {
235
- throw new Error('MCP tool argument "request.type" must be a supported inspect_akan_context request type.');
242
+ throw new AkanMcpToolError(
243
+ "invalidArgument",
244
+ 'MCP tool argument "request.type" must be a supported inspect_akan_context request type.',
245
+ );
236
246
  }
237
247
  const base = { question, draft: { reason: stringArg(args.draft, "reason"), type: args.draft.type }, review };
238
248
  const request = args.request;
@@ -324,7 +334,7 @@ export const readonlyMcpTools: McpToolDefinition[] = [
324
334
  {
325
335
  name: "get_graph",
326
336
  description:
327
- "Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with the guards they name (each guard keyed by the app or lib that declares it, with its scope and the comment that says what it checks), the service methods and queries each calls, the store actions that fetch them, and the components and routes that use them. A module's jobs (internal: interval, cron, timeout, initialize, destroy, process) read their schedule and server or operation modes and call service methods like endpoints do; each adaptor a service plugs (an adapt() class, or an akanjs role) is a node, a method that calls it links to it, and an adaptor links to the adaptors it plugs itself. Each page/ file is a route (its URL pattern after the locale) or a layout, layouts wrap what they contain, and a route or layout that runs .guard() reads `guard` and links each function its guard calls; a layout's guard gates every route it wraps. A graph too large to answer unscoped drops first the routes and layouts that neither changed nor use a change; app or module brings them back. Every node and edge carries its source location. Pass app and/or module to scope a large workspace. Pass since (a git revision such as main, or HEAD for uncommitted work) for the Change Map instead: what changed since HEAD forked from it, working tree included, with each node, field and edge marked added, removed or changed, the unchanged neighbours they call or are called by, and the changed files no node covers, each marked the same way.",
337
+ "Read-only Akan Graph from source, no dev server needed: models with their fields and relations, slices and endpoints with the guards they name (each guard keyed by the app or lib that declares it, with its scope and the comment that says what it checks), the service methods and queries each calls, the store actions that fetch them, and the components and routes that use them. A module's jobs (internal: interval, cron, timeout, initialize, destroy, process) read their schedule and server or operation modes and call service methods like endpoints do; each adaptor a service plugs (an adapt() class, or an akanjs role) is a node, a method that calls it links to it (an app service holds the fields its module's lib services plug, unless it declares the field itself), an adaptor links to the adaptors it plugs itself, a class from a package outside the workspace is adaptor:<package>:<Name>, and a role an app binds with applyAdaptor(Role, Impl) links to that implementation (binds, labelled with the app; a scope keeps its app's bindings). Each page/ file is a route (its URL pattern after the locale) or a layout, layouts wrap what they contain, and a route or layout that runs .guard() reads `guard` and links each function its guard calls; a layout's guard gates every route it wraps. A graph too large to answer unscoped drops first the routes and layouts that neither changed nor use a change; app or module brings them back. Every node and edge carries its source location. Pass app and/or module to scope a large workspace. Pass since (a git revision such as main, or HEAD for uncommitted work) for the Change Map instead: what changed since HEAD forked from it, working tree included, with each node, field and edge marked added, removed or changed, the unchanged neighbours they call or are called by, and the changed files no node covers, each marked the same way.",
328
338
  inputSchema: { type: "object", properties: { app: stringProperty, module: stringProperty, since: stringProperty } },
329
339
  },
330
340
  {
@@ -371,7 +381,7 @@ export const planMcpTools: McpToolDefinition[] = [
371
381
  {
372
382
  name: "plan_workflow",
373
383
  description:
374
- "Create a read-only workflow plan after context inspection and return planPath plus next.tool=apply_workflow.",
384
+ "Create a read-only workflow plan after context inspection and return planPath, plus next.tool=apply_workflow unless a diagnostic is an error.",
375
385
  inputSchema: {
376
386
  type: "object",
377
387
  properties: { workflow: stringProperty, inputs: objectProperty, out: stringProperty },
@@ -383,7 +393,8 @@ export const planMcpTools: McpToolDefinition[] = [
383
393
  export const applyMcpTools: McpToolDefinition[] = [
384
394
  {
385
395
  name: "apply_workflow",
386
- description: "Apply a stored workflow plan before direct edits and return validationTarget for run_validation.",
396
+ description:
397
+ "Apply a stored workflow plan before direct edits and return validationTarget. Unless it failed, next.tool is run_validation after an apply and apply_workflow without dryRun after a dry run.",
387
398
  inputSchema: {
388
399
  type: "object",
389
400
  properties: { planPath: stringProperty, dryRun: booleanProperty },
@@ -0,0 +1,16 @@
1
+ import { AkanError } from "akanjs/base";
2
+ import type { McpFailure } from "akanjs/common";
3
+
4
+ export type AkanMcpToolErrorCode = "invalidArgument" | "toolUnavailable" | "unknownTool";
5
+
6
+ export class AkanMcpToolError extends AkanError<AkanMcpToolErrorCode> {
7
+ constructor(code: AkanMcpToolErrorCode, message: string) {
8
+ super(code, message, { statusCode: 400 });
9
+ }
10
+
11
+ static failureOf(error: unknown): McpFailure {
12
+ return AkanError.is(error)
13
+ ? { code: error.code, statusCode: error.statusCode }
14
+ : { code: "toolFailed", statusCode: 500 };
15
+ }
16
+ }