@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.
- package/CHANGELOG.md +7 -0
- package/akanApp/akanApp.host.test.ts +211 -0
- package/akanApp/akanApp.host.ts +360 -27
- package/frontendBuild/devChangePlanner.ts +179 -0
- package/frontendBuild/devGeneratedIndexSync.ts +157 -0
- package/frontendBuild/frontendBuild.test.ts +110 -1
- package/frontendBuild/index.ts +2 -0
- package/incrementalBuilder/devWatchBatch.test.ts +59 -0
- package/incrementalBuilder/devWatchBatch.ts +48 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +69 -13
- package/incrementalBuilder/index.ts +1 -0
- package/integration/devStability.integration.test.ts +248 -0
- package/integration/devStabilityHarness.ts +485 -0
- package/lint/no-deep-internal-import.grit +2 -2
- package/package.json +2 -2
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -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
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
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(
|
|
256
|
-
|
|
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
|
|
265
|
-
this.#
|
|
266
|
-
void this.#restartBackend(
|
|
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(
|
|
270
|
-
this.logger.verbose(
|
|
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 === "
|
|
300
|
-
|
|
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
|
|
528
|
+
this.#scheduleBackendRestart(backendRestartReasonFromMessage(message));
|
|
310
529
|
return;
|
|
311
530
|
}
|
|
312
531
|
this.#sendToBackend(message);
|
|
313
532
|
}
|
|
314
|
-
#
|
|
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.#
|
|
317
|
-
if (this.#
|
|
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 (
|
|
322
|
-
|
|
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;
|