@akanjs/devkit 3.0.0-beta.9 → 3.0.0
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/DEV_RUNTIME_KNOBS.md +44 -26
- package/README.ko.md +21 -7
- package/README.md +17 -8
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +56 -3
- package/akanApp/akanApp.host.ts +218 -278
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +81 -35
- package/akanApp/devHostPolicy.ts +41 -102
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +439 -141
- package/akanConfig/akanConfig.ts +359 -275
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +22 -8
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +41 -5
- package/applicationBuildRunner.ts +71 -63
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +32 -30
- package/artifact/implicitRootLayout.ts +58 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +35 -2
- package/biomeBase.ts +2 -5
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +71 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/csrE2e/csrE2eHarness.fixture.ts +505 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +252 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +176 -106
- package/executors.ts +212 -498
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +22 -9
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +123 -37
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +16 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +122 -110
- package/frontendBuild/csrArtifactBuilder.ts +9 -31
- package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
- package/frontendBuild/csrDevArtifactWriter.ts +307 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
- package/frontendBuild/csrDevModuleCompiler.ts +422 -0
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +145 -0
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +363 -0
- package/frontendBuild/csrDevTypes.ts +61 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +31 -82
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +57 -70
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +59 -32
- package/frontendBuild/hmrWatcher.ts +31 -74
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +28 -30
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +100 -62
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
- package/frontendBuild/ssrDevBundler.ts +85 -0
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +147 -63
- package/incrementalBuilder/buildBatchProtocol.ts +13 -38
- package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
- package/incrementalBuilder/buildBatchRunner.ts +26 -38
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
- package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +105 -199
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/desktopBin.test.ts +182 -0
- package/mobile/desktopBin.ts +144 -0
- package/mobile/desktopServerStage.test.ts +252 -0
- package/mobile/desktopServerStage.ts +178 -0
- package/mobile/index.ts +9 -0
- package/mobile/mobileTarget.test.ts +57 -8
- package/mobile/mobileTarget.ts +45 -26
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +184 -0
- package/mobile/nativeApp.ts +454 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +438 -0
- package/mobile/nativeConfig.ts +294 -0
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +66 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +11 -11
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +200 -286
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +91 -225
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +9 -25
- package/subspace.ts +48 -115
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +42 -20
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/transforms/transforms.test.ts +166 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +6 -23
- package/typeChecker.ts +7 -63
- package/types.ts +2 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.test.ts +18 -3
- package/workspaceLayout.ts +22 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { CodeAgentEvent } from "akanjs/common";
|
|
6
|
+
import type { CodeAgent } from "./CodeAgent";
|
|
7
|
+
import { CodeAgentRpcHost } from "./CodeAgentRpcHost";
|
|
8
|
+
import { CodeAgentRpcListener } from "./CodeAgentRpcListener";
|
|
9
|
+
|
|
10
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-rpc-"));
|
|
11
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
12
|
+
|
|
13
|
+
class FakeAgent {
|
|
14
|
+
seq = 0;
|
|
15
|
+
frames: CodeAgentEvent[] = [];
|
|
16
|
+
disposed = false;
|
|
17
|
+
aborted = false;
|
|
18
|
+
#listener: (event: CodeAgentEvent) => void = () => {};
|
|
19
|
+
on(listener: (event: CodeAgentEvent) => void) {
|
|
20
|
+
this.#listener = listener;
|
|
21
|
+
return () => {};
|
|
22
|
+
}
|
|
23
|
+
announce() {
|
|
24
|
+
this.emit();
|
|
25
|
+
}
|
|
26
|
+
emit() {
|
|
27
|
+
const event = { type: "idle", seq: ++this.seq } as CodeAgentEvent;
|
|
28
|
+
this.frames.push(event);
|
|
29
|
+
this.#listener(event);
|
|
30
|
+
}
|
|
31
|
+
async prompt() {
|
|
32
|
+
this.emit();
|
|
33
|
+
}
|
|
34
|
+
async abort() {
|
|
35
|
+
this.aborted = true;
|
|
36
|
+
}
|
|
37
|
+
async state({ sinceSeq }: { sinceSeq?: number } = {}) {
|
|
38
|
+
return { replayFrom: 0, frames: this.frames.filter((event) => event.seq > (sinceSeq ?? Number.MAX_SAFE_INTEGER)) };
|
|
39
|
+
}
|
|
40
|
+
dispose() {
|
|
41
|
+
this.disposed = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class Client {
|
|
46
|
+
lines: unknown[] = [];
|
|
47
|
+
#buffer = "";
|
|
48
|
+
#socket: Awaited<ReturnType<typeof Bun.connect>> | null = null;
|
|
49
|
+
static async connect(unix: string) {
|
|
50
|
+
const client = new Client();
|
|
51
|
+
client.#socket = await Bun.connect({
|
|
52
|
+
unix,
|
|
53
|
+
socket: {
|
|
54
|
+
data: (_socket, data) => {
|
|
55
|
+
client.#buffer += data.toString();
|
|
56
|
+
const parts = client.#buffer.split("\n");
|
|
57
|
+
client.#buffer = parts.pop() ?? "";
|
|
58
|
+
for (const part of parts) if (part) client.lines.push(JSON.parse(part));
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
return client;
|
|
63
|
+
}
|
|
64
|
+
send(id: string, command: object) {
|
|
65
|
+
this.#socket?.write(`${JSON.stringify({ id, command })}\n`);
|
|
66
|
+
}
|
|
67
|
+
async reply(id: string) {
|
|
68
|
+
for (let idx = 0; idx < 200; idx++) {
|
|
69
|
+
const found = this.lines.find((line) => (line as { id?: string }).id === id);
|
|
70
|
+
if (found) return found as { ok: boolean; data: { frames?: CodeAgentEvent[] } };
|
|
71
|
+
await Bun.sleep(5);
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`no reply ${id}`);
|
|
74
|
+
}
|
|
75
|
+
close() {
|
|
76
|
+
this.#socket?.end();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe("CodeAgentRpcListener", () => {
|
|
81
|
+
test("parses unix and tcp addresses, defaulting tcp to loopback", () => {
|
|
82
|
+
expect(CodeAgentRpcListener.parse("unix:/tmp/a.sock")).toEqual({ unix: "/tmp/a.sock" });
|
|
83
|
+
expect(CodeAgentRpcListener.parse("tcp:7000")).toEqual({ hostname: "127.0.0.1", port: 7000 });
|
|
84
|
+
expect(CodeAgentRpcListener.parse("0.0.0.0:7000")).toEqual({ hostname: "0.0.0.0", port: 7000 });
|
|
85
|
+
expect(() => CodeAgentRpcListener.parse("tcp:nope")).toThrow("--rpc-listen");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("the agent outlives a dropped client, and the next one catches up by seq", async () => {
|
|
89
|
+
const agent = new FakeAgent();
|
|
90
|
+
const unix = path.join(dir, "agent.sock");
|
|
91
|
+
const listener = new CodeAgentRpcListener(new CodeAgentRpcHost(agent as unknown as CodeAgent, null), { unix });
|
|
92
|
+
listener.listen();
|
|
93
|
+
const served = listener.serve();
|
|
94
|
+
|
|
95
|
+
const first = await Client.connect(unix);
|
|
96
|
+
first.send("1", { type: "prompt", message: "hi" });
|
|
97
|
+
expect((await first.reply("1")).ok).toBe(true);
|
|
98
|
+
first.close();
|
|
99
|
+
await Bun.sleep(20);
|
|
100
|
+
agent.emit();
|
|
101
|
+
expect(agent.aborted).toBe(false);
|
|
102
|
+
|
|
103
|
+
const second = await Client.connect(unix);
|
|
104
|
+
second.send("2", { type: "get_state", sinceSeq: 2 });
|
|
105
|
+
const state = await second.reply("2");
|
|
106
|
+
expect(state.data.frames?.map((frame) => frame.seq)).toEqual([3]);
|
|
107
|
+
|
|
108
|
+
second.send("3", { type: "shutdown" });
|
|
109
|
+
await second.reply("3");
|
|
110
|
+
await served;
|
|
111
|
+
expect(agent.disposed).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CodeAgentSuspended } from "./CodeAgentSuspended";
|
|
6
|
+
|
|
7
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-suspended-"));
|
|
8
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
9
|
+
|
|
10
|
+
const question = {
|
|
11
|
+
questionId: "qx.1",
|
|
12
|
+
prompt: "Which app?",
|
|
13
|
+
kind: "select" as const,
|
|
14
|
+
options: [{ key: "a", label: "akan" }],
|
|
15
|
+
};
|
|
16
|
+
const approval = {
|
|
17
|
+
approvalId: "ax.2",
|
|
18
|
+
toolCallId: "c1",
|
|
19
|
+
name: "write",
|
|
20
|
+
summary: "write a.ts",
|
|
21
|
+
policy: "writes" as const,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("CodeAgentSuspended", () => {
|
|
25
|
+
test("a restarted worker reads back what it was waiting on, and each ask is taken once", () => {
|
|
26
|
+
const file = CodeAgentSuspended.fileOf(dir, "s1");
|
|
27
|
+
const before = new CodeAgentSuspended(file);
|
|
28
|
+
before.setQuestion(question);
|
|
29
|
+
before.addApproval(approval);
|
|
30
|
+
|
|
31
|
+
const after = new CodeAgentSuspended(file);
|
|
32
|
+
expect(after.question).toEqual(question);
|
|
33
|
+
expect(after.approvals).toEqual([approval]);
|
|
34
|
+
expect(after.takeQuestion("qx.1")).toEqual(question);
|
|
35
|
+
expect(after.takeQuestion("qx.1")).toBeUndefined();
|
|
36
|
+
expect(after.takeApproval("ax.2")).toEqual(approval);
|
|
37
|
+
expect(after.takeApproval("ax.2")).toBeUndefined();
|
|
38
|
+
expect(existsSync(file ?? "")).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("a memory session keeps its asks without touching disk", () => {
|
|
42
|
+
const memory = new CodeAgentSuspended(CodeAgentSuspended.fileOf(undefined, "s2"));
|
|
43
|
+
memory.setQuestion(question);
|
|
44
|
+
expect(memory.takeQuestion("other")).toBeUndefined();
|
|
45
|
+
expect(memory.takeQuestion("qx.1")).toEqual(question);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CodeMailbox } from "./CodeMailbox";
|
|
6
|
+
|
|
7
|
+
const boxes: CodeMailbox[] = [];
|
|
8
|
+
const open = (dir: string, id: string, name: string, onMail: (mail: { text: string }) => void = () => undefined) => {
|
|
9
|
+
const box = new CodeMailbox(dir, id, name);
|
|
10
|
+
box.open("/repo", onMail);
|
|
11
|
+
boxes.push(box);
|
|
12
|
+
return box;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
for (const box of boxes.splice(0)) box.close();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("CodeMailbox", () => {
|
|
20
|
+
test("each session sees the others and not itself", () => {
|
|
21
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
|
|
22
|
+
const one = open(dir, "aaaa1111", "gateway-work");
|
|
23
|
+
const two = open(dir, "bbbb2222", "store-work");
|
|
24
|
+
expect(one.peers().map((peer) => peer.name)).toEqual(["store-work"]);
|
|
25
|
+
expect(two.peers().map((peer) => peer.name)).toEqual(["gateway-work"]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a message lands in the other session's inbox and is delivered once", async () => {
|
|
29
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
|
|
30
|
+
const got: string[] = [];
|
|
31
|
+
const one = open(dir, "aaaa1111", "gateway-work");
|
|
32
|
+
open(dir, "bbbb2222", "store-work", (mail) => got.push(mail.text));
|
|
33
|
+
one.send("bbbb2222", "the adapter moved to srvkit");
|
|
34
|
+
await Bun.sleep(CodeMailbox.pollMs + 400);
|
|
35
|
+
expect(got).toEqual(["the adapter moved to srvkit"]);
|
|
36
|
+
await Bun.sleep(CodeMailbox.pollMs + 200);
|
|
37
|
+
expect(got).toEqual(["the adapter moved to srvkit"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a session opening on a full inbox does not replay it", async () => {
|
|
41
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
|
|
42
|
+
open(dir, "cccc3333", "seeded").close();
|
|
43
|
+
const inbox = path.join(dir, "inbox", "cccc3333.jsonl");
|
|
44
|
+
writeFileSync(inbox, `${JSON.stringify({ from: "x", fromName: "x", text: "old" })}\n`);
|
|
45
|
+
const got: string[] = [];
|
|
46
|
+
open(dir, "cccc3333", "seeded", (mail) => got.push(mail.text));
|
|
47
|
+
await Bun.sleep(CodeMailbox.pollMs + 400);
|
|
48
|
+
expect(got).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a session that stopped beating is not offered as somewhere to send", () => {
|
|
52
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
|
|
53
|
+
const one = open(dir, "aaaa1111", "alive");
|
|
54
|
+
const stale = { id: "dddd4444", name: "crashed", cwd: "/repo", pid: 1, at: Date.now() - CodeMailbox.staleMs * 2 };
|
|
55
|
+
writeFileSync(path.join(dir, "live", "dddd4444.json"), JSON.stringify(stale));
|
|
56
|
+
expect(one.peers()).toEqual([]);
|
|
57
|
+
expect(one.find("crashed")).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a name or the front of an id finds one peer, and an ambiguous one finds none", () => {
|
|
61
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
|
|
62
|
+
const one = open(dir, "aaaa1111", "alive");
|
|
63
|
+
open(dir, "bbbb2222", "store-work");
|
|
64
|
+
open(dir, "bbbb3333", "store-other");
|
|
65
|
+
expect(one.find("store-work")?.id).toBe("bbbb2222");
|
|
66
|
+
expect(one.find("bbbb2")?.id).toBe("bbbb2222");
|
|
67
|
+
expect(one.find("store")).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a runaway exchange is stopped by the ceiling rather than by the disk", () => {
|
|
71
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-mail-"));
|
|
72
|
+
const one = open(dir, "aaaa1111", "alive");
|
|
73
|
+
for (let at = 0; at < CodeMailbox.sendsPerMinute; at += 1) one.send("bbbb2222", "ping");
|
|
74
|
+
expect(() => one.send("bbbb2222", "ping")).toThrow("a minute is the ceiling");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { CodeSessionFork } from "./CodeSessionFork";
|
|
6
|
+
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
7
|
+
|
|
8
|
+
const lines = (id: string) => [
|
|
9
|
+
JSON.stringify({ type: "session", version: 3, id, timestamp: "2026-09-20T14:35:32.579Z", cwd: "/repo" }),
|
|
10
|
+
JSON.stringify({ type: "session_info", id: "aa", parentId: null, name: "read-the-guard" }),
|
|
11
|
+
JSON.stringify({ type: "message", id: "bb", parentId: "aa", message: { role: "user", content: "who guards it" } }),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const seed = () => {
|
|
15
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-fork-"));
|
|
16
|
+
const id = "01a0bf3e-6563-78f4-9017-c541ca067354";
|
|
17
|
+
const file = path.join(dir, `2026-09-20T14-35-32-579Z_${id}.jsonl`);
|
|
18
|
+
writeFileSync(file, `${lines(id).join("\n")}\n`);
|
|
19
|
+
return { dir, id, file };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const records = (file: string) =>
|
|
23
|
+
readFileSync(file, "utf8")
|
|
24
|
+
.split("\n")
|
|
25
|
+
.filter((line) => !!line.trim())
|
|
26
|
+
.map((line) => JSON.parse(line) as { type?: string; id?: string; name?: string });
|
|
27
|
+
|
|
28
|
+
describe("CodeSessionFork", () => {
|
|
29
|
+
test("the copy carries the conversation under an id of its own", () => {
|
|
30
|
+
const { dir, id, file } = seed();
|
|
31
|
+
const forked = CodeSessionFork.fork(dir, id);
|
|
32
|
+
expect(forked.id).not.toBe(id);
|
|
33
|
+
expect(forked.file).not.toBe(file);
|
|
34
|
+
const copied = records(forked.file);
|
|
35
|
+
expect(copied.find((record) => record.type === "session")?.id).toBe(forked.id);
|
|
36
|
+
expect(copied.filter((record) => record.type === "message").length).toBe(1);
|
|
37
|
+
expect(CodeSessionIndex.fileOf(dir, forked.id)).toBe(forked.file);
|
|
38
|
+
expect(CodeSessionIndex.fileOf(dir, id)).toBe(file);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("the original is left exactly as it was", () => {
|
|
42
|
+
const { dir, id, file } = seed();
|
|
43
|
+
const before = readFileSync(file, "utf8");
|
|
44
|
+
CodeSessionFork.fork(dir, id);
|
|
45
|
+
expect(readFileSync(file, "utf8")).toBe(before);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("the copy says it is one, and takes a name when given one", () => {
|
|
49
|
+
const { dir, id } = seed();
|
|
50
|
+
expect(records(CodeSessionFork.fork(dir, id).file).at(-1)?.name).toBe("read-the-guard-fork");
|
|
51
|
+
expect(records(CodeSessionFork.fork(dir, id, "try the other guard").file).at(-1)?.name).toBe("try the other guard");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("an id nothing on disk answers to is refused rather than opening a new session", () => {
|
|
55
|
+
const { dir } = seed();
|
|
56
|
+
expect(() => CodeSessionFork.fork(dir, "nope")).toThrow("No stored session nope");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { codeAgentSessionName } from "akanjs/common";
|
|
6
|
+
import { CodeSessionIndex } from "./CodeSessionIndex";
|
|
7
|
+
|
|
8
|
+
const write = (dir: string, id: string, lines: unknown[]) => {
|
|
9
|
+
const file = path.join(dir, `2026-09-20T10-00-00-000Z_${id}.jsonl`);
|
|
10
|
+
writeFileSync(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`);
|
|
11
|
+
return file;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const session = (id: string) => ({ type: "session", version: 3, id, cwd: "/repo" });
|
|
15
|
+
const user = (text: string) => ({ type: "message", message: { role: "user", content: [{ type: "text", text }] } });
|
|
16
|
+
const assistant = (text: string) => ({
|
|
17
|
+
type: "message",
|
|
18
|
+
message: { role: "assistant", content: [{ type: "text", text }] },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("CodeSessionIndex", () => {
|
|
22
|
+
test("reads the name, the opening ask and how many turns a session holds", () => {
|
|
23
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-sessions-"));
|
|
24
|
+
write(dir, "s1", [
|
|
25
|
+
session("s1"),
|
|
26
|
+
user("add a comment module"),
|
|
27
|
+
assistant("done"),
|
|
28
|
+
user("now wire it up"),
|
|
29
|
+
{ type: "session_info", name: "add-a-comment-module" },
|
|
30
|
+
{ type: "session_info", name: "comment-module" },
|
|
31
|
+
]);
|
|
32
|
+
const [entry] = CodeSessionIndex.list(dir);
|
|
33
|
+
expect(entry?.id).toBe("s1");
|
|
34
|
+
expect(entry?.name).toBe("comment-module");
|
|
35
|
+
expect(entry?.opening).toBe("add a comment module");
|
|
36
|
+
expect(entry?.turns).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a file that is not a session is skipped rather than listed as a blank one", () => {
|
|
40
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-sessions-"));
|
|
41
|
+
writeFileSync(path.join(dir, "notes.txt"), "not a session");
|
|
42
|
+
write(dir, "s2", [{ type: "model_change", provider: "deepseek" }, user("hi")]);
|
|
43
|
+
write(dir, "s3", [session("s3"), user("hi")]);
|
|
44
|
+
expect(CodeSessionIndex.list(dir).map((entry) => entry.id)).toEqual(["s3"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a session past the read limit is still listed, from its opening lines", () => {
|
|
48
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-sessions-"));
|
|
49
|
+
const filler = assistant("x".repeat(64 * 1024));
|
|
50
|
+
write(dir, "big", [session("big"), user("port the dashboard"), ...Array.from({ length: 10 }, () => filler)]);
|
|
51
|
+
const [entry] = CodeSessionIndex.list(dir);
|
|
52
|
+
expect(entry?.id).toBe("big");
|
|
53
|
+
expect(entry?.opening).toBe("port the dashboard");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("an id resolves to its file, and an unknown one to nothing", () => {
|
|
57
|
+
const dir = mkdtempSync(path.join(tmpdir(), "akan-sessions-"));
|
|
58
|
+
const file = write(dir, "s4", [session("s4"), user("hi")]);
|
|
59
|
+
expect(CodeSessionIndex.fileOf(dir, "s4")).toBe(file);
|
|
60
|
+
expect(CodeSessionIndex.fileOf(dir, "s5")).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a missing directory lists nothing instead of throwing", () => {
|
|
64
|
+
expect(CodeSessionIndex.list(path.join(tmpdir(), "akan-sessions-missing"))).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("codeAgentSessionName", () => {
|
|
69
|
+
test("names a session after what it was asked, in one glanceable line", () => {
|
|
70
|
+
expect(codeAgentSessionName("Add a comment module to apps/minimal")).toBe("add-a-comment-module-to-apps-minimal");
|
|
71
|
+
expect(codeAgentSessionName("Add a comment module to apps/minimal and wire up its dictionary")).toBe(
|
|
72
|
+
"add-a-comment-module-to-apps-minimal-and",
|
|
73
|
+
);
|
|
74
|
+
expect(codeAgentSessionName("안녕, 모듈 하나 만들어줘")).toBe("안녕-모듈-하나-만들어줘");
|
|
75
|
+
expect(codeAgentSessionName("```ts\nconst a = 1;\n```")).toBe("session");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Console } from "node:console";
|
|
2
|
+
|
|
3
|
+
// Bun's native `console` writes to fd 1 directly, past the engine's stdout guard, corrupting the RPC stream.
|
|
4
|
+
// Must be the first import of an RPC entry: ESM evaluates imports first, so an inline assignment runs too late.
|
|
5
|
+
globalThis.console = new Console({
|
|
6
|
+
stdout: process.stderr,
|
|
7
|
+
stderr: process.stderr,
|
|
8
|
+
}) as unknown as typeof globalThis.console;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const readHead = (file: string, maxBytes: number) => {
|
|
4
|
+
const fd = openSync(file, "r");
|
|
5
|
+
try {
|
|
6
|
+
const head = Buffer.alloc(maxBytes);
|
|
7
|
+
return head.subarray(0, readSync(fd, head, 0, maxBytes, 0)).toString("utf8");
|
|
8
|
+
} finally {
|
|
9
|
+
closeSync(fd);
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const readJsonLines = (file: string, maxBytes?: number) => {
|
|
14
|
+
try {
|
|
15
|
+
const text =
|
|
16
|
+
maxBytes !== undefined && statSync(file).size > maxBytes ? readHead(file, maxBytes) : readFileSync(file, "utf8");
|
|
17
|
+
return text.split("\n").filter((line) => !!line.trim());
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const parseJsonLine = <T>(line: string) => {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(line) as T;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { AkanEditScope } from "../tools/AkanEditScope";
|
|
4
|
+
import type { TurnFeedbackSource } from "./TurnFeedback";
|
|
5
|
+
|
|
6
|
+
export interface DevLogFeedbackOptions {
|
|
7
|
+
workspaceRoot: string;
|
|
8
|
+
cwd: string;
|
|
9
|
+
apps: string[];
|
|
10
|
+
/** Hot reload finishes after the turn does, so the read waits for it. */
|
|
11
|
+
graceMs?: number;
|
|
12
|
+
maxLines?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const errorPattern = /\bERROR\b|\bFATAL\b|\[stderr\]|error:|Error:|✖|Unhandled|Cannot find|is not a function/;
|
|
16
|
+
const noisePattern = /DEBUG|\bWARN\b|MCP catalogue|rate limit/;
|
|
17
|
+
|
|
18
|
+
// dev.log is the only file carrying the dev host's own build output; lint and typecheck pass on code that dies at run.
|
|
19
|
+
export class DevLogFeedback implements TurnFeedbackSource {
|
|
20
|
+
readonly key = "dev-server";
|
|
21
|
+
readonly #options: Required<DevLogFeedbackOptions>;
|
|
22
|
+
readonly #marks = new Map<string, number>();
|
|
23
|
+
#baseline: ReadonlySet<string> = new Set();
|
|
24
|
+
|
|
25
|
+
constructor(options: DevLogFeedbackOptions) {
|
|
26
|
+
this.#options = { graceMs: 1_500, maxLines: 30, ...options };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async begin() {
|
|
30
|
+
for (const app of this.#options.apps) this.#marks.set(app, DevLogFeedback.#sizeOf(this.#pathOf(app)));
|
|
31
|
+
this.#baseline = await AkanEditScope.baseline(this.#options.cwd);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async observe() {
|
|
35
|
+
const running = this.#options.apps.filter((app) => existsSync(this.#pathOf(app)));
|
|
36
|
+
if (!running.length) return undefined;
|
|
37
|
+
// A turn that wrote nothing cannot have broken the app; without this gate another person's dev server reopens it.
|
|
38
|
+
if (!(await AkanEditScope.since(this.#options.cwd, this.#baseline)).paths.length) return undefined;
|
|
39
|
+
await Bun.sleep(this.#options.graceMs);
|
|
40
|
+
const reports: string[] = [];
|
|
41
|
+
for (const app of running) {
|
|
42
|
+
const lines = await this.#linesSince(app);
|
|
43
|
+
if (lines.length) reports.push(`**${app}** dev server:\n\`\`\`\n${lines.join("\n")}\n\`\`\``);
|
|
44
|
+
}
|
|
45
|
+
if (!reports.length) return undefined;
|
|
46
|
+
return [
|
|
47
|
+
"The dev server logged errors after your edits:",
|
|
48
|
+
...reports,
|
|
49
|
+
`Full log: ${running.map((app) => path.relative(this.#options.workspaceRoot, this.#pathOf(app))).join(", ")}`,
|
|
50
|
+
].join("\n\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async #linesSince(app: string) {
|
|
54
|
+
const file = this.#pathOf(app);
|
|
55
|
+
const from = this.#marks.get(app) ?? 0;
|
|
56
|
+
const size = DevLogFeedback.#sizeOf(file);
|
|
57
|
+
// A smaller file means `akan start` rotated to a new session mid-turn; its own boot output is not a finding.
|
|
58
|
+
if (size <= from) return [];
|
|
59
|
+
const text = await Bun.file(file).slice(from, size).text();
|
|
60
|
+
this.#marks.set(app, size);
|
|
61
|
+
return text
|
|
62
|
+
.split("\n")
|
|
63
|
+
.filter((line) => errorPattern.test(line) && !noisePattern.test(line))
|
|
64
|
+
.slice(-this.#options.maxLines);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#pathOf(app: string) {
|
|
68
|
+
return path.join(this.#options.workspaceRoot, "local", "apps", app, "runtime", "dev.log");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The port is allocated, not predicted, so the log's `ready` line is the only record of this session's URL. */
|
|
72
|
+
static async previewUrl(workspaceRoot: string, app: string) {
|
|
73
|
+
const file = path.join(workspaceRoot, "local", "apps", app, "runtime", "dev.log");
|
|
74
|
+
if (!existsSync(file)) return undefined;
|
|
75
|
+
const matches = [...(await Bun.file(file).text()).matchAll(/ready \(pid=\d+\) — (https?:\/\/\S+)/g)];
|
|
76
|
+
return matches.at(-1)?.[1];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
static #sizeOf(file: string) {
|
|
80
|
+
return existsSync(file) ? statSync(file).size : 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// Bun.WebView's `webkit` backend is the macOS system framework and throws on every other platform.
|
|
6
|
+
export class PreviewChrome {
|
|
7
|
+
/** Read by `Bun.WebView` itself; listed so the availability check agrees with what the launcher will do. */
|
|
8
|
+
static readonly pathEnvKey = "BUN_CHROME_PATH";
|
|
9
|
+
|
|
10
|
+
/** Set this to drive Chrome on macOS too, e.g. to reproduce what a pod sees. */
|
|
11
|
+
static readonly backendEnvKey = "AKAN_CODE_WEBVIEW_BACKEND";
|
|
12
|
+
|
|
13
|
+
/** The executables Bun looks for on PATH before giving up. */
|
|
14
|
+
static readonly executables = [
|
|
15
|
+
"chromium",
|
|
16
|
+
"chromium-browser",
|
|
17
|
+
"google-chrome-stable",
|
|
18
|
+
"google-chrome",
|
|
19
|
+
"brave-browser",
|
|
20
|
+
"microsoft-edge",
|
|
21
|
+
"chrome",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** Must match the macOS app bundles Bun searches, or the probe refuses a browser the launcher would find. */
|
|
25
|
+
static readonly appBundles = [
|
|
26
|
+
"Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
27
|
+
"Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
28
|
+
"Chromium.app/Contents/MacOS/Chromium",
|
|
29
|
+
"Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
30
|
+
"Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
static get backend(): "webkit" | "chrome" {
|
|
34
|
+
const declared = process.env[PreviewChrome.backendEnvKey];
|
|
35
|
+
if (declared === "webkit" || declared === "chrome") return declared;
|
|
36
|
+
return process.platform === "darwin" ? "webkit" : "chrome";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static executable() {
|
|
40
|
+
const declared = process.env[PreviewChrome.pathEnvKey];
|
|
41
|
+
if (declared) return declared;
|
|
42
|
+
for (const name of PreviewChrome.executables) {
|
|
43
|
+
const found = Bun.which(name);
|
|
44
|
+
if (found) return found;
|
|
45
|
+
}
|
|
46
|
+
if (process.platform !== "darwin") return undefined;
|
|
47
|
+
for (const root of ["/Applications", path.join(homedir(), "Applications")])
|
|
48
|
+
for (const bundle of PreviewChrome.appBundles) {
|
|
49
|
+
const candidate = path.join(root, bundle);
|
|
50
|
+
if (existsSync(candidate)) return candidate;
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static get available() {
|
|
56
|
+
return PreviewChrome.backend === "webkit" ? process.platform === "darwin" : !!PreviewChrome.executable();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static backendOption() {
|
|
60
|
+
if (PreviewChrome.backend === "webkit") return "webkit";
|
|
61
|
+
// Docker's default 64MB /dev/shm is below what Chromium's renderer allocates; the crash looks like a hung load.
|
|
62
|
+
const argv = ["--disable-dev-shm-usage"];
|
|
63
|
+
// Chromium refuses uid 0 with its sandbox on, and uid 0 is the ordinary uid in a container.
|
|
64
|
+
if (process.getuid?.() === 0) argv.push("--no-sandbox");
|
|
65
|
+
return { type: "chrome" as const, argv };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The generated image has no browser or fonts; a fontless container renders every glyph as a box. */
|
|
69
|
+
static readonly dockerRuns = [
|
|
70
|
+
"apt-get update && apt-get install -y --no-install-recommends chromium fonts-liberation fonts-noto-color-emoji fonts-noto-cjk && rm -rf /var/lib/apt/lists/*",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
static unavailableReason() {
|
|
74
|
+
if (PreviewChrome.available) return undefined;
|
|
75
|
+
if (PreviewChrome.backend === "webkit")
|
|
76
|
+
return `The webkit WebView backend exists only on macOS. Set ${PreviewChrome.backendEnvKey}=chrome and install a browser.`;
|
|
77
|
+
return [
|
|
78
|
+
"The page preview needs a browser and this machine has none on PATH.",
|
|
79
|
+
`Install one (${PreviewChrome.dockerRuns[0]}) or point ${PreviewChrome.pathEnvKey} at an existing Chrome.`,
|
|
80
|
+
].join(" ");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { AkanEditScope } from "../tools/AkanEditScope";
|
|
2
|
+
import { PreviewView } from "./PreviewView";
|
|
3
|
+
import type { TurnFeedbackFinding, TurnFeedbackSource } from "./TurnFeedback";
|
|
4
|
+
|
|
5
|
+
export interface PreviewFeedbackOptions {
|
|
6
|
+
cwd: string;
|
|
7
|
+
/** Origin of a running dev server. Without one the source is inert. */
|
|
8
|
+
previewUrl?: string;
|
|
9
|
+
/** Every akan route sits under `/:lang`, so a bare path renders nothing. */
|
|
10
|
+
routes?: string[];
|
|
11
|
+
/** Only a model whose input includes images gets a screenshot attached. */
|
|
12
|
+
canSeeImages?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Findings are text, not screenshots: a few hundred tokens, readable by any model, and naming the defect.
|
|
16
|
+
export class PreviewFeedback implements TurnFeedbackSource {
|
|
17
|
+
readonly key = "preview";
|
|
18
|
+
readonly #options: PreviewFeedbackOptions;
|
|
19
|
+
#baseline: ReadonlySet<string> = new Set();
|
|
20
|
+
|
|
21
|
+
constructor(options: PreviewFeedbackOptions) {
|
|
22
|
+
this.#options = options;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async begin() {
|
|
26
|
+
this.#baseline = await AkanEditScope.baseline(this.#options.cwd);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async observe(): Promise<TurnFeedbackFinding | undefined> {
|
|
30
|
+
if (!this.#options.previewUrl || !PreviewView.available) return undefined;
|
|
31
|
+
const scope = await AkanEditScope.since(this.#options.cwd, this.#baseline);
|
|
32
|
+
if (!scope.touchesTsx) return undefined;
|
|
33
|
+
const findings: string[] = [];
|
|
34
|
+
const images: { data: string; mimeType: string }[] = [];
|
|
35
|
+
for (const route of this.#options.routes ?? ["/en/"]) {
|
|
36
|
+
const url = new URL(route, this.#options.previewUrl).toString();
|
|
37
|
+
const probe = await PreviewView.probe(url, { screenshot: !!this.#options.canSeeImages });
|
|
38
|
+
const problems = [...probe.pageErrors, ...probe.consoleErrors, ...probe.domFindings];
|
|
39
|
+
if (!problems.length) continue;
|
|
40
|
+
findings.push(`**${url}**\n${problems.map((line) => `- ${line}`).join("\n")}`);
|
|
41
|
+
if (probe.screenshot) images.push({ data: probe.screenshot, mimeType: "image/png" });
|
|
42
|
+
}
|
|
43
|
+
if (!findings.length) return undefined;
|
|
44
|
+
return { text: ["The rendered page has problems after your UI edits:", ...findings].join("\n\n"), images };
|
|
45
|
+
}
|
|
46
|
+
}
|