@akanjs/devkit 2.3.5 → 2.3.6-rc.1

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.
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { Logger } from "akanjs/common";
3
- import type { BuilderMessage } from "akanjs/server";
3
+ import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeRole } from "akanjs/server";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { createTunnel } from "../createTunnel";
6
6
  import { WorkspaceExecutor } from "../executors";
@@ -16,6 +16,9 @@ const BUILDER_START_MAX_ATTEMPTS = 3;
16
16
  const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
17
17
  const NON_SOURCE_EXT_RE =
18
18
  /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
19
+ const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
20
+ const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
21
+ const RUNTIME_METADATA_BASENAMES = new Set(["dict.ts", "sig.ts", "useClient.ts", "useServer.ts"]);
19
22
  const GRAPH_IMPORT_KINDS = new Set<Bun.ImportKind>([
20
23
  "import-statement",
21
24
  "require-call",
@@ -23,6 +26,126 @@ const GRAPH_IMPORT_KINDS = new Set<Bun.ImportKind>([
23
26
  "dynamic-import",
24
27
  ]);
25
28
 
29
+ export const shouldRestartBackendByDevPlan = (
30
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
31
+ ): boolean | null => {
32
+ if (!message.devPlan) return null;
33
+ if (message.devPlan.actions.includes("report-error")) return false;
34
+ if (message.devPlan.actions.includes("restart-builder")) return false;
35
+ return message.devPlan.actions.includes("restart-backend");
36
+ };
37
+
38
+ export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
39
+ message.devPlan?.actions.includes("restart-builder") ?? false;
40
+
41
+ export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
42
+ message.devPlan?.actions.includes("restart-dev-host") ?? message.kinds.includes("config");
43
+
44
+ export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "stopping" | "recovering" | "stopped";
45
+
46
+ export interface BackendRestartReason {
47
+ generation?: number;
48
+ files: string[];
49
+ roles: Extract<DevChangeRole, "server" | "shared" | "barrel" | "config">[];
50
+ }
51
+
52
+ interface LastGoodFrontendState {
53
+ pages?: Extract<BuilderMessage, { type: "pages-updated" }>;
54
+ css?: Extract<BuilderMessage, { type: "css-updated" }>;
55
+ }
56
+
57
+ const RESTART_ROLE_ORDER: BackendRestartReason["roles"] = ["server", "shared", "barrel", "config"];
58
+
59
+ const generationValue = (generation: number | undefined): number => generation ?? -1;
60
+
61
+ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string): boolean => {
62
+ const abs = path.resolve(file);
63
+ const ext = path.extname(abs).toLowerCase();
64
+ if (!SOURCE_EXTS.has(ext)) return false;
65
+ const rel = path.relative(path.resolve(workspaceRoot), abs);
66
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
67
+ const parts = rel.split(path.sep).filter(Boolean);
68
+ const [scope] = parts;
69
+ if (scope !== "apps" && scope !== "libs" && scope !== "pkgs") return false;
70
+
71
+ const base = path.basename(abs);
72
+ return (
73
+ parts.includes("srvkit") ||
74
+ parts.includes("common") ||
75
+ SERVER_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
76
+ SHARED_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
77
+ RUNTIME_METADATA_BASENAMES.has(base) ||
78
+ base === "main.ts" ||
79
+ base === "server.ts"
80
+ );
81
+ };
82
+
83
+ export const shouldMarkBuildPhaseRecovered = (
84
+ previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
85
+ status: DevBuildStatus,
86
+ ): boolean => {
87
+ const previous = previousByPhase.get(status.phase);
88
+ return Boolean(previous && status.ok && !previous.ok && generationValue(status.generation) >= previous.generation);
89
+ };
90
+
91
+ export const createBackendBuildStatus = ({
92
+ generation,
93
+ ok,
94
+ files = [],
95
+ message,
96
+ }: {
97
+ generation: number;
98
+ ok: boolean;
99
+ files?: string[];
100
+ message?: string;
101
+ }): DevBuildStatus => ({
102
+ generation,
103
+ phase: "backend",
104
+ ok,
105
+ files,
106
+ message,
107
+ });
108
+
109
+ export const backendRestartReasonFromMessage = (
110
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
111
+ ): BackendRestartReason => {
112
+ const roleSet = new Set<BackendRestartReason["roles"][number]>();
113
+ for (const role of message.devPlan?.roles ?? []) {
114
+ if (role === "server" || role === "shared" || role === "barrel" || role === "config") roleSet.add(role);
115
+ }
116
+ return {
117
+ generation: message.devPlan?.generation ?? message.generation,
118
+ files: [...new Set(message.files)].sort(),
119
+ roles: RESTART_ROLE_ORDER.filter((role) => roleSet.has(role)),
120
+ };
121
+ };
122
+
123
+ export const mergeBackendRestartReasons = (
124
+ current: BackendRestartReason | null,
125
+ next: BackendRestartReason,
126
+ ): BackendRestartReason => ({
127
+ generation:
128
+ generationValue(next.generation) >= generationValue(current?.generation) ? next.generation : current?.generation,
129
+ files: [...new Set([...(current?.files ?? []), ...next.files])].sort(),
130
+ roles: RESTART_ROLE_ORDER.filter((role) => current?.roles.includes(role) || next.roles.includes(role)),
131
+ });
132
+
133
+ export const shouldReplaceLastGoodMessage = (
134
+ current:
135
+ | Extract<BuilderMessage, { type: "pages-updated" }>
136
+ | Extract<BuilderMessage, { type: "css-updated" }>
137
+ | undefined,
138
+ next: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
139
+ ): boolean => !current || generationValue(next.data.generation) >= generationValue(current.data.generation);
140
+
141
+ export const shouldQueueBuildStatusReplay = (backendReady: boolean, pendingReplayCount: number): boolean =>
142
+ !backendReady || pendingReplayCount > 0;
143
+
144
+ export const buildStatusReplaySequence = (
145
+ pendingReplay: readonly DevBuildStatus[],
146
+ latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
147
+ ): DevBuildStatus[] => [...pendingReplay, ...latestByPhase.values()];
148
+
26
149
  class BackendImportGraph {
27
150
  readonly #app: App;
28
151
  readonly #logger: Logger;
@@ -32,6 +155,7 @@ class BackendImportGraph {
32
155
  readonly #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
33
156
  #files = new Set<string>();
34
157
  #ready = false;
158
+ #lastRefreshSucceeded = false;
35
159
 
36
160
  constructor(app: App, logger: Logger) {
37
161
  this.#app = app;
@@ -42,6 +166,10 @@ class BackendImportGraph {
42
166
  return this.#ready;
43
167
  }
44
168
 
169
+ get lastRefreshSucceeded() {
170
+ return this.#lastRefreshSucceeded;
171
+ }
172
+
45
173
  has(file: string) {
46
174
  return this.#files.has(path.resolve(file));
47
175
  }
@@ -51,10 +179,12 @@ class BackendImportGraph {
51
179
  const files = await this.#build();
52
180
  this.#files = files;
53
181
  this.#ready = true;
182
+ this.#lastRefreshSucceeded = true;
54
183
  this.#logger.verbose(`[backend-graph] scanned ${files.size} files`);
55
184
  return true;
56
185
  } catch (err) {
57
186
  this.#ready = this.#files.size > 0;
187
+ this.#lastRefreshSucceeded = false;
58
188
  this.#logger.warn(
59
189
  `[backend-graph] scan failed; ${this.#ready ? "using previous graph" : "using fallback rules"}: ${err instanceof Error ? err.message : String(err)}`,
60
190
  );
@@ -136,9 +266,13 @@ export class AkanAppHost {
136
266
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
137
267
  #backendRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
138
268
  #backendRecoveryAttempts = 0;
139
- #restartFiles = new Set<string>();
140
- #latestPagesUpdated: Extract<BuilderMessage, { type: "pages-updated" }> | null = null;
141
- #latestCssUpdated: Extract<BuilderMessage, { type: "css-updated" }> | null = null;
269
+ #backendLifecycleState: BackendLifecycleState = "stopped";
270
+ #pendingRestartReason: BackendRestartReason | null = null;
271
+ #backendStartStatus: { generation?: number; files: string[] } | null = null;
272
+ #backendBuildStatusGeneration = 0;
273
+ #lastGoodFrontend: LastGoodFrontendState = {};
274
+ #buildStatusByPhase = new Map<BuildPhase, DevBuildStatus>();
275
+ #pendingBuildStatusReplay: DevBuildStatus[] = [];
142
276
  #builderMessageQueue: Promise<void> = Promise.resolve();
143
277
  #backendGraph: BackendImportGraph;
144
278
  constructor(
@@ -183,7 +317,9 @@ export class AkanAppHost {
183
317
  if (environment === "local") return "localhost";
184
318
  return await createTunnel(type, { app: this.app, environment });
185
319
  }
186
- #startBackend() {
320
+ #startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
321
+ this.#backendStartStatus = startStatus;
322
+ this.#setBackendLifecycleState("starting");
187
323
  this.#backendReady = false;
188
324
  const backend = Bun.spawn(["bun", `apps/${this.app.name}/main.ts`], {
189
325
  cwd: this.app.workspace.workspaceRoot,
@@ -194,6 +330,8 @@ export class AkanAppHost {
194
330
  if (msg.type === "backend-ready") {
195
331
  this.#backendReady = true;
196
332
  this.#backendRecoveryAttempts = 0;
333
+ this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
334
+ this.#recordBackendReadyStatus();
197
335
  this.logger.verbose(`backend ready pid=${msg.pid}`);
198
336
  this.#replayBuilderState();
199
337
  return;
@@ -214,9 +352,57 @@ export class AkanAppHost {
214
352
  this.#backend = backend;
215
353
  this.logger.verbose(`backend spawned pid=${backend.pid}`);
216
354
  }
355
+ #nextBackendBuildStatusGeneration(generation?: number): number {
356
+ if (typeof generation === "number") {
357
+ this.#backendBuildStatusGeneration = Math.max(this.#backendBuildStatusGeneration, generation);
358
+ return generation;
359
+ }
360
+ this.#backendBuildStatusGeneration += 1;
361
+ return this.#backendBuildStatusGeneration;
362
+ }
363
+ #recordBackendBuildStatus({
364
+ generation,
365
+ ok,
366
+ files,
367
+ message,
368
+ }: {
369
+ generation?: number;
370
+ ok: boolean;
371
+ files?: string[];
372
+ message?: string;
373
+ }): DevBuildStatus {
374
+ const status = createBackendBuildStatus({
375
+ generation: this.#nextBackendBuildStatusGeneration(generation),
376
+ ok,
377
+ files,
378
+ message,
379
+ });
380
+ this.#recordBuildStatus(status);
381
+ return status;
382
+ }
383
+ #recordBackendReadyStatus(): void {
384
+ const previous = this.#buildStatusByPhase.get("backend");
385
+ const startStatus = this.#backendStartStatus;
386
+ if (startStatus || previous?.ok === false) {
387
+ const status = this.#recordBackendBuildStatus({
388
+ generation: startStatus?.generation ?? previous?.generation,
389
+ ok: true,
390
+ files: startStatus?.files ?? previous?.files ?? [],
391
+ message: "Backend ready",
392
+ });
393
+ this.#sendOrQueueBuildStatus(status);
394
+ }
395
+ this.#backendStartStatus = null;
396
+ }
397
+ #setBackendLifecycleState(next: BackendLifecycleState, detail?: string): void {
398
+ if (this.#backendLifecycleState === next && !detail) return;
399
+ const prev = this.#backendLifecycleState;
400
+ this.#backendLifecycleState = next;
401
+ this.logger.verbose(`[backend-lifecycle] ${prev} -> ${next}${detail ? ` ${detail}` : ""}`);
402
+ }
217
403
  #sendToBackend(message: BuilderMessage) {
218
404
  if (!this.#backend || !this.#backendReady) {
219
- if (message.type === "css-updated" || message.type === "pages-updated") {
405
+ if (message.type === "css-updated" || message.type === "pages-updated" || message.type === "build-status") {
220
406
  this.logger.verbose(`backend is not ready; will replay ${message.type}`);
221
407
  return;
222
408
  }
@@ -236,6 +422,7 @@ export class AkanAppHost {
236
422
  const backend = this.#backend;
237
423
  this.#plannedBackendStops.add(backend);
238
424
  this.#backendReady = false;
425
+ this.#setBackendLifecycleState("stopping", `pid=${backend.pid}`);
239
426
  this.logger.verbose(`stopping backend pid=${backend.pid}`);
240
427
  try {
241
428
  backend.kill("SIGTERM");
@@ -250,10 +437,16 @@ export class AkanAppHost {
250
437
  }
251
438
  } finally {
252
439
  if (this.#backend === backend) this.#backend = null;
440
+ this.#setBackendLifecycleState("stopped", `pid=${backend.pid}`);
253
441
  }
254
442
  }
255
- #scheduleBackendRestart(files: string[]) {
256
- for (const file of files) this.#restartFiles.add(file);
443
+ #scheduleBackendRestart(reason: BackendRestartReason) {
444
+ this.#pendingRestartReason = mergeBackendRestartReasons(this.#pendingRestartReason, reason);
445
+ const pending = this.#pendingRestartReason;
446
+ this.#setBackendLifecycleState(
447
+ "restart-pending",
448
+ `generation=${pending.generation ?? "(unknown)"} files=${pending.files.length} roles=${pending.roles.join(",") || "(none)"}`,
449
+ );
257
450
  if (this.#backendRecoveryTimer) {
258
451
  clearTimeout(this.#backendRecoveryTimer);
259
452
  this.#backendRecoveryTimer = null;
@@ -261,22 +454,31 @@ export class AkanAppHost {
261
454
  if (this.#restartTimer) clearTimeout(this.#restartTimer);
262
455
  this.#restartTimer = setTimeout(() => {
263
456
  this.#restartTimer = null;
264
- const changed = [...this.#restartFiles];
265
- this.#restartFiles.clear();
266
- void this.#restartBackend(changed);
457
+ const next = this.#pendingRestartReason;
458
+ this.#pendingRestartReason = null;
459
+ if (next) void this.#restartBackend(next);
267
460
  }, BACKEND_RESTART_DEBOUNCE_MS);
268
461
  }
269
- async #restartBackend(files: string[]) {
270
- this.logger.verbose(`[backend-reload] restarting backend for ${files.length} file(s)`);
462
+ async #restartBackend(reason: BackendRestartReason) {
463
+ this.logger.verbose(
464
+ `[backend-reload] restarting backend generation=${reason.generation ?? "(unknown)"} files=${reason.files.length} roles=${reason.roles.join(",") || "(none)"}`,
465
+ );
271
466
  this.#backendRecoveryAttempts = 0;
272
467
  await Promise.all([this.#stopBackend(), this.#backendGraph.refresh()]);
273
- this.#startBackend();
468
+ this.#startBackend({ generation: reason.generation, files: reason.files });
274
469
  }
275
470
  #scheduleBackendRecovery(reason: string) {
276
471
  if (this.#backendRecoveryTimer || this.#backend) return;
472
+ this.#setBackendLifecycleState("recovering", reason);
277
473
  const attempt = this.#backendRecoveryAttempts;
278
474
  const delay = Math.min(BACKEND_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BACKEND_RECOVERY_MAX_DELAY_MS);
279
475
  this.#backendRecoveryAttempts = attempt + 1;
476
+ const failureStatus = this.#recordBackendBuildStatus({
477
+ ok: false,
478
+ files: [],
479
+ message: `Backend exited unexpectedly (${reason}); restarting in ${delay}ms`,
480
+ });
481
+ this.#sendOrQueueBuildStatus(failureStatus);
280
482
  this.logger.warn(
281
483
  `[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecoveryAttempts})`,
282
484
  );
@@ -284,7 +486,7 @@ export class AkanAppHost {
284
486
  this.#backendRecoveryTimer = null;
285
487
  if (this.#backend) return;
286
488
  void this.#backendGraph.refresh().finally(() => {
287
- if (!this.#backend) this.#startBackend();
489
+ if (!this.#backend) this.#startBackend({ generation: failureStatus.generation, files: failureStatus.files });
288
490
  });
289
491
  }, delay);
290
492
  }
@@ -296,8 +498,13 @@ export class AkanAppHost {
296
498
  });
297
499
  }
298
500
  async #handleBuilderMessage(message: BuilderMessage) {
299
- if (message.type === "pages-updated") this.#latestPagesUpdated = message;
300
- if (message.type === "css-updated") this.#latestCssUpdated = message;
501
+ if (message.type === "build-status") {
502
+ this.#recordBuildStatus(message.data);
503
+ this.#sendOrQueueBuildStatus(message.data);
504
+ return;
505
+ }
506
+ if (message.type === "pages-updated") this.#recordLastGood(message);
507
+ if (message.type === "css-updated") this.#recordLastGood(message);
301
508
  if (message.type === "invalidate") {
302
509
  await this.#handleInvalidate(message);
303
510
  return;
@@ -305,26 +512,152 @@ export class AkanAppHost {
305
512
  this.#sendToBackend(message);
306
513
  }
307
514
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
515
+ if (shouldRestartBuilderByDevPlan(message)) {
516
+ try {
517
+ await this.#restartDevChildren(message);
518
+ } catch (err) {
519
+ this.#recordDevHostRestartFailure(message, err);
520
+ }
521
+ return;
522
+ }
523
+ if (shouldRestartDevHostByDevPlan(message)) {
524
+ this.#recordDevHostRestartRequired(message);
525
+ return;
526
+ }
308
527
  if (await this.#shouldRestartBackend(message)) {
309
- this.#scheduleBackendRestart(message.files);
528
+ this.#scheduleBackendRestart(backendRestartReasonFromMessage(message));
310
529
  return;
311
530
  }
312
531
  this.#sendToBackend(message);
313
532
  }
314
- #replayBuilderState() {
533
+ async #restartDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
534
+ const generation = message.devPlan?.generation ?? message.generation;
535
+ this.logger.warn(
536
+ `[dev-host] recycling builder/backend for runtime metadata generation=${generation ?? "(unknown)"} files=${message.files.length}`,
537
+ );
538
+ if (this.#restartTimer) {
539
+ clearTimeout(this.#restartTimer);
540
+ this.#restartTimer = null;
541
+ }
542
+ if (this.#backendRecoveryTimer) {
543
+ clearTimeout(this.#backendRecoveryTimer);
544
+ this.#backendRecoveryTimer = null;
545
+ }
546
+ this.#pendingRestartReason = null;
547
+ this.#lastGoodFrontend = {};
548
+ this.#buildStatusByPhase.clear();
549
+ this.#pendingBuildStatusReplay = [];
550
+ await this.#stopBackend();
551
+ this.#stopBuilder();
552
+ await this.#backendGraph.refresh();
553
+ await this.#startBuilder();
554
+ this.#startBackend({ generation, files: message.files });
555
+ }
556
+ #recordLastGood(
557
+ message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
558
+ ): void {
559
+ if (message.type === "pages-updated") {
560
+ if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.pages, message)) return;
561
+ this.#lastGoodFrontend.pages = message;
562
+ this.logger.verbose(
563
+ `[last-good] pages generation=${message.data.generation ?? "(unknown)"} buildId=${message.data.buildId}`,
564
+ );
565
+ return;
566
+ }
567
+ if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.css, message)) return;
568
+ this.#lastGoodFrontend.css = message;
569
+ this.logger.verbose(
570
+ `[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`,
571
+ );
572
+ }
573
+ #recordDevHostRestartRequired(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
574
+ const generation = message.devPlan?.generation ?? message.generation;
575
+ const detail = `generation=${generation ?? "(unknown)"} files=${message.files.length}`;
576
+ this.logger.warn(
577
+ `[dev-host] config change requires a manual restart until controlled dev-host restart is implemented (${detail})`,
578
+ );
579
+ if (typeof generation === "number") {
580
+ const status: DevBuildStatus = {
581
+ generation,
582
+ phase: "scan",
583
+ ok: false,
584
+ files: message.files,
585
+ message: "Config change requires restarting `akan start` to apply.",
586
+ };
587
+ this.#recordBuildStatus(status);
588
+ this.#sendOrQueueBuildStatus(status);
589
+ }
590
+ }
591
+ #recordDevHostRestartFailure(message: Extract<BuilderMessage, { type: "invalidate" }>, err: unknown): void {
592
+ const generation = message.devPlan?.generation ?? message.generation ?? this.#nextBackendBuildStatusGeneration();
593
+ const detail = err instanceof Error ? err.message : String(err);
594
+ this.logger.warn(`[dev-host] runtime metadata restart failed generation=${generation}: ${detail}`);
595
+ const status: DevBuildStatus = {
596
+ generation,
597
+ phase: "scan",
598
+ ok: false,
599
+ files: message.files,
600
+ message: `Runtime metadata change requires restarting \`akan start\` to apply: ${detail}`,
601
+ };
602
+ this.#recordBuildStatus(status);
603
+ this.#sendOrQueueBuildStatus(status);
604
+ }
605
+ #recordBuildStatus(status: DevBuildStatus): void {
606
+ const recovered = shouldMarkBuildPhaseRecovered(this.#buildStatusByPhase, status);
607
+ this.#buildStatusByPhase.set(status.phase, status);
608
+ const label = `[build-status] generation=${status.generation} phase=${status.phase} ok=${status.ok} files=${status.files.length}`;
609
+ if (status.ok) this.logger.verbose(`${label}${recovered ? " recovered=1" : ""}`);
610
+ else this.logger.warn(`${label}${status.message ? ` message=${status.message}` : ""}`);
611
+ }
612
+ #sendOrQueueBuildStatus(status: DevBuildStatus): void {
613
+ if (!this.#backend || shouldQueueBuildStatusReplay(this.#backendReady, this.#pendingBuildStatusReplay.length)) {
614
+ this.#pendingBuildStatusReplay.push(status);
615
+ this.logger.verbose(
616
+ `backend is not ready; will replay build-status generation=${status.generation} phase=${status.phase}`,
617
+ );
618
+ return;
619
+ }
620
+ this.#sendToBackend({ type: "build-status", data: status });
621
+ }
622
+ #replayBuilderState(): void {
315
623
  if (!this.#backendReady) return;
316
- if (this.#latestCssUpdated) this.#sendToBackend(this.#latestCssUpdated);
317
- if (this.#latestPagesUpdated) this.#sendToBackend(this.#latestPagesUpdated);
624
+ if (this.#lastGoodFrontend.css) this.#sendToBackend(this.#lastGoodFrontend.css);
625
+ if (this.#lastGoodFrontend.pages) this.#sendToBackend(this.#lastGoodFrontend.pages);
626
+ const queuedStatuses = this.#pendingBuildStatusReplay.splice(0);
627
+ for (const status of buildStatusReplaySequence(queuedStatuses, this.#buildStatusByPhase)) {
628
+ this.#sendToBackend({ type: "build-status", data: status });
629
+ }
318
630
  }
319
631
  async #shouldRestartBackend(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<boolean> {
320
632
  if (message.kinds.length === 1 && message.kinds[0] === "css") return false;
321
- if (!this.#backendGraph.ready && message.kinds.includes("code")) await this.#backendGraph.refresh();
322
- return message.files.some((file) => this.#isBackendFile(file));
633
+ if (message.devPlan) {
634
+ const { generation, roles, actions, reasonByFile } = message.devPlan;
635
+ this.logger.verbose(
636
+ `[dev-plan] generation=${generation} roles=${roles.join(",") || "(none)"} actions=${actions.join(",") || "(none)"} reasons=${Object.keys(reasonByFile).length}`,
637
+ );
638
+ const shouldRestart = shouldRestartBackendByDevPlan(message) ?? false;
639
+ if (shouldRestart && message.kinds.includes("code")) await this.#backendGraph.refresh();
640
+ return shouldRestart;
641
+ }
642
+ if (message.kinds.includes("code")) await this.#backendGraph.refresh();
643
+ if (message.files.some((file) => this.#isBackendFile(file))) return true;
644
+ if (!this.#backendGraph.lastRefreshSucceeded) {
645
+ const fallbackFiles = message.files.filter((file) =>
646
+ isLegacyBackendFallbackFile(file, this.app.workspace.workspaceRoot),
647
+ );
648
+ if (fallbackFiles.length > 0) {
649
+ this.logger.warn(
650
+ `[backend-graph] using path-role fallback for legacy invalidate; restart files=${fallbackFiles.length}`,
651
+ );
652
+ return true;
653
+ }
654
+ }
655
+ return false;
323
656
  }
324
657
  #isBackendFile(file: string): boolean {
325
658
  return this.#backendGraph.has(file);
326
659
  }
327
- async #startBuilder() {
660
+ async #startBuilder(): Promise<IncrementalBuilderHost> {
328
661
  const startTime = Date.now();
329
662
  this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
330
663
  let lastError: unknown;
@@ -345,7 +678,7 @@ export class AkanAppHost {
345
678
  }
346
679
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
347
680
  }
348
- #waitForBuilderReady(attempt: number) {
681
+ #waitForBuilderReady(attempt: number): Promise<void> {
349
682
  return new Promise<void>((resolve, reject) => {
350
683
  if (!this.#builder) throw new Error("Builder Not Found");
351
684
  let settled = false;
@@ -372,7 +705,7 @@ export class AkanAppHost {
372
705
  });
373
706
  });
374
707
  }
375
- #sendToBuilder(message: BuilderMessage) {
708
+ #sendToBuilder(message: BuilderMessage): void {
376
709
  if (this.#builder?.send(message)) return;
377
710
  if (message.type === "build-route") {
378
711
  this.#sendToBackend({
@@ -385,7 +718,7 @@ export class AkanAppHost {
385
718
  }
386
719
  this.logger.warn("akanAppHost builder is not running");
387
720
  }
388
- #stopBuilder() {
721
+ #stopBuilder(): void {
389
722
  if (!this.#builder) return;
390
723
  this.#builder.stop();
391
724
  this.#builder = null;