@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.
- package/CHANGELOG.md +238 -11
- package/agentAssets.ts +1 -1
- package/akanApp/akanApp.host.test.ts +127 -0
- package/akanApp/akanApp.host.ts +50 -57
- package/akanApp/devHostPolicy.test.ts +11 -11
- package/akanApp/devHostPolicy.ts +2 -4
- package/akanContext.ts +2 -5
- package/akanMcpContract.ts +21 -10
- package/akanMcpToolError.ts +16 -0
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +497 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +65 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.test.ts +4 -0
- package/artifact/implicitRootLayout.ts +13 -1
- package/artifact/storeBootstrap.ts +3 -2
- package/bareErrorScanner.ts +126 -0
- package/biome.base.json +2 -1
- package/codeAgent/skills/akan-debug/SKILL.md +7 -3
- package/codeAgent/skills/akan-err/SKILL.md +7 -0
- package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
- package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/codeAgent/skills/akan-validate/SKILL.md +1 -0
- package/codeAgent/tools/McpClient.ts +28 -6
- package/codeAgent/tools/McpToolPack.ts +1 -1
- package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/cliError.ts +72 -0
- package/commandDecorators/command.ts +186 -96
- package/commandDecorators/commandDecorators.test.ts +240 -17
- package/commandDecorators/exitCodeError.ts +6 -7
- package/commandDecorators/index.ts +1 -0
- package/commandDecorators/targetMeta.ts +3 -0
- package/conventionSet.test.ts +5 -2
- package/createTunnel.test.ts +12 -2
- package/csrE2e/csrE2eHarness.fixture.ts +1 -1
- package/errorCodeReference.test.ts +282 -0
- package/errorCodeReference.ts +71 -0
- package/errorCodeScan.ts +551 -0
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/buildRouteClient.test.ts +43 -0
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
- package/frontendBuild/clientEntryDiscovery.ts +53 -9
- package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
- package/frontendBuild/csrArtifactBuilder.ts +9 -3
- package/frontendBuild/csrDevBundler.ts +12 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
- package/frontendBuild/csrDevModuleCompiler.ts +37 -8
- package/frontendBuild/csrDevPatcher.test.ts +89 -2
- package/frontendBuild/csrDevPatcher.ts +47 -7
- package/frontendBuild/csrDevTypes.ts +16 -0
- package/frontendBuild/cssCompiler.test.ts +63 -1
- package/frontendBuild/devBarrelImports.ts +132 -0
- package/frontendBuild/devRegistryBundler.ts +19 -4
- package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/precompressArtifacts.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/ssrDevBundler.ts +13 -2
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
- package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
- package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
- package/lint/no-import-common-err.grit +31 -0
- package/lint/no-init-fetch-in-client.grit +1 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/maintainabilityBaseline.test.ts +49 -0
- package/maintainabilityBaseline.ts +73 -8
- package/maintainabilityContract.test.ts +302 -1
- package/maintainabilityContract.ts +87 -22
- package/maintainabilityExceptionScanner.test.ts +14 -0
- package/maintainabilityExceptionScanner.ts +5 -1
- package/maintainabilityLimits.ts +12 -0
- package/maintainabilityReport.ts +20 -3
- package/moduleFieldNames.test.ts +56 -0
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewChrome.ts +3 -3
- package/preview/PreviewView.ts +48 -9
- package/preview/previewView.test.ts +154 -3
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/transforms/barrelAnalyzer.ts +46 -1
- package/transforms/barrelImportsPlugin.ts +61 -45
- package/transforms/transforms.test.ts +67 -1
- package/typecheck/projectTypecheck.ts +5 -1
- package/typecheck/typecheck.proc.ts +3 -2
- package/typecheck/typecheckFailure.ts +6 -1
- package/workflow/artifacts.ts +25 -7
- package/workflow/fieldType.ts +92 -0
- package/workflow/index.ts +2 -0
- package/workflow/plainName.ts +28 -0
- package/workflow/plan.ts +67 -22
- package/workflow/source.ts +0 -5
- package/workspaceGraph.test.ts +200 -3
- 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
|
+
});
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -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
|
-
#
|
|
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
|
-
|
|
123
|
-
#
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
this.#
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
435
|
-
if (
|
|
436
|
-
const message = `Backend exited ${this.#
|
|
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.#
|
|
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
|
|
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.#
|
|
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.#
|
|
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
|
-
}
|
|
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.#
|
|
767
|
-
this.#
|
|
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.#
|
|
1028
|
-
const
|
|
1029
|
-
|
|
1030
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
-
|
|
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("
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
expect(
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
expect(
|
|
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
|
|
package/akanApp/devHostPolicy.ts
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
package/akanMcpContract.ts
CHANGED
|
@@ -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
|
|
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
|
|
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)
|
|
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
|
|
230
|
-
if (!isRecord(args.request))
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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
|
+
}
|