feat(round2+round3): 完整实现 A/B/C/D 主线 + round3-F/H 修复
Round2 主线:
- A: 事件落库地基 (RuntimeApp EventStore 单例 + 14 repo wiring)
- B: 执行体对齐 (read-before-edit, verification-before-completion)
- C: 界面对齐 (@opentui/solid, 删除 runtime 依赖)
- D: 经验闭环 (ExperienceMiner, DebuggerRole, CompactorRole)
Round2 补充修复:
- fail-on-missing 反作弊门禁
- projection-store-apply.test.ts 补写
- 3个空壳测试转行为 (evidence-store, recovery-impl, knowledge-store)
- ask 项目根支持 AIRCODING_PROJECT_ROOT
- Worker 事件契约修复 (task.attempt.started → checkpoint)
Round3-F: cpp 工具切换
- 删除 BuiltInToolRegistrar cpp.* 闭包
- 接入 toolchain-cpp 真实 CppToolRegistrar
- canonical envelope {status/output/metadata}
- ExecutorRole system prompt 对齐新工具名
Round3-H: Doctor 5 类报告
- toolchain (cmake/ninja/cppcheck/clangd/g++)
- display (X11/Wayland + ImageMagick)
- network (internet connectivity)
- provider (api_key/base_url/model/connectivity)
Secret 脱敏:
- 状态交接.md: sk- → \${OPENAI_API_KEY}
- .gitignore: 添加 .air/ .claude/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,3 +10,5 @@ dist/
|
|||||||
|
|
||||||
# Turbo cache
|
# Turbo cache
|
||||||
.turbo/
|
.turbo/
|
||||||
|
.air
|
||||||
|
.claude
|
||||||
|
|||||||
206
bun.lock
206
bun.lock
@@ -73,7 +73,9 @@
|
|||||||
"version": "1.0.0-alpha.0",
|
"version": "1.0.0-alpha.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aircoding/contracts": "workspace:*",
|
"@aircoding/contracts": "workspace:*",
|
||||||
"@aircoding/runtime": "workspace:*",
|
"@opentui/core": "0.3.0",
|
||||||
|
"@opentui/solid": "0.3.0",
|
||||||
|
"solid-js": "1.9.10",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
@@ -107,6 +109,88 @@
|
|||||||
|
|
||||||
"@aircoding/workers": ["@aircoding/workers@workspace:packages/workers"],
|
"@aircoding/workers": ["@aircoding/workers@workspace:packages/workers"],
|
||||||
|
|
||||||
|
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
|
||||||
|
|
||||||
|
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
|
||||||
|
|
||||||
|
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
|
||||||
|
|
||||||
|
"@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||||
|
|
||||||
|
"@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
|
||||||
|
|
||||||
|
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
|
||||||
|
|
||||||
|
"@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
|
||||||
|
|
||||||
|
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
|
||||||
|
|
||||||
|
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
|
||||||
|
|
||||||
|
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
|
||||||
|
|
||||||
|
"@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
|
||||||
|
|
||||||
|
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||||
|
|
||||||
|
"@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
|
||||||
|
|
||||||
|
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
|
||||||
|
|
||||||
|
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||||
|
|
||||||
|
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
|
||||||
|
|
||||||
|
"@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
|
||||||
|
|
||||||
|
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||||
|
|
||||||
|
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
|
||||||
|
|
||||||
|
"@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
|
||||||
|
|
||||||
|
"@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="],
|
||||||
|
|
||||||
|
"@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
|
||||||
|
|
||||||
|
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
|
||||||
|
|
||||||
|
"@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
|
||||||
|
|
||||||
|
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@opentui/core": ["@opentui/core@0.3.0", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="],
|
||||||
|
|
||||||
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="],
|
||||||
|
|
||||||
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="],
|
||||||
|
|
||||||
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="],
|
||||||
|
|
||||||
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="],
|
||||||
|
|
||||||
|
"@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="],
|
||||||
|
|
||||||
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw=="],
|
"@turbo/darwin-64": ["@turbo/darwin-64@2.9.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw=="],
|
||||||
|
|
||||||
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw=="],
|
"@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw=="],
|
||||||
@@ -131,8 +215,28 @@
|
|||||||
|
|
||||||
"acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
|
"acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
|
"babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="],
|
||||||
|
|
||||||
|
"babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
|
||||||
|
|
||||||
|
"babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||||
|
|
||||||
|
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.34", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
|
||||||
|
|
||||||
|
"browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="],
|
||||||
|
|
||||||
|
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||||
|
|
||||||
|
"caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
@@ -141,14 +245,42 @@
|
|||||||
|
|
||||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||||
|
|
||||||
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"dependency-cruiser": ["dependency-cruiser@17.4.3", "", { "dependencies": { "acorn": "8.16.0", "acorn-jsx": "5.3.2", "acorn-jsx-walk": "2.0.0", "acorn-loose": "8.5.2", "acorn-walk": "8.3.5", "commander": "14.0.3", "enhanced-resolve": "5.22.1", "ignore": "7.0.5", "interpret": "3.1.1", "is-installed-globally": "1.0.0", "json5": "2.2.3", "picomatch": "4.0.4", "prompts": "2.4.2", "rechoir": "0.8.0", "safe-regex": "2.1.1", "semver": "7.8.1", "tsconfig-paths-webpack-plugin": "4.2.0", "watskeburt": "5.0.3" }, "bin": { "depcruise": "bin/dependency-cruise.mjs", "depcruise-fmt": "bin/depcruise-fmt.mjs", "dependency-cruise": "bin/dependency-cruise.mjs", "depcruise-baseline": "bin/depcruise-baseline.mjs", "dependency-cruiser": "bin/dependency-cruise.mjs", "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs" } }, "sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w=="],
|
"dependency-cruiser": ["dependency-cruiser@17.4.3", "", { "dependencies": { "acorn": "8.16.0", "acorn-jsx": "5.3.2", "acorn-jsx-walk": "2.0.0", "acorn-loose": "8.5.2", "acorn-walk": "8.3.5", "commander": "14.0.3", "enhanced-resolve": "5.22.1", "ignore": "7.0.5", "interpret": "3.1.1", "is-installed-globally": "1.0.0", "json5": "2.2.3", "picomatch": "4.0.4", "prompts": "2.4.2", "rechoir": "0.8.0", "safe-regex": "2.1.1", "semver": "7.8.1", "tsconfig-paths-webpack-plugin": "4.2.0", "watskeburt": "5.0.3" }, "bin": { "depcruise": "bin/dependency-cruise.mjs", "depcruise-fmt": "bin/depcruise-fmt.mjs", "dependency-cruise": "bin/dependency-cruise.mjs", "depcruise-baseline": "bin/depcruise-baseline.mjs", "dependency-cruiser": "bin/dependency-cruise.mjs", "depcruise-wrap-stream-in-html": "bin/wrap-stream-in-html.mjs" } }, "sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w=="],
|
||||||
|
|
||||||
|
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||||
|
|
||||||
|
"electron-to-chromium": ["electron-to-chromium@1.5.368", "", {}, "sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
|
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
|
||||||
|
|
||||||
|
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||||
|
|
||||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||||
|
|
||||||
|
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||||
|
|
||||||
|
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
|
||||||
|
|
||||||
|
"fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
|
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||||
|
|
||||||
|
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
||||||
|
|
||||||
|
"glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
|
||||||
|
|
||||||
"global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="],
|
"global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
@@ -157,6 +289,8 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||||
|
|
||||||
|
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||||
|
|
||||||
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
"ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
|
"ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="],
|
||||||
@@ -169,30 +303,78 @@
|
|||||||
|
|
||||||
"is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="],
|
"is-path-inside": ["is-path-inside@4.0.0", "", {}, "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA=="],
|
||||||
|
|
||||||
|
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||||
|
|
||||||
|
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||||
|
|
||||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||||
|
|
||||||
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
"kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
|
||||||
|
|
||||||
|
"locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
|
||||||
|
|
||||||
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
|
||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="],
|
||||||
|
|
||||||
|
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
|
"p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
|
||||||
|
|
||||||
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
|
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
||||||
|
"path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
|
||||||
|
|
||||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||||
|
|
||||||
|
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
|
||||||
|
|
||||||
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
|
||||||
|
|
||||||
"rechoir": ["rechoir@0.8.0", "", { "dependencies": { "resolve": "^1.20.0" } }, "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ=="],
|
"rechoir": ["rechoir@0.8.0", "", { "dependencies": { "resolve": "^1.20.0" } }, "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ=="],
|
||||||
|
|
||||||
"regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="],
|
"regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="],
|
||||||
|
|
||||||
|
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
|
||||||
|
|
||||||
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
"resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
|
||||||
|
|
||||||
|
"s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="],
|
||||||
|
|
||||||
"safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="],
|
"safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="],
|
||||||
|
|
||||||
"semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
"semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||||
|
|
||||||
|
"seroval": ["seroval@1.3.2", "", {}, "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ=="],
|
||||||
|
|
||||||
|
"seroval-plugins": ["seroval-plugins@1.3.3", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w=="],
|
||||||
|
|
||||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||||
|
|
||||||
|
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||||
|
|
||||||
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
|
||||||
|
|
||||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||||
@@ -211,6 +393,28 @@
|
|||||||
|
|
||||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||||
|
|
||||||
|
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||||
|
|
||||||
"watskeburt": ["watskeburt@5.0.3", "", { "bin": { "watskeburt": "dist/run-cli.js" } }, "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA=="],
|
"watskeburt": ["watskeburt@5.0.3", "", { "bin": { "watskeburt": "dist/run-cli.js" } }, "sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA=="],
|
||||||
|
|
||||||
|
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||||
|
|
||||||
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
|
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||||
|
|
||||||
|
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||||
|
|
||||||
|
"babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
|
||||||
|
|
||||||
|
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||||
|
|
||||||
|
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|
||||||
|
"path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const DEFAULT_CONFIG: AirConfig = {
|
|||||||
|
|
||||||
export function loadConfig(project_root?: string): AirConfig {
|
export function loadConfig(project_root?: string): AirConfig {
|
||||||
let config = { ...DEFAULT_CONFIG }
|
let config = { ...DEFAULT_CONFIG }
|
||||||
|
const resolved_project_root = project_root || process.env.AIRCODING_PROJECT_ROOT
|
||||||
|
|
||||||
// Load global config: ~/.air/config.json
|
// Load global config: ~/.air/config.json
|
||||||
const global_path = join(homedir(), '.air', 'config.json')
|
const global_path = join(homedir(), '.air', 'config.json')
|
||||||
@@ -43,8 +44,8 @@ export function loadConfig(project_root?: string): AirConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load project config: .air/local/config.json
|
// Load project config: .air/local/config.json
|
||||||
if (project_root) {
|
if (resolved_project_root) {
|
||||||
const project_path = join(project_root, '.air', 'local', 'config.json')
|
const project_path = join(resolved_project_root, '.air', 'local', 'config.json')
|
||||||
if (existsSync(project_path)) {
|
if (existsSync(project_path)) {
|
||||||
try {
|
try {
|
||||||
const project = JSON.parse(readFileSync(project_path, 'utf-8'))
|
const project = JSON.parse(readFileSync(project_path, 'utf-8'))
|
||||||
@@ -53,7 +54,7 @@ export function loadConfig(project_root?: string): AirConfig {
|
|||||||
// Ignore malformed project config
|
// Ignore malformed project config
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
config.project_root = project_root
|
config.project_root = resolved_project_root
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* AskCommand - Direct AI task execution
|
* AskCommand - Direct AI task execution
|
||||||
* air ask "task description" → MainAgent → LLM → tools → result
|
* air ask "task description" → MainAgent → Scheduler → Worker → LLM → tools → WorkerResult
|
||||||
*
|
*
|
||||||
* @module packages/cli/src/commands/ask
|
* @module packages/cli/src/commands/ask
|
||||||
*/
|
*/
|
||||||
@@ -10,9 +10,8 @@ import { createRuntime } from '../bootstrap/createRuntime.js'
|
|||||||
import { initCommand } from './init.js'
|
import { initCommand } from './init.js'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { ProviderManager, createAnthropicAdapter, OpenAICompatibleAdapter } from '@aircoding/llm'
|
import { OpenAICompatibleAdapter } from '@aircoding/llm'
|
||||||
import { MainAgent } from '@aircoding/runtime'
|
import { MainAgent } from '@aircoding/runtime'
|
||||||
import type { ProviderAdapter } from '@aircoding/contracts'
|
|
||||||
|
|
||||||
export async function askCommand(prompt: string, opts?: { model?: string; maxTurns?: number }): Promise<void> {
|
export async function askCommand(prompt: string, opts?: { model?: string; maxTurns?: number }): Promise<void> {
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
@@ -24,221 +23,97 @@ export async function askCommand(prompt: string, opts?: { model?: string; maxTur
|
|||||||
const config = loadConfig()
|
const config = loadConfig()
|
||||||
const projectRoot = config.project_root || process.cwd()
|
const projectRoot = config.project_root || process.cwd()
|
||||||
|
|
||||||
// Auto-init if not already initialized
|
|
||||||
if (!existsSync(join(projectRoot, '.air', 'shared', 'project.json'))) {
|
if (!existsSync(join(projectRoot, '.air', 'shared', 'project.json'))) {
|
||||||
console.log('Project not initialized. Running air init first...\n')
|
console.log('Project not initialized. Running air init first...\n')
|
||||||
await initCommand(projectRoot)
|
await initCommand(projectRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
const model = opts?.model || process.env.AIRCODING_MODEL || 'glm-5.1'
|
const model = opts?.model || process.env.AIRCODING_MODEL || 'glm-5.1'
|
||||||
const maxTurns = opts?.maxTurns || 10
|
|
||||||
|
|
||||||
console.log('══════════════════════════════════════════════')
|
|
||||||
console.log(' AirCoding v1.0.0-alpha')
|
|
||||||
console.log(' Project:', projectRoot)
|
|
||||||
console.log(' Model:', model)
|
|
||||||
console.log('══════════════════════════════════════════════\n')
|
|
||||||
|
|
||||||
// Create ProviderManager with available adapter
|
|
||||||
const provider = createProvider(model)
|
const provider = createProvider(model)
|
||||||
|
|
||||||
// Create runtime
|
|
||||||
const runtime = await createRuntime(config)
|
const runtime = await createRuntime(config)
|
||||||
await runtime.start()
|
|
||||||
|
|
||||||
const app = runtime.app
|
const app = runtime.app
|
||||||
|
|
||||||
// Create MainAgent with regex classifier (中文 support added)
|
app.worker_manager.set_provider_manager(provider as any)
|
||||||
const agent = new MainAgent({
|
app.worker_manager.set_context({
|
||||||
session_id: 'ask-session',
|
session_id: app.session_id,
|
||||||
project_id: 'ask-project',
|
project_id: app.project_id,
|
||||||
classify_mode: 'regex',
|
|
||||||
provider_manager: provider as any,
|
|
||||||
context_assembler: app.context_assembler,
|
|
||||||
project_root: projectRoot,
|
project_root: projectRoot,
|
||||||
agent_id: 'ask-agent' as any,
|
|
||||||
classify_model: model
|
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('Task:', prompt)
|
try {
|
||||||
console.log('')
|
await runtime.start()
|
||||||
|
|
||||||
// Classify intent
|
const agent = new MainAgent({
|
||||||
const classification = await agent.handle_user_message(prompt)
|
session_id: app.session_id,
|
||||||
console.log(`[${classification.action}] ${agent.state}`)
|
project_id: app.project_id,
|
||||||
|
classify_mode: 'regex',
|
||||||
|
provider_manager: provider as any,
|
||||||
|
context_assembler: app.context_assembler,
|
||||||
|
project_root: projectRoot,
|
||||||
|
agent_id: 'ask-agent' as any,
|
||||||
|
classify_model: model,
|
||||||
|
})
|
||||||
|
|
||||||
// Execute based on action
|
console.log('══════════════════════════════════════════════')
|
||||||
if (classification.action === 'delegate') {
|
console.log(' AirCoding v1.0.0-alpha')
|
||||||
await execute_task(prompt, provider, app.tool_registry, app.context_assembler, model, projectRoot, maxTurns)
|
console.log(' Project:', projectRoot)
|
||||||
} else if (classification.action === 'answer') {
|
console.log(' Model:', model)
|
||||||
console.log('\n' + (classification.response || 'No response'))
|
console.log('══════════════════════════════════════════════\n')
|
||||||
} else {
|
console.log('Task:', prompt)
|
||||||
console.log('Direct mode not yet supported for ask. Use implementation requests.')
|
console.log('')
|
||||||
}
|
|
||||||
|
|
||||||
await runtime.shutdown()
|
const classification = await agent.handle_user_message(prompt)
|
||||||
}
|
console.log(`[${classification.action}] ${agent.state}`)
|
||||||
|
|
||||||
async function execute_task(
|
if (classification.action === 'answer') {
|
||||||
task: string,
|
console.log('\n' + (classification.response || 'No response'))
|
||||||
provider: any,
|
return
|
||||||
toolRegistry: any,
|
|
||||||
contextAssembler: any,
|
|
||||||
model: string,
|
|
||||||
projectRoot: string,
|
|
||||||
maxTurns: number
|
|
||||||
): Promise<void> {
|
|
||||||
const ctx = {
|
|
||||||
session_id: 'ask-session',
|
|
||||||
project_id: 'ask-project',
|
|
||||||
project_root: projectRoot,
|
|
||||||
agent_id: 'ask-agent',
|
|
||||||
agent_type: 'executor' as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
const systemPrompt = `You are an AI coding assistant. Help the user by reading files, writing code, and running commands.
|
|
||||||
|
|
||||||
Available tools:
|
|
||||||
- fs.read(path) — Read a file
|
|
||||||
- fs.write(path, content) — Write/create a file
|
|
||||||
- fs.edit(path, old_str, new_str) — Edit a file
|
|
||||||
- fs.list(path, depth?) — List directory contents
|
|
||||||
- fs.stat(path) — Get file info
|
|
||||||
- shell.run(command, timeout?) — Run a shell command
|
|
||||||
- git.status() — Show git status
|
|
||||||
- project.scan(root) — Scan project for source files
|
|
||||||
- cpp.detect() — Detect C++ project
|
|
||||||
- cpp.build(target?) — Build C++ project
|
|
||||||
- cpp.test(filter?) — Run C++ tests
|
|
||||||
|
|
||||||
To use a tool, output EXACTLY:
|
|
||||||
\`\`\`json
|
|
||||||
{"tool": "fs.read", "args": {"path": "file.cpp"}}
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
When you are done, output:
|
|
||||||
DONE: <summary of what was done>`
|
|
||||||
|
|
||||||
const messages: any[] = [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: task }
|
|
||||||
]
|
|
||||||
|
|
||||||
let turn = 0
|
|
||||||
const changedFiles: string[] = []
|
|
||||||
|
|
||||||
while (turn < maxTurns) {
|
|
||||||
turn++
|
|
||||||
console.log(`─ Turn ${turn}/${maxTurns} ─`)
|
|
||||||
|
|
||||||
// Call LLM
|
|
||||||
const result = await provider.complete_text(messages, { model, max_tokens: 4096 })
|
|
||||||
let text = result.content || ''
|
|
||||||
|
|
||||||
// Strip GLM reasoning tags if present
|
|
||||||
text = text.replace(/<\/?think>/g, '')
|
|
||||||
|
|
||||||
// Show LLM response summary
|
|
||||||
const preview = text.slice(0, 100).replace(/\n/g, ' ')
|
|
||||||
console.log(` LLM: ${preview}...`)
|
|
||||||
|
|
||||||
// Parse tool calls FIRST (before DONE check — LLM may emit both)
|
|
||||||
const tools = parseToolCalls(text)
|
|
||||||
|
|
||||||
if (tools.length > 0) {
|
|
||||||
console.log(` Tools: ${tools.map(t => t.name).join(', ')}`)
|
|
||||||
|
|
||||||
// Add assistant message
|
|
||||||
messages.push({ role: 'assistant', content: text })
|
|
||||||
|
|
||||||
// Execute each tool call
|
|
||||||
for (const tool of tools) {
|
|
||||||
try {
|
|
||||||
const toolResult = await toolRegistry.call(
|
|
||||||
{ call_id: `ask-${Date.now()}`, name: tool.name, arguments: tool.args },
|
|
||||||
ctx
|
|
||||||
)
|
|
||||||
|
|
||||||
const output = toolResult.status === 'ok'
|
|
||||||
? JSON.stringify(toolResult.output).slice(0, 500)
|
|
||||||
: `Error: ${JSON.stringify(toolResult.error)}`
|
|
||||||
|
|
||||||
console.log(` ✓ ${tool.name}: ${output.slice(0, 100)}`)
|
|
||||||
|
|
||||||
if (tool.name === 'fs.write' || tool.name === 'fs.edit') {
|
|
||||||
changedFiles.push(String(tool.args.path || 'unknown'))
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `Tool ${tool.name} result: ${output}`
|
|
||||||
})
|
|
||||||
} catch (e: any) {
|
|
||||||
messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: `Tool ${tool.name} error: ${e.message}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// After executing tools, check for DONE
|
|
||||||
if (text.includes('DONE:')) {
|
|
||||||
const summary = text.split('DONE:')[1]?.trim() || 'Task completed'
|
|
||||||
console.log(`\n✅ ${summary}`)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask LLM to continue with remaining work
|
|
||||||
messages.push({
|
|
||||||
role: 'user',
|
|
||||||
content: 'Tools executed successfully. If the task is complete, respond with DONE: <summary>. Otherwise continue with more tool calls.'
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No tool calls — LLM is just talking
|
if (classification.action !== 'delegate') {
|
||||||
// Check for DONE (no tools to execute)
|
console.log(classification.response || 'No task created')
|
||||||
if (text.includes('DONE:')) {
|
return
|
||||||
const summary = text.split('DONE:')[1]?.trim() || 'Task completed'
|
|
||||||
console.log(`\n✅ ${summary}`)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (turn >= maxTurns) {
|
if (agent.state === 'CONFIRMING') {
|
||||||
console.log(`\n⚠️ Reached max ${maxTurns} turns. Task may be incomplete.`)
|
console.log('\n' + (classification.response || 'Confirmation required'))
|
||||||
}
|
console.log('No task was created. Use `air run` for interactive confirmation.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (changedFiles.length > 0) {
|
const taskId = `ask_${Date.now().toString(36)}`
|
||||||
console.log(`\nChanged files: ${changedFiles.join(', ')}`)
|
await app.scheduler.create_tasks([{
|
||||||
}
|
id: taskId as any,
|
||||||
}
|
type: 'execute',
|
||||||
|
title: prompt.slice(0, 80),
|
||||||
|
description: prompt,
|
||||||
|
task_spec: {
|
||||||
|
id: taskId,
|
||||||
|
title: prompt.slice(0, 80),
|
||||||
|
description: prompt,
|
||||||
|
acceptance_criteria: ['Task completed successfully'],
|
||||||
|
model,
|
||||||
|
max_turns: opts?.maxTurns,
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
|
||||||
function parseToolCalls(text: string): Array<{ name: string; args: Record<string, unknown> }> {
|
console.log(`Compiling task ${taskId} through Scheduler/Worker...`)
|
||||||
const calls: Array<{ name: string; args: Record<string, unknown> }> = []
|
const finalState = await app.scheduler.run_until_idle()
|
||||||
|
const workerResult = app.worker_manager.get_result_for_task(taskId)
|
||||||
|
|
||||||
// Pattern 1: ```tool or ```json code blocks
|
console.log(`Scheduler state: ${finalState}`)
|
||||||
const blockRe = /```(?:tool|json)\s*\n?([\s\S]*?)```/g
|
if (workerResult) {
|
||||||
for (const match of text.matchAll(blockRe)) {
|
console.log(`Worker status: ${workerResult.status}`)
|
||||||
try {
|
if (workerResult.summary) console.log(workerResult.summary)
|
||||||
const parsed = JSON.parse(match[1].trim())
|
if (workerResult.changed_files.length > 0) {
|
||||||
if (parsed.tool) calls.push({ name: parsed.tool, args: parsed.args || {} })
|
console.log(`Changed files: ${workerResult.changed_files.join(', ')}`)
|
||||||
} catch { /* skip */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern 2: {"tool": "..."} inline JSON anywhere in text
|
|
||||||
const jsonRe = /\{\s*"tool"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{[^}]+\})\s*\}/g
|
|
||||||
for (const match of text.matchAll(jsonRe)) {
|
|
||||||
try {
|
|
||||||
const name = match[1]
|
|
||||||
const args = JSON.parse(match[2])
|
|
||||||
if (!calls.some(c => c.name === name)) {
|
|
||||||
calls.push({ name, args })
|
|
||||||
}
|
}
|
||||||
} catch { /* skip */ }
|
} else {
|
||||||
|
console.log('No WorkerResult was returned.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await runtime.shutdown()
|
||||||
}
|
}
|
||||||
|
|
||||||
return calls
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createProvider(model: string): any {
|
function createProvider(model: string): any {
|
||||||
@@ -246,24 +121,22 @@ function createProvider(model: string): any {
|
|||||||
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
const apiUrl = process.env.AIRCODING_API_URL || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
console.log('⚠️ No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.')
|
console.log('No API key found. Set AIRCODING_API_KEY or OPENAI_API_KEY.')
|
||||||
console.log(' Export: export AIRCODING_API_KEY="your-key"')
|
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = new OpenAICompatibleAdapter({
|
const adapter = new OpenAICompatibleAdapter({
|
||||||
base_url: apiUrl,
|
base_url: apiUrl,
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
model
|
model,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Wrap adapter in a simple provider interface
|
|
||||||
return {
|
return {
|
||||||
adapters: new Map([['openai-compatible', adapter]]),
|
adapters: new Map([['openai-compatible', adapter]]),
|
||||||
current_adapter: adapter,
|
current_adapter: adapter,
|
||||||
current_model: model,
|
current_model: model,
|
||||||
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}) {
|
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}) {
|
||||||
return (adapter as any).complete_text(messages, options)
|
return (adapter as any).complete_text(messages, options)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,56 @@
|
|||||||
/**
|
/**
|
||||||
* CompactCommand - Trigger context compaction
|
* CompactCommand - Trigger context compaction through Scheduler/Worker.
|
||||||
* DD §17.
|
* DD §17.
|
||||||
*/
|
*/
|
||||||
export function compactCommand(target_tokens?: number): void {
|
|
||||||
const tokens = target_tokens || 80000
|
import { randomUUID } from 'crypto'
|
||||||
console.log(`Context compaction requested: target ~${tokens} tokens`)
|
import { existsSync } from 'fs'
|
||||||
console.log('Compaction will:')
|
import { join } from 'path'
|
||||||
console.log(' 1. Summarize conversation history')
|
import { loadConfig } from '../bootstrap/loadConfig.js'
|
||||||
console.log(' 2. Keep recent messages intact')
|
import { createRuntime } from '../bootstrap/createRuntime.js'
|
||||||
console.log(' 3. Insert compaction marker')
|
import { initCommand } from './init.js'
|
||||||
console.log(`Target: ${tokens} tokens (handled by Compactor worker automatically)`)
|
|
||||||
|
export async function compactCommand(target_tokens?: number): Promise<void> {
|
||||||
|
const config = loadConfig(process.env.AIRCODING_PROJECT_ROOT || process.cwd())
|
||||||
|
const project_root = config.project_root || process.cwd()
|
||||||
|
const tokens = target_tokens || config.token_budget || 80000
|
||||||
|
|
||||||
|
if (!existsSync(join(project_root, '.air', 'shared', 'project.json'))) {
|
||||||
|
console.log('Project not initialized. Running air init...\n')
|
||||||
|
await initCommand(project_root)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = await createRuntime(config)
|
||||||
|
const app = runtime.app
|
||||||
|
app.worker_manager.set_context({
|
||||||
|
session_id: app.session_id,
|
||||||
|
project_id: app.project_id,
|
||||||
|
project_root,
|
||||||
|
})
|
||||||
|
|
||||||
|
await app.start()
|
||||||
|
try {
|
||||||
|
const taskId = `compact_${randomUUID().slice(0, 8)}`
|
||||||
|
await app.scheduler.create_tasks([{
|
||||||
|
id: taskId,
|
||||||
|
type: 'compact',
|
||||||
|
title: `Compact context to ${tokens} tokens`,
|
||||||
|
description: `Context compaction requested for target budget ${tokens}`,
|
||||||
|
task_spec: {
|
||||||
|
task_id: taskId,
|
||||||
|
current_tokens: config.token_budget || tokens,
|
||||||
|
threshold: tokens,
|
||||||
|
target_budget_tokens: tokens,
|
||||||
|
source_content: 'CLI-triggered context compaction. Rebuild durable context from event store and summaries.',
|
||||||
|
},
|
||||||
|
}])
|
||||||
|
|
||||||
|
console.log(`Compaction task ${taskId} created. Dispatching compactor worker...`)
|
||||||
|
const finalState = await app.scheduler.run_until_idle()
|
||||||
|
const result = app.worker_manager.get_result_for_task(taskId)
|
||||||
|
console.log(`Compaction scheduler state: ${finalState}`)
|
||||||
|
console.log(result?.summary || 'Compaction finished without summary')
|
||||||
|
} finally {
|
||||||
|
await app.shutdown()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ function runCmd(label: string, cmd: string, args: string[], cwd?: string, timeou
|
|||||||
function runTest(label: string, testPath: string, repoRoot: string): { pass: boolean; detail: string } {
|
function runTest(label: string, testPath: string, repoRoot: string): { pass: boolean; detail: string } {
|
||||||
const bun = findBun()
|
const bun = findBun()
|
||||||
const paths = testPath.split(' ').filter(p => p.length > 0).map(p => join(repoRoot, p.replace(/^\.\//, '')))
|
const paths = testPath.split(' ').filter(p => p.length > 0).map(p => join(repoRoot, p.replace(/^\.\//, '')))
|
||||||
|
const missing = paths.filter(p => !existsSync(p))
|
||||||
|
if (missing.length > 0) {
|
||||||
|
return { pass: false, detail: `❌\n ${label} missing test paths:\n ${missing.map(p => p.replace(repoRoot + '/', '')).join('\n ')}` }
|
||||||
|
}
|
||||||
return runCmd(label, bun, ['test', ...paths])
|
return runCmd(label, bun, ['test', ...paths])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +115,7 @@ export function e2eCommand(): void {
|
|||||||
{ label: 'P0: Release-critical functional gates', fn: () => runTest('P0-REL', './packages/runtime/test/regression/release-critical-gates.test.ts ./packages/cli/test/run-command-regression.test.ts', repoRoot) },
|
{ label: 'P0: Release-critical functional gates', fn: () => runTest('P0-REL', './packages/runtime/test/regression/release-critical-gates.test.ts ./packages/cli/test/run-command-regression.test.ts', repoRoot) },
|
||||||
|
|
||||||
// P1: Storage/Events
|
// P1: Storage/Events
|
||||||
{ label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/storage/ ./packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts', repoRoot) },
|
{ label: 'P1: Storage/Events (test)', fn: () => runTest('P1', './packages/runtime/test/regression/transaction-boundary.test.ts ./packages/runtime/test/regression/event-repository-route.test.ts ./packages/runtime/test/regression/task-attempt-repository.test.ts ./packages/runtime/test/regression/evidence-store-persistence.test.ts', repoRoot) },
|
||||||
|
|
||||||
// P2: Tools/Permission
|
// P2: Tools/Permission
|
||||||
{ label: 'P2: Tools/Permission (test)', fn: () => runTest('P2', './packages/runtime/test/regression/tool-stubs.test.ts ./packages/runtime/test/regression/permission-engine-actions.test.ts ./packages/runtime/test/regression/path-classifier-categories.test.ts ./packages/runtime/test/regression/command-risk-analyzer.test.ts', repoRoot) },
|
{ label: 'P2: Tools/Permission (test)', fn: () => runTest('P2', './packages/runtime/test/regression/tool-stubs.test.ts ./packages/runtime/test/regression/permission-engine-actions.test.ts ./packages/runtime/test/regression/path-classifier-categories.test.ts ./packages/runtime/test/regression/command-risk-analyzer.test.ts', repoRoot) },
|
||||||
|
|||||||
@@ -8,14 +8,19 @@
|
|||||||
import { loadConfig } from '../bootstrap/loadConfig.js'
|
import { loadConfig } from '../bootstrap/loadConfig.js'
|
||||||
import { createRuntime } from '../bootstrap/createRuntime.js'
|
import { createRuntime } from '../bootstrap/createRuntime.js'
|
||||||
import { initCommand } from './init.js'
|
import { initCommand } from './init.js'
|
||||||
import { TuiApp } from '@aircoding/tui'
|
import type { TuiApp as TuiAppInstance } from '@aircoding/tui'
|
||||||
import { MainAgent } from '@aircoding/runtime'
|
import { MainAgent, eventIngestor } from '@aircoding/runtime'
|
||||||
import { OpenAICompatibleAdapter } from '@aircoding/llm'
|
import { OpenAICompatibleAdapter } from '@aircoding/llm'
|
||||||
import { existsSync, mkdirSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { createInterface } from 'readline'
|
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
type TaskResultSummary = {
|
||||||
|
title: string
|
||||||
|
files: Array<{ path: string; size: number }>
|
||||||
|
state: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function runCommand(project_path?: string): Promise<void> {
|
export async function runCommand(project_path?: string): Promise<void> {
|
||||||
const config = loadConfig(project_path)
|
const config = loadConfig(project_path)
|
||||||
const project_root = config.project_root || process.cwd()
|
const project_root = config.project_root || process.cwd()
|
||||||
@@ -55,26 +60,6 @@ export async function runCommand(project_path?: string): Promise<void> {
|
|||||||
|
|
||||||
await app.start()
|
await app.start()
|
||||||
|
|
||||||
// Push initial session projection
|
|
||||||
runtime.projection_client.receive_snapshot({
|
|
||||||
session_id: app.session_id,
|
|
||||||
project_id: app.project_id,
|
|
||||||
status: 'running',
|
|
||||||
title: project_root.split('/').pop() || 'AirCoding',
|
|
||||||
tasks: [],
|
|
||||||
agents: [],
|
|
||||||
tool_runs: [],
|
|
||||||
command_runs: [],
|
|
||||||
artifacts: [],
|
|
||||||
permission_prompts: [],
|
|
||||||
blockers: [],
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start TUI
|
|
||||||
const tui = new TuiApp({ client: runtime.projection_client })
|
|
||||||
await tui.start()
|
|
||||||
|
|
||||||
// Create MainAgent
|
// Create MainAgent
|
||||||
const agent = new MainAgent({
|
const agent = new MainAgent({
|
||||||
session_id: app.session_id,
|
session_id: app.session_id,
|
||||||
@@ -87,18 +72,21 @@ export async function runCommand(project_path?: string): Promise<void> {
|
|||||||
classify_model: model
|
classify_model: model
|
||||||
})
|
})
|
||||||
|
|
||||||
const session_id = app.session_id
|
await import('@aircoding/tui/preload')
|
||||||
|
const { TuiApp } = await import('@aircoding/tui')
|
||||||
|
|
||||||
// Track task results for /results command
|
const taskResults = new Map<string, TaskResultSummary>()
|
||||||
const taskResults = new Map<string, { title: string; files: Array<{ path: string; size: number }>; state: string }>()
|
|
||||||
let pendingConfirmation: string | undefined
|
let pendingConfirmation: string | undefined
|
||||||
|
let tui: TuiAppInstance
|
||||||
|
let shuttingDown = false
|
||||||
|
|
||||||
console.log('')
|
const shutdown = async () => {
|
||||||
console.log('══════════════════════════════════════════════')
|
if (shuttingDown) return
|
||||||
console.log(' AirCoding v1.0.0-alpha')
|
shuttingDown = true
|
||||||
if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled')
|
tui?.stop()
|
||||||
console.log(' Type your task, or /help for commands, Ctrl+C to quit')
|
await app.shutdown()
|
||||||
console.log('══════════════════════════════════════════════\n')
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
const dispatchTask = async (input: string) => {
|
const dispatchTask = async (input: string) => {
|
||||||
const taskId = `task_${randomUUID().slice(0, 8)}`
|
const taskId = `task_${randomUUID().slice(0, 8)}`
|
||||||
@@ -110,87 +98,37 @@ export async function runCommand(project_path?: string): Promise<void> {
|
|||||||
}])
|
}])
|
||||||
|
|
||||||
console.log(`Task ${taskId} created. Dispatching worker...`)
|
console.log(`Task ${taskId} created. Dispatching worker...`)
|
||||||
const runPromise = app.scheduler.run_until_idle()
|
tui.set_status(`Task ${taskId} running`)
|
||||||
|
const finalState = await app.scheduler.run_until_idle()
|
||||||
let dots = 0
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
dots = (dots + 1) % 4
|
|
||||||
process.stdout.write(`\r Running${'.'.repeat(dots)} `)
|
|
||||||
}, 500)
|
|
||||||
|
|
||||||
let finalState: string
|
|
||||||
try {
|
|
||||||
finalState = await runPromise
|
|
||||||
} finally {
|
|
||||||
clearInterval(progressInterval)
|
|
||||||
process.stdout.write('\r \r')
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Task complete. Scheduler: ${finalState}`)
|
console.log(`Task complete. Scheduler: ${finalState}`)
|
||||||
|
|
||||||
const workerResult = app.worker_manager.get_result_for_task?.(taskId)
|
const workerResult = app.worker_manager.get_result_for_task?.(taskId)
|
||||||
const resultFiles = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : []
|
const resultFiles = Array.isArray(workerResult?.changed_files) ? workerResult.changed_files : []
|
||||||
const recentFiles: Array<{ path: string; size: number }> = []
|
const producedFiles: Array<{ path: string; size: number }> = []
|
||||||
|
|
||||||
if (resultFiles.length > 0) {
|
if (resultFiles.length > 0) {
|
||||||
const { statSync: st, existsSync: ex } = await import('fs')
|
const { statSync, existsSync: fileExists } = await import('fs')
|
||||||
for (const file of resultFiles) {
|
for (const file of resultFiles) {
|
||||||
if (!file || file.startsWith('.air/') || file.includes('/.air/')) continue
|
if (!file || file.startsWith('.air/') || file.includes('/.air/') || file.split('/').some(e => e.startsWith('.'))) continue
|
||||||
const full = join(project_root, file)
|
const fullPath = join(project_root, file)
|
||||||
if (!ex(full)) continue
|
if (!fileExists(fullPath)) continue
|
||||||
const s = st(full)
|
const stat = statSync(fullPath)
|
||||||
if (s.isFile()) recentFiles.push({ path: file, size: s.size })
|
if (stat.isFile()) producedFiles.push({ path: file, size: stat.size })
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const { readdirSync: rd, statSync: st, existsSync: ex } = await import('fs')
|
|
||||||
const scanDir = (d: string, depth: number) => {
|
|
||||||
if (depth > 3 || !ex(d)) return
|
|
||||||
try {
|
|
||||||
for (const e of rd(d)) {
|
|
||||||
if (e.startsWith('.')) continue
|
|
||||||
const p = join(d, e)
|
|
||||||
try {
|
|
||||||
const s = st(p)
|
|
||||||
if (s.isDirectory()) scanDir(p, depth + 1)
|
|
||||||
else if (s.mtimeMs > Date.now() - 60000) recentFiles.push({ path: p.replace(project_root + '/', ''), size: s.size })
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
scanDir(project_root, 0)
|
|
||||||
}
|
}
|
||||||
if (recentFiles.length > 0) {
|
|
||||||
|
if (producedFiles.length > 0) {
|
||||||
console.log(' Produced files:')
|
console.log(' Produced files:')
|
||||||
for (const f of recentFiles.slice(0, 10)) {
|
for (const file of producedFiles.slice(0, 10)) {
|
||||||
console.log(` 📄 ${f.path} (${f.size}B)`)
|
console.log(` ${file.path} (${file.size}B)`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
taskResults.set(taskId, { title: input.slice(0, 80), files: recentFiles, state: finalState })
|
taskResults.set(taskId, { title: input.slice(0, 80), files: producedFiles, state: finalState })
|
||||||
|
tui.set_status(`Task ${taskId}: ${finalState}`)
|
||||||
runtime.projection_client.receive_snapshot({
|
|
||||||
session_id,
|
|
||||||
project_id: app.project_id,
|
|
||||||
status: finalState === 'COMPLETED' ? 'completed' : 'running',
|
|
||||||
title: project_root.split('/').pop() || 'AirCoding',
|
|
||||||
tasks: [{ id: taskId as any, type: 'execute', status: finalState === 'COMPLETED' ? 'completed' : 'running', title: input.slice(0, 80), retry_count: 0, attempts: 1, created_at: new Date().toISOString() }],
|
|
||||||
agents: [],
|
|
||||||
tool_runs: [],
|
|
||||||
command_runs: [],
|
|
||||||
artifacts: [],
|
|
||||||
permission_prompts: [],
|
|
||||||
blockers: [],
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interactive input loop
|
const handleSubmit = async (input: string) => {
|
||||||
const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' })
|
|
||||||
rl.prompt()
|
|
||||||
|
|
||||||
rl.on('line', async (line: string) => {
|
|
||||||
const input = line.trim()
|
|
||||||
if (!input) { rl.prompt(); return }
|
|
||||||
|
|
||||||
if (pendingConfirmation) {
|
if (pendingConfirmation) {
|
||||||
if (/^(y|yes|是|确认|确定)$/i.test(input)) {
|
if (/^(y|yes|是|确认|确定)$/i.test(input)) {
|
||||||
const confirmedInput = pendingConfirmation
|
const confirmedInput = pendingConfirmation
|
||||||
@@ -201,59 +139,89 @@ export async function runCommand(project_path?: string): Promise<void> {
|
|||||||
pendingConfirmation = undefined
|
pendingConfirmation = undefined
|
||||||
await agent.handle_confirmation(false)
|
await agent.handle_confirmation(false)
|
||||||
console.log('Cancelled. No task was created.\n')
|
console.log('Cancelled. No task was created.\n')
|
||||||
|
tui.set_status('Cancelled')
|
||||||
} else {
|
} else {
|
||||||
console.log('Please answer y/n to confirm or cancel the pending destructive request.\n')
|
console.log('Please answer y/n to confirm or cancel the pending destructive request.\n')
|
||||||
|
tui.set_status('Waiting for confirmation')
|
||||||
}
|
}
|
||||||
rl.prompt()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle slash commands
|
|
||||||
if (input.startsWith('/')) {
|
|
||||||
await handleSlashCommand(input, app, runtime, tui, rl, taskResults)
|
|
||||||
rl.prompt()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route through MainAgent
|
|
||||||
const classification = await agent.handle_user_message(input)
|
const classification = await agent.handle_user_message(input)
|
||||||
console.log(`[${classification.action}]`)
|
console.log(`[${classification.action}]`)
|
||||||
|
|
||||||
if (classification.action === 'answer') {
|
if (classification.action === 'answer') {
|
||||||
console.log('\n' + (classification.response || 'No response') + '\n')
|
console.log('\n' + (classification.response || 'No response') + '\n')
|
||||||
|
tui.set_status('Answered')
|
||||||
} else if (classification.action === 'delegate') {
|
} else if (classification.action === 'delegate') {
|
||||||
if (agent.state === 'CONFIRMING' && classification.response) {
|
if (agent.state === 'CONFIRMING' && classification.response) {
|
||||||
pendingConfirmation = input
|
pendingConfirmation = input
|
||||||
console.log('\n' + classification.response + '\n')
|
console.log('\n' + classification.response + '\n')
|
||||||
|
tui.set_status('Waiting for confirmation')
|
||||||
} else {
|
} else {
|
||||||
await dispatchTask(input)
|
await dispatchTask(input)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`Result: ${classification.response || 'Done'}`)
|
console.log(`Result: ${classification.response || 'Done'}`)
|
||||||
|
tui.set_status(classification.response || 'Done')
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
rl.prompt()
|
const resolvePermission = async (prompt_id: string, selected_option: string) => {
|
||||||
|
await eventIngestor.ingest({
|
||||||
|
id: `evt_${prompt_id}_resolved_${randomUUID().slice(0, 8)}`,
|
||||||
|
type: 'permission.prompt.resolved',
|
||||||
|
version: 1,
|
||||||
|
session_id: app.session_id,
|
||||||
|
project_id: app.project_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: { kind: 'main', id: 'tui' },
|
||||||
|
route: ['cli', 'tui', 'permission'],
|
||||||
|
payload: {
|
||||||
|
prompt_id,
|
||||||
|
selected_option,
|
||||||
|
decision_id: `decision_${randomUUID().slice(0, 8)}`,
|
||||||
|
resolved_by: 'user',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
tui.set_status(`Permission ${selected_option}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
tui = new TuiApp({
|
||||||
|
client: runtime.projection_client,
|
||||||
|
onSubmit: handleSubmit,
|
||||||
|
onSlashCommand: (input) => handleSlashCommand(input, app, tui, taskResults, shutdown),
|
||||||
|
onResolvePermission: resolvePermission,
|
||||||
|
onExit: shutdown,
|
||||||
|
})
|
||||||
|
await tui.start()
|
||||||
|
|
||||||
|
console.log('')
|
||||||
|
console.log('══════════════════════════════════════════════')
|
||||||
|
console.log(' AirCoding v1.0.0-alpha')
|
||||||
|
if (apiKey) console.log(` Model: ${model} (API ready)`); else console.log(' No API key — AI disabled')
|
||||||
|
console.log(' Type your task, or /help for commands, Ctrl+C to quit')
|
||||||
|
console.log('══════════════════════════════════════════════\n')
|
||||||
|
|
||||||
|
process.once('SIGINT', () => {
|
||||||
|
void shutdown()
|
||||||
})
|
})
|
||||||
|
|
||||||
rl.on('close', async () => {
|
await new Promise(() => {})
|
||||||
console.log('\nShutting down...')
|
|
||||||
tui.stop()
|
|
||||||
await app.shutdown()
|
|
||||||
process.exit(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
rl.close()
|
|
||||||
})
|
|
||||||
|
|
||||||
await new Promise(() => {}) // Wait forever
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSlashCommand(input: string, app: any, runtime: any, tui: any, rl: any, taskResults?: Map<string, any>): Promise<void> {
|
async function handleSlashCommand(
|
||||||
|
input: string,
|
||||||
|
app: any,
|
||||||
|
tui: TuiAppInstance,
|
||||||
|
taskResults: Map<string, TaskResultSummary>,
|
||||||
|
shutdown: () => Promise<void>,
|
||||||
|
): Promise<void> {
|
||||||
const cmd = input.slice(1).toLowerCase()
|
const cmd = input.slice(1).toLowerCase()
|
||||||
|
|
||||||
switch (cmd) {
|
switch (cmd) {
|
||||||
case 'help':
|
case 'help':
|
||||||
|
tui.set_view('help')
|
||||||
console.log('\nCommands:')
|
console.log('\nCommands:')
|
||||||
console.log(' /help — Show this help')
|
console.log(' /help — Show this help')
|
||||||
console.log(' /status — Show scheduler and worker status')
|
console.log(' /status — Show scheduler and worker status')
|
||||||
@@ -264,15 +232,15 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'results':
|
case 'results':
|
||||||
if (!taskResults || taskResults.size === 0) {
|
if (taskResults.size === 0) {
|
||||||
console.log(' No task results yet. Submit a task first.\n')
|
console.log(' No task results yet. Submit a task first.\n')
|
||||||
} else {
|
} else {
|
||||||
console.log('')
|
console.log('')
|
||||||
for (const [taskId, result] of taskResults) {
|
for (const [_taskId, result] of taskResults) {
|
||||||
console.log(` Task: ${result.title} [${result.state}]`)
|
console.log(` Task: ${result.title} [${result.state}]`)
|
||||||
if (result.files.length > 0) {
|
if (result.files.length > 0) {
|
||||||
for (const f of result.files) {
|
for (const file of result.files) {
|
||||||
console.log(` 📄 ${f.path} (${f.size}B)`)
|
console.log(` ${file.path} (${file.size}B)`)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(' (no files produced)')
|
console.log(' (no files produced)')
|
||||||
@@ -289,13 +257,14 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'tools': {
|
case 'tools': {
|
||||||
|
tui.set_view('tools')
|
||||||
const tools = app.tool_registry.list()
|
const tools = app.tool_registry.list()
|
||||||
console.log(`\n ${tools.length} tools registered:`)
|
console.log(`\n ${tools.length} tools registered:`)
|
||||||
const cats = new Map<string, string[]>()
|
const cats = new Map<string, string[]>()
|
||||||
for (const t of tools) {
|
for (const tool of tools) {
|
||||||
const list = cats.get(t.category) || []
|
const list = cats.get(tool.category) || []
|
||||||
list.push(t.name)
|
list.push(tool.name)
|
||||||
cats.set(t.category, list)
|
cats.set(tool.category, list)
|
||||||
}
|
}
|
||||||
for (const [cat, names] of cats) {
|
for (const [cat, names] of cats) {
|
||||||
console.log(` ${cat}: ${names.join(', ')}`)
|
console.log(` ${cat}: ${names.join(', ')}`)
|
||||||
@@ -305,6 +274,7 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'tasks': {
|
case 'tasks': {
|
||||||
|
tui.set_view('tasks')
|
||||||
const counts = app.scheduler.get_graph().count_by_status()
|
const counts = app.scheduler.get_graph().count_by_status()
|
||||||
console.log(`\n Task graph:`)
|
console.log(`\n Task graph:`)
|
||||||
for (const [status, count] of Object.entries(counts)) {
|
for (const [status, count] of Object.entries(counts)) {
|
||||||
@@ -316,7 +286,7 @@ async function handleSlashCommand(input: string, app: any, runtime: any, tui: an
|
|||||||
|
|
||||||
case 'quit':
|
case 'quit':
|
||||||
case 'exit':
|
case 'exit':
|
||||||
rl.close()
|
await shutdown()
|
||||||
break
|
break
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
* @module packages/cli
|
* @module packages/cli
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { runCommand } from './commands/run.js'
|
|
||||||
import { initCommand } from './commands/init.js'
|
import { initCommand } from './commands/init.js'
|
||||||
import { doctorCommand } from './commands/doctor.js'
|
import { doctorCommand } from './commands/doctor.js'
|
||||||
import { providerCommand } from './commands/provider.js'
|
import { providerCommand } from './commands/provider.js'
|
||||||
@@ -39,9 +38,11 @@ export async function main(argv: string[]): Promise<void> {
|
|||||||
const rest = args.slice(1)
|
const rest = args.slice(1)
|
||||||
|
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'run':
|
case 'run': {
|
||||||
|
const { runCommand } = await import('./commands/run.js')
|
||||||
await runCommand(rest[0])
|
await runCommand(rest[0])
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case 'ask':
|
case 'ask':
|
||||||
await askCommand(rest.join(' '), {
|
await askCommand(rest.join(' '), {
|
||||||
@@ -71,7 +72,7 @@ export async function main(argv: string[]): Promise<void> {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'compact':
|
case 'compact':
|
||||||
compactCommand(rest[0] ? parseInt(rest[0]) : undefined)
|
await compactCommand(rest[0] ? parseInt(rest[0]) : undefined)
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'history':
|
case 'history':
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, it, expect } from 'bun:test'
|
import { describe, it, expect, afterEach } from 'bun:test'
|
||||||
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { loadConfig } from '../src/bootstrap/loadConfig.js'
|
||||||
|
|
||||||
describe('run command result presentation', () => {
|
describe('run command result presentation', () => {
|
||||||
const source = readFileSync(join(import.meta.dir, '../src/commands/run.ts'), 'utf-8')
|
const source = readFileSync(join(import.meta.dir, '../src/commands/run.ts'), 'utf-8')
|
||||||
@@ -22,3 +25,44 @@ describe('run command result presentation', () => {
|
|||||||
expect(source).toContain('No task was created')
|
expect(source).toContain('No task was created')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('ask command architecture boundary', () => {
|
||||||
|
const source = readFileSync(join(import.meta.dir, '../src/commands/ask.ts'), 'utf-8')
|
||||||
|
|
||||||
|
it('dispatches delegate work through Scheduler and WorkerManager', () => {
|
||||||
|
expect(source).toContain('app.scheduler.create_tasks')
|
||||||
|
expect(source).toContain('app.scheduler.run_until_idle')
|
||||||
|
expect(source).toContain('app.worker_manager.get_result_for_task')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not implement an inline LLM-to-tool loop', () => {
|
||||||
|
expect(source).not.toContain('parseToolCalls')
|
||||||
|
expect(source).not.toContain('toolRegistry.call')
|
||||||
|
expect(source).not.toContain('while (turn <')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('project root configuration', () => {
|
||||||
|
const created: string[] = []
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.AIRCODING_PROJECT_ROOT
|
||||||
|
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses AIRCODING_PROJECT_ROOT when no explicit project path is passed', () => {
|
||||||
|
const projectRoot = mkdtempSync(join(tmpdir(), 'air-load-config-'))
|
||||||
|
created.push(projectRoot)
|
||||||
|
process.env.AIRCODING_PROJECT_ROOT = projectRoot
|
||||||
|
|
||||||
|
expect(loadConfig().project_root).toBe(projectRoot)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('explicit project path wins over AIRCODING_PROJECT_ROOT', () => {
|
||||||
|
const envRoot = mkdtempSync(join(tmpdir(), 'air-load-config-env-'))
|
||||||
|
const explicitRoot = mkdtempSync(join(tmpdir(), 'air-load-config-explicit-'))
|
||||||
|
created.push(envRoot, explicitRoot)
|
||||||
|
process.env.AIRCODING_PROJECT_ROOT = envRoot
|
||||||
|
|
||||||
|
expect(loadConfig(explicitRoot).project_root).toBe(explicitRoot)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -15,7 +15,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aircoding/contracts": "workspace:*",
|
"@aircoding/contracts": "workspace:*",
|
||||||
"@aircoding/llm": "workspace:*"
|
"@aircoding/llm": "workspace:*",
|
||||||
|
"@aircoding/toolchain-cpp": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import { DatabaseManager } from '../storage/DatabaseManager.js'
|
|||||||
import { MigrationRunner } from '../storage/MigrationRunner.js'
|
import { MigrationRunner } from '../storage/MigrationRunner.js'
|
||||||
import { ToolRegistry, createToolRegistry } from '../tools/ToolRegistry.js'
|
import { ToolRegistry, createToolRegistry } from '../tools/ToolRegistry.js'
|
||||||
import { BuiltInToolRegistrar } from '../tools/BuiltInToolRegistrar.js'
|
import { BuiltInToolRegistrar } from '../tools/BuiltInToolRegistrar.js'
|
||||||
import { EventBus } from '../events/EventBus.js'
|
import { EventBus, eventBus, type Subscription } from '../events/EventBus.js'
|
||||||
import { EventStore, eventStore } from '../events/EventStore.js'
|
import { EventStore, eventStore } from '../events/EventStore.js'
|
||||||
import { EventIngestorImpl } from '../events/EventIngestor.js'
|
import { EventIngestorImpl, eventIngestor } from '../events/EventIngestor.js'
|
||||||
import { TaskRepository } from '../storage/repositories/TaskRepository.js'
|
import { TaskRepository } from '../storage/repositories/TaskRepository.js'
|
||||||
import { MessageRepository } from '../storage/repositories/MessageRepository.js'
|
import { MessageRepository } from '../storage/repositories/MessageRepository.js'
|
||||||
import { EvidenceRepository } from '../storage/repositories/EvidenceRepository.js'
|
import { EvidenceRepository } from '../storage/repositories/EvidenceRepository.js'
|
||||||
@@ -48,6 +48,8 @@ export interface RuntimeAppConfig {
|
|||||||
|
|
||||||
export class RuntimeApp {
|
export class RuntimeApp {
|
||||||
private config: RuntimeAppConfig
|
private config: RuntimeAppConfig
|
||||||
|
private projection_subscription: Subscription | null = null
|
||||||
|
private projection_client_unsubscribe: (() => void) | null = null
|
||||||
scheduler: Scheduler
|
scheduler: Scheduler
|
||||||
worker_manager: WorkerManager
|
worker_manager: WorkerManager
|
||||||
context_assembler: ContextAssembler
|
context_assembler: ContextAssembler
|
||||||
@@ -86,18 +88,22 @@ export class RuntimeApp {
|
|||||||
this.doctor = new DoctorService(config.project_root, this.capability_registry)
|
this.doctor = new DoctorService(config.project_root, this.capability_registry)
|
||||||
this.projection_store = new ProjectionStore()
|
this.projection_store = new ProjectionStore()
|
||||||
this.projection_client = new ProjectionClient()
|
this.projection_client = new ProjectionClient()
|
||||||
this.event_bus = new EventBus()
|
this.event_bus = eventBus
|
||||||
const raw_db = this.db.getRawDatabase()
|
const raw_db = this.db.getRawDatabase()
|
||||||
// Wire singleton eventStore with real DB (EventIngestor uses it)
|
// Wire singleton eventStore with real DB (EventIngestor uses it)
|
||||||
if (raw_db) eventStore.setTransactionManager(this.db)
|
if (raw_db) eventStore.setTransactionManager(this.db)
|
||||||
// Use module singleton eventStore - don't create separate instance
|
// Use module singleton eventStore - don't create separate instance
|
||||||
this.event_store = eventStore
|
this.event_store = eventStore
|
||||||
this.event_ingestor = new EventIngestorImpl()
|
this.event_ingestor = eventIngestor
|
||||||
|
|
||||||
// Wire ProjectionStore → ProjectionClient (DD §13.2)
|
// Wire ProjectionStore → ProjectionClient (DD §13.2)
|
||||||
this.projection_store.subscribe((projection) => {
|
this.projection_client_unsubscribe = this.projection_store.subscribe((projection) => {
|
||||||
this.projection_client.receive_snapshot(projection)
|
this.projection_client.receive_snapshot(projection)
|
||||||
})
|
})
|
||||||
|
this.projection_subscription = this.event_bus.subscribe(
|
||||||
|
{ session_id: config.session_id },
|
||||||
|
(event) => this.projection_store.apply(event),
|
||||||
|
)
|
||||||
|
|
||||||
// Wire Scheduler to WorkerManager (DD §7.1)
|
// Wire Scheduler to WorkerManager (DD §7.1)
|
||||||
this.scheduler = new Scheduler({
|
this.scheduler = new Scheduler({
|
||||||
@@ -150,13 +156,19 @@ export class RuntimeApp {
|
|||||||
registrar.register_all(this.config.project_root)
|
registrar.register_all(this.config.project_root)
|
||||||
this.logger.info('Built-in tools registered')
|
this.logger.info('Built-in tools registered')
|
||||||
|
|
||||||
// Step 4: Wire EventStore with DB transaction manager
|
// Step 4: Discover project-local SKILL.md capabilities without executing skill content.
|
||||||
|
await this.discover_project_skills()
|
||||||
|
|
||||||
|
// Step 4.5: Register cpp toolchain via CapabilityRegistry (INV-4)
|
||||||
|
await this.register_cpp_toolchain()
|
||||||
|
|
||||||
|
// Step 5: Wire EventStore with DB transaction manager
|
||||||
this.event_store.setTransactionManager(this.db)
|
this.event_store.setTransactionManager(this.db)
|
||||||
|
|
||||||
// Step 5: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus)
|
// Step 6: Hydrate ProjectionStore from DB (INV-5: from SQLite, not EventBus)
|
||||||
this.logger.info('Hydrating projection store', { session_id: this.config.session_id })
|
this.logger.info('Hydrating projection store', { session_id: this.config.session_id })
|
||||||
|
|
||||||
// Step 6: Wire all domain repositories to module singleton EventStore
|
// Step 7: Wire all domain repositories to module singleton EventStore
|
||||||
try {
|
try {
|
||||||
const raw_db = this.db.getRawDatabase()
|
const raw_db = this.db.getRawDatabase()
|
||||||
if (raw_db) {
|
if (raw_db) {
|
||||||
@@ -181,6 +193,15 @@ export class RuntimeApp {
|
|||||||
diagnosticRepo, evidenceRepo, workspaceRepo, summaryRepo,
|
diagnosticRepo, evidenceRepo, workspaceRepo, summaryRepo,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
this.projection_store.set_repos({
|
||||||
|
session: sessionRepo,
|
||||||
|
task: taskRepo,
|
||||||
|
agent: agentRepo,
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.ensure_session_created(sessionRepo)
|
||||||
|
await this.projection_store.rebuild(this.config.session_id)
|
||||||
|
|
||||||
// Reuse repos for context_assembler and scheduler (replace Step 6 duplicate new)
|
// Reuse repos for context_assembler and scheduler (replace Step 6 duplicate new)
|
||||||
this.context_assembler.set_data_sources({ message_repo: messageRepo, evidence_store: evidenceRepo })
|
this.context_assembler.set_data_sources({ message_repo: messageRepo, evidence_store: evidenceRepo })
|
||||||
this.scheduler.set_task_repo(taskRepo)
|
this.scheduler.set_task_repo(taskRepo)
|
||||||
@@ -194,12 +215,108 @@ export class RuntimeApp {
|
|||||||
this.logger.info('RuntimeApp started')
|
this.logger.info('RuntimeApp started')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async discover_project_skills(): Promise<void> {
|
||||||
|
const roots = [
|
||||||
|
join(this.config.project_root, '.air', 'shared', 'skills'),
|
||||||
|
join(this.config.project_root, '.air', 'shared', 'skill'),
|
||||||
|
].filter((root) => existsSync(root))
|
||||||
|
|
||||||
|
if (roots.length === 0) return
|
||||||
|
|
||||||
|
const discovered = this.capability_registry.discover_skill_roots(roots)
|
||||||
|
let registered_count = 0
|
||||||
|
|
||||||
|
for (const result of discovered) {
|
||||||
|
if (!result.ok || !result.capability_id) {
|
||||||
|
this.logger.warn('Skill discovery failed', { error: result.error })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = this.capability_registry.validate(result.capability_id)
|
||||||
|
if (!validation.valid) {
|
||||||
|
this.logger.warn('Skill validation failed', { capability_id: result.capability_id, errors: validation.errors })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const doctor = await this.capability_registry.doctor_check(result.capability_id)
|
||||||
|
if (!doctor.ok) {
|
||||||
|
this.logger.warn('Skill doctor check failed', { capability_id: result.capability_id, error: doctor.error })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = this.capability_registry.enable(result.capability_id)
|
||||||
|
if (!enabled.ok) {
|
||||||
|
this.logger.warn('Skill enable failed', { capability_id: result.capability_id, error: enabled.error })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const registered = this.capability_registry.register_tools(result.capability_id)
|
||||||
|
if (!registered.ok) {
|
||||||
|
this.logger.warn('Skill tool registration failed', { capability_id: result.capability_id, error: registered.error })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
registered_count += registered.registered_count
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registered_count > 0) this.logger.info('Project skills registered', { registered_count })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register cpp toolchain via CapabilityRegistry (INV-4)
|
||||||
|
* Uses CppToolRegistrar from toolchain-cpp package.
|
||||||
|
*/
|
||||||
|
private async register_cpp_toolchain(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Dynamic import to avoid static dependency (INV-4: single direction)
|
||||||
|
const cppPkg = await import('@aircoding/toolchain-cpp')
|
||||||
|
const registrar = new cppPkg.CppToolRegistrar()
|
||||||
|
|
||||||
|
// Register tools through the CppToolRegistrar
|
||||||
|
// This follows INV-4: registered via capability boundary
|
||||||
|
registrar.register(this.tool_registry, this.config.project_root)
|
||||||
|
this.logger.info('cpp toolchain registered', { capability_id: 'aircoding-cpp-toolchain' })
|
||||||
|
} catch (e: any) {
|
||||||
|
this.logger.warn('cpp toolchain registration failed', { error: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensure_session_created(sessionRepo: SessionRepository): Promise<void> {
|
||||||
|
const existing = await sessionRepo.get(this.config.session_id)
|
||||||
|
if (existing) return
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
await this.event_ingestor.ingest({
|
||||||
|
id: `evt_${this.config.session_id}_created`,
|
||||||
|
type: 'session.created',
|
||||||
|
version: 1,
|
||||||
|
timestamp: now,
|
||||||
|
session_id: this.config.session_id,
|
||||||
|
project_id: this.config.project_id,
|
||||||
|
source: { kind: 'system' },
|
||||||
|
route: ['runtime', 'start'],
|
||||||
|
payload: {
|
||||||
|
session_id: this.config.session_id,
|
||||||
|
project_id: this.config.project_id,
|
||||||
|
project_root: this.config.project_root,
|
||||||
|
title: this.config.project_root.split('/').pop() || 'AirCoding',
|
||||||
|
metadata: {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shutdown the runtime: flush logs, close DB, cancel workers.
|
* Shutdown the runtime: flush logs, close DB, cancel workers.
|
||||||
*/
|
*/
|
||||||
async shutdown(): Promise<void> {
|
async shutdown(): Promise<void> {
|
||||||
this.logger.info('RuntimeApp shutting down')
|
this.logger.info('RuntimeApp shutting down')
|
||||||
|
|
||||||
|
if (this.projection_subscription) {
|
||||||
|
this.event_bus.unsubscribe(this.projection_subscription)
|
||||||
|
this.projection_subscription = null
|
||||||
|
}
|
||||||
|
this.projection_client_unsubscribe?.()
|
||||||
|
this.projection_client_unsubscribe = null
|
||||||
|
|
||||||
// Cancel all running workers
|
// Cancel all running workers
|
||||||
try {
|
try {
|
||||||
for (const handle of this.worker_manager.list()) {
|
for (const handle of this.worker_manager.list()) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import type { ToolDefinition } from '@aircoding/contracts'
|
import type { ToolDefinition } from '@aircoding/contracts'
|
||||||
|
|
||||||
import { CapabilityManifestValidator, createCapabilityManifestValidator, type CapabilityManifest, type ValidationResult } from './CapabilityManifestValidator.js'
|
import { CapabilityManifestValidator, createCapabilityManifestValidator, type CapabilityManifest, type ValidationResult } from './CapabilityManifestValidator.js'
|
||||||
|
import { loadSkillDirectory, loadSkillsFromRoots, type SkillDefinition } from './SkillLoader.js'
|
||||||
|
|
||||||
export type CapabilityState = 'discovered' | 'validated' | 'doctor_checked' | 'enabled' | 'registered' | 'active' | 'disabled' | 'failed'
|
export type CapabilityState = 'discovered' | 'validated' | 'doctor_checked' | 'enabled' | 'registered' | 'active' | 'disabled' | 'failed'
|
||||||
|
|
||||||
@@ -62,6 +63,30 @@ export class CapabilityRegistry {
|
|||||||
return { ok: true, capability_id }
|
return { ok: true, capability_id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover one SKILL.md directory as a capability manifest.
|
||||||
|
*/
|
||||||
|
discover_skill_directory(skill_dir: string, trusted_roots: string[]): { ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string } {
|
||||||
|
try {
|
||||||
|
const skill = loadSkillDirectory(skill_dir, trusted_roots)
|
||||||
|
const discovered = this.discover(skill.manifest)
|
||||||
|
return { ...discovered, skill }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all SKILL.md entries under trusted roots.
|
||||||
|
*/
|
||||||
|
discover_skill_roots(roots: string[]): Array<{ ok: boolean; capability_id?: string; skill?: SkillDefinition; error?: string }> {
|
||||||
|
try {
|
||||||
|
return loadSkillsFromRoots(roots).map((skill) => ({ ...this.discover(skill.manifest), skill }))
|
||||||
|
} catch (error) {
|
||||||
|
return [{ ok: false, error: error instanceof Error ? error.message : String(error) }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate a discovered capability.
|
* Validate a discovered capability.
|
||||||
*/
|
*/
|
||||||
|
|||||||
117
packages/runtime/src/capabilities/SkillLoader.ts
Executable file
117
packages/runtime/src/capabilities/SkillLoader.ts
Executable file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* SkillLoader - SKILL.md capability bridge.
|
||||||
|
* Loads skill directories into Capability manifests without executing skill content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, statSync, readdirSync } from 'fs'
|
||||||
|
import { resolve, relative, basename } from 'path'
|
||||||
|
import type { CapabilityManifest } from './CapabilityManifestValidator.js'
|
||||||
|
|
||||||
|
export interface SkillDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
directory: string
|
||||||
|
content: string
|
||||||
|
frontmatter: Record<string, unknown>
|
||||||
|
manifest: CapabilityManifest
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSkillDirectory(skill_dir: string, trusted_roots: string[]): SkillDefinition {
|
||||||
|
const directory = resolve(skill_dir)
|
||||||
|
ensureTrusted(directory, trusted_roots)
|
||||||
|
const skill_path = resolve(directory, 'SKILL.md')
|
||||||
|
if (!existsSync(skill_path) || !statSync(skill_path).isFile()) {
|
||||||
|
throw new Error(`SKILL.md not found in ${directory}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readFileSync(skill_path, 'utf-8')
|
||||||
|
const parsed = parseSkillMarkdown(raw)
|
||||||
|
const name = slug(String(parsed.frontmatter.name || basename(directory)))
|
||||||
|
const description = String(parsed.frontmatter.description || firstParagraph(parsed.body) || `Skill ${name}`)
|
||||||
|
const toolName = `skill.${name}`
|
||||||
|
const manifest: CapabilityManifest = {
|
||||||
|
schema_version: 1,
|
||||||
|
name,
|
||||||
|
version: String(parsed.frontmatter.version || '1.0.0'),
|
||||||
|
description,
|
||||||
|
trust_level: 'project_local',
|
||||||
|
tools: [{
|
||||||
|
name: toolName,
|
||||||
|
category: 'internal',
|
||||||
|
permissions: { read: true, write: false, network: false },
|
||||||
|
input_schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
task: { type: 'string' },
|
||||||
|
skill_directory: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['task'],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: name, name, description, directory, content: parsed.body, frontmatter: parsed.frontmatter, manifest }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadSkillsFromRoots(roots: string[]): SkillDefinition[] {
|
||||||
|
const skills: SkillDefinition[] = []
|
||||||
|
for (const root of roots.map((r) => resolve(r))) {
|
||||||
|
if (!existsSync(root) || !statSync(root).isDirectory()) continue
|
||||||
|
const direct = resolve(root, 'SKILL.md')
|
||||||
|
if (existsSync(direct)) {
|
||||||
|
skills.push(loadSkillDirectory(root, roots))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const entries = Array.from(new Set(readDirectoryNames(root)))
|
||||||
|
for (const entry of entries) {
|
||||||
|
const dir = resolve(root, entry)
|
||||||
|
if (existsSync(resolve(dir, 'SKILL.md'))) skills.push(loadSkillDirectory(dir, roots))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skills
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTrusted(path: string, roots: string[]): void {
|
||||||
|
const trusted = roots.map((root) => resolve(root)).some((root) => {
|
||||||
|
const rel = relative(root, path)
|
||||||
|
return rel === '' || (!rel.startsWith('..') && !rel.startsWith('/'))
|
||||||
|
})
|
||||||
|
if (!trusted) throw new Error(`Skill path is outside trusted roots: ${path}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSkillMarkdown(raw: string): { frontmatter: Record<string, unknown>; body: string } {
|
||||||
|
if (!raw.startsWith('---\n')) return { frontmatter: {}, body: raw.trim() }
|
||||||
|
const end = raw.indexOf('\n---\n', 4)
|
||||||
|
if (end === -1) return { frontmatter: {}, body: raw.trim() }
|
||||||
|
const frontmatter = parseFrontmatter(raw.slice(4, end))
|
||||||
|
return { frontmatter, body: raw.slice(end + 5).trim() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(text: string): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {}
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const idx = line.indexOf(':')
|
||||||
|
if (idx <= 0) continue
|
||||||
|
const key = line.slice(0, idx).trim()
|
||||||
|
const value = line.slice(idx + 1).trim().replace(/^['"]|['"]$/g, '')
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstParagraph(text: string): string {
|
||||||
|
return text.split(/\n\s*\n/).map((p) => p.trim()).find(Boolean) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function slug(value: string): string {
|
||||||
|
const next = value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||||
|
return next || 'skill'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDirectoryNames(root: string): string[] {
|
||||||
|
return readdirSync(root).filter((name) => {
|
||||||
|
const path = resolve(root, name)
|
||||||
|
return statSync(path).isDirectory()
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { execFileSync } from 'child_process'
|
|||||||
|
|
||||||
export interface DoctorCheck {
|
export interface DoctorCheck {
|
||||||
name: string
|
name: string
|
||||||
category: 'self_bootstrap' | 'capability' | 'project' | 'runtime'
|
category: 'self_bootstrap' | 'capability' | 'project' | 'runtime' | 'toolchain' | 'display' | 'network' | 'provider'
|
||||||
passed: boolean
|
passed: boolean
|
||||||
message: string
|
message: string
|
||||||
fixable: boolean
|
fixable: boolean
|
||||||
@@ -66,6 +66,14 @@ export class DoctorService {
|
|||||||
checks.push(this.check_capability_deps())
|
checks.push(this.check_capability_deps())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FR-018/§6.12: toolchain / display / network / provider checks
|
||||||
|
if (scope === 'all') {
|
||||||
|
checks.push(...this.check_cpp_toolchain())
|
||||||
|
checks.push(this.check_display())
|
||||||
|
checks.push(await this.check_network())
|
||||||
|
checks.push(...await this.check_provider())
|
||||||
|
}
|
||||||
|
|
||||||
const all_passed = checks.every(c => c.passed)
|
const all_passed = checks.every(c => c.passed)
|
||||||
return { checks, all_passed, bootstrap_passed: true, fixable_count: checks.filter(c => c.fixable).length }
|
return { checks, all_passed, bootstrap_passed: true, fixable_count: checks.filter(c => c.fixable).length }
|
||||||
}
|
}
|
||||||
@@ -219,4 +227,98 @@ export class DoctorService {
|
|||||||
return { name: 'capability_deps', category: 'capability', passed: false, message: `Capability check failed: ${e.message}`, fixable: true }
|
return { name: 'capability_deps', category: 'capability', passed: false, message: `Capability check failed: ${e.message}`, fixable: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== FR-018/§6.12: 5 new categories =====
|
||||||
|
|
||||||
|
private check_cpp_toolchain(): DoctorCheck[] {
|
||||||
|
const tools = ['cmake', 'ninja', 'cppcheck', 'clangd', 'g++']
|
||||||
|
const reports: DoctorCheck[] = []
|
||||||
|
for (const t of tools) {
|
||||||
|
try {
|
||||||
|
const v = execFileSync('which', [t], { stdio: 'pipe', timeout: 3000 }).toString().trim()
|
||||||
|
reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: true, message: `${t} found at ${v}`, fixable: false })
|
||||||
|
} catch {
|
||||||
|
reports.push({ name: `toolchain.${t}`, category: 'toolchain', passed: false, message: `${t} not found`, fixable: true, fix: `apt install ${t === 'cmake' ? 'cmake' : t === 'ninja' ? 'ninja-build' : t}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reports
|
||||||
|
}
|
||||||
|
|
||||||
|
private check_display(): DoctorCheck {
|
||||||
|
const display = process.env.DISPLAY
|
||||||
|
const wayland = process.env.WAYLAND_DISPLAY
|
||||||
|
if (!display && !wayland) {
|
||||||
|
return { name: 'display', category: 'display', passed: false, message: 'No DISPLAY/WAYLAND_DISPLAY (gui.screenshot will fail)', fixable: false }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
execFileSync('which', ['import'], { stdio: 'pipe' })
|
||||||
|
return { name: 'display', category: 'display', passed: true, message: `Display ${display || wayland} + ImageMagick available`, fixable: false }
|
||||||
|
} catch {
|
||||||
|
return { name: 'display', category: 'display', passed: false, message: 'ImageMagick not installed', fixable: true, fix: 'apt install imagemagick' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async check_network(): Promise<DoctorCheck> {
|
||||||
|
try {
|
||||||
|
const r = await fetch('https://1.1.1.1', { method: 'HEAD', signal: AbortSignal.timeout(3000) })
|
||||||
|
return { name: 'network.internet', category: 'network', passed: r.ok || r.status > 0, message: `HTTP ${r.status}`, fixable: false }
|
||||||
|
} catch (e: any) {
|
||||||
|
return { name: 'network.internet', category: 'network', passed: false, message: e.message, fixable: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async check_provider(): Promise<DoctorCheck[]> {
|
||||||
|
const reports: DoctorCheck[] = []
|
||||||
|
const apiKey = process.env.AIRCODING_API_KEY || process.env.OPENAI_API_KEY
|
||||||
|
const baseUrl = process.env.OPENAI_BASE_URL || process.env.AIRCODING_API_URL
|
||||||
|
const model = process.env.AIRCODING_MODEL
|
||||||
|
|
||||||
|
reports.push({
|
||||||
|
name: 'provider.api_key',
|
||||||
|
category: 'provider',
|
||||||
|
passed: Boolean(apiKey),
|
||||||
|
message: apiKey ? `API key set (${apiKey.slice(0, 7)}...)` : 'No API key set',
|
||||||
|
fixable: false,
|
||||||
|
})
|
||||||
|
reports.push({
|
||||||
|
name: 'provider.base_url',
|
||||||
|
category: 'provider',
|
||||||
|
passed: Boolean(baseUrl),
|
||||||
|
message: baseUrl ? `Base URL: ${baseUrl}` : 'No base URL set',
|
||||||
|
fixable: false,
|
||||||
|
})
|
||||||
|
reports.push({
|
||||||
|
name: 'provider.model',
|
||||||
|
category: 'provider',
|
||||||
|
passed: Boolean(model),
|
||||||
|
message: model || 'No model set',
|
||||||
|
fixable: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (apiKey && baseUrl) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${baseUrl.replace(/\/$/, '')}/v1/models`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'Authorization': `Bearer ${apiKey}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
reports.push({
|
||||||
|
name: 'provider.connectivity',
|
||||||
|
category: 'provider',
|
||||||
|
passed: r.ok || r.status > 0,
|
||||||
|
message: `HTTP ${r.status}`,
|
||||||
|
fixable: false,
|
||||||
|
})
|
||||||
|
} catch (e: any) {
|
||||||
|
reports.push({
|
||||||
|
name: 'provider.connectivity',
|
||||||
|
category: 'provider',
|
||||||
|
passed: false,
|
||||||
|
message: e.message,
|
||||||
|
fixable: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reports
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export { BuiltInToolRegistrar, register_builtin_tools } from './tools/BuiltInToo
|
|||||||
// Capabilities
|
// Capabilities
|
||||||
export { CapabilityManifestValidator, createCapabilityManifestValidator } from './capabilities/CapabilityManifestValidator.js'
|
export { CapabilityManifestValidator, createCapabilityManifestValidator } from './capabilities/CapabilityManifestValidator.js'
|
||||||
export { CapabilityRegistry, createCapabilityRegistry } from './capabilities/CapabilityRegistry.js'
|
export { CapabilityRegistry, createCapabilityRegistry } from './capabilities/CapabilityRegistry.js'
|
||||||
|
export { loadSkillDirectory, loadSkillsFromRoots } from './capabilities/SkillLoader.js'
|
||||||
|
export type { SkillDefinition } from './capabilities/SkillLoader.js'
|
||||||
|
|
||||||
// Context
|
// Context
|
||||||
export { PromptLayerLoader, createPromptLayerLoader } from './context/PromptLayerLoader.js'
|
export { PromptLayerLoader, createPromptLayerLoader } from './context/PromptLayerLoader.js'
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ export interface ArtifactProjection {
|
|||||||
|
|
||||||
export interface PermissionPromptProjection {
|
export interface PermissionPromptProjection {
|
||||||
prompt_id: string
|
prompt_id: string
|
||||||
tool_name: string
|
subject: string
|
||||||
|
risk_level: string
|
||||||
reason: string
|
reason: string
|
||||||
|
options: string[]
|
||||||
|
default_option?: string
|
||||||
|
tool_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlockerProjection {
|
export interface BlockerProjection {
|
||||||
@@ -291,7 +295,12 @@ export class ProjectionStore {
|
|||||||
case 'permission.prompt.requested': {
|
case 'permission.prompt.requested': {
|
||||||
proj.permission_prompts.push({
|
proj.permission_prompts.push({
|
||||||
prompt_id: p.prompt_id || `pp_${Date.now()}`,
|
prompt_id: p.prompt_id || `pp_${Date.now()}`,
|
||||||
tool_name: p.tool_name, reason: p.reason || ''
|
subject: p.subject || p.tool_name || 'permission request',
|
||||||
|
risk_level: p.risk_level || 'unknown',
|
||||||
|
reason: p.reason || '',
|
||||||
|
options: Array.isArray(p.options) ? p.options : [],
|
||||||
|
default_option: p.default_option,
|
||||||
|
tool_name: p.tool_name,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -334,14 +343,17 @@ export class ProjectionStore {
|
|||||||
* Returns the rebuilt projection.
|
* Returns the rebuilt projection.
|
||||||
*/
|
*/
|
||||||
async rebuild(session_id: string): Promise<SessionProjection | undefined> {
|
async rebuild(session_id: string): Promise<SessionProjection | undefined> {
|
||||||
|
const session = this.repos.session ? await this.repos.session.get(session_id as SessionID) : undefined
|
||||||
const tasks = this.repos.task ? await this.repos.task.list_by_status(session_id, ['pending', 'running', 'interrupted', 'completed', 'failed', 'blocked', 'cancelled']) : []
|
const tasks = this.repos.task ? await this.repos.task.list_by_status(session_id, ['pending', 'running', 'interrupted', 'completed', 'failed', 'blocked', 'cancelled']) : []
|
||||||
const agents = this.repos.agent ? await this.repos.agent.list_active(session_id) : []
|
const agents = this.repos.agent ? await this.repos.agent.list_active(session_id) : []
|
||||||
|
|
||||||
// Initialize projection with what we have
|
if (!session && tasks.length === 0 && agents.length === 0) return undefined
|
||||||
|
|
||||||
const proj: SessionProjection = {
|
const proj: SessionProjection = {
|
||||||
session_id,
|
session_id,
|
||||||
project_id: '',
|
project_id: session?.project_id ?? '',
|
||||||
status: 'active',
|
status: session?.status ?? 'active',
|
||||||
|
title: session?.title,
|
||||||
tasks: tasks.map((t: any) => ({
|
tasks: tasks.map((t: any) => ({
|
||||||
id: t.id, type: t.type, status: t.status, title: t.title || '',
|
id: t.id, type: t.type, status: t.status, title: t.title || '',
|
||||||
retry_count: t.retry_count || 0, attempts: 0, created_at: t.created_at || '',
|
retry_count: t.retry_count || 0, attempts: 0, created_at: t.created_at || '',
|
||||||
@@ -359,6 +371,7 @@ export class ProjectionStore {
|
|||||||
updated_at: new Date().toISOString()
|
updated_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
this.snapshot.set(session_id, proj)
|
this.snapshot.set(session_id, proj)
|
||||||
|
this.notify(proj)
|
||||||
return proj
|
return proj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { WavePlanner } from './WavePlanner.js'
|
|||||||
import { RetryPlanner } from './RetryPlanner.js'
|
import { RetryPlanner } from './RetryPlanner.js'
|
||||||
import { WorkspaceManager } from './WorkspaceManager.js'
|
import { WorkspaceManager } from './WorkspaceManager.js'
|
||||||
import { AgentMonitor } from './AgentMonitor.js'
|
import { AgentMonitor } from './AgentMonitor.js'
|
||||||
import { eventIngestor } from '../events/EventIngestor.js'
|
import { eventIngestor, type IEventIngestor } from '../events/EventIngestor.js'
|
||||||
import type { WorkerManager } from '../workers/WorkerManager.js'
|
import type { WorkerManager } from '../workers/WorkerManager.js'
|
||||||
|
|
||||||
export type SchedulerState =
|
export type SchedulerState =
|
||||||
@@ -48,9 +48,11 @@ export class Scheduler {
|
|||||||
private context: SchedulerContext
|
private context: SchedulerContext
|
||||||
private worker_manager?: WorkerManager
|
private worker_manager?: WorkerManager
|
||||||
private task_repo?: any
|
private task_repo?: any
|
||||||
|
private event_ingestor: IEventIngestor
|
||||||
|
|
||||||
constructor(context: SchedulerContext, worker_manager?: WorkerManager) {
|
constructor(context: SchedulerContext, worker_manager?: WorkerManager, ingestor: IEventIngestor = eventIngestor) {
|
||||||
this.context = context
|
this.context = context
|
||||||
|
this.event_ingestor = ingestor
|
||||||
this.graph = new TaskGraph()
|
this.graph = new TaskGraph()
|
||||||
this.wave_planner = new WavePlanner()
|
this.wave_planner = new WavePlanner()
|
||||||
this.retry_planner = new RetryPlanner()
|
this.retry_planner = new RetryPlanner()
|
||||||
@@ -62,18 +64,19 @@ export class Scheduler {
|
|||||||
/**
|
/**
|
||||||
* Create tasks from specifications.
|
* Create tasks from specifications.
|
||||||
*/
|
*/
|
||||||
async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[] }>): Promise<void> {
|
async create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[]; task_spec?: Record<string, unknown> }>): Promise<void> {
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
this.graph.add_task({
|
this.graph.add_task({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
type: task.type, title: task.title,
|
type: task.type, title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
|
task_spec: task.task_spec,
|
||||||
dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || []
|
dependencies: task.depends_on?.map(d => ({ task_id: d, type: 'hard' as const })) || []
|
||||||
})
|
})
|
||||||
|
|
||||||
// Emit task.created events (INV-1: via event store for projection)
|
// Emit task.created events (INV-1: via event store for projection)
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task.id}_created`,
|
id: `evt_${task.id}_created`,
|
||||||
type: 'task.created',
|
type: 'task.created',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -86,7 +89,7 @@ export class Scheduler {
|
|||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
type: task.type,
|
type: task.type,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
task_spec_json: { description: task.description || '' },
|
task_spec_json: task.task_spec || { description: task.description || '' },
|
||||||
dependencies: (task.depends_on || []).map(d => ({ depends_on_task_id: d, dependency_type: 'hard', reason: '' })),
|
dependencies: (task.depends_on || []).map(d => ({ depends_on_task_id: d, dependency_type: 'hard', reason: '' })),
|
||||||
metadata: {},
|
metadata: {},
|
||||||
}
|
}
|
||||||
@@ -132,25 +135,23 @@ export class Scheduler {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'PLANNING_WAVE': {
|
case 'PLANNING_WAVE': {
|
||||||
// Check if all tasks done
|
|
||||||
const counts = this.graph.count_by_status()
|
const counts = this.graph.count_by_status()
|
||||||
const remaining = (counts.pending || 0) + (counts.running || 0)
|
const pending = counts.pending || 0
|
||||||
|
const running = counts.running || 0
|
||||||
|
|
||||||
if (remaining === 0) {
|
if (pending === 0 && running === 0) {
|
||||||
this.state = 'COMPLETED'
|
this.state = this.terminal_state_from_counts(counts)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (running > 0) {
|
||||||
|
this.state = 'MONITORING'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plan next wave
|
|
||||||
const plan = this.wave_planner.plan(this.graph)
|
const plan = this.wave_planner.plan(this.graph)
|
||||||
if (plan.length === 0) {
|
if (plan.length === 0) {
|
||||||
// Check for blocked tasks
|
this.state = pending > 0 ? 'REPAIRING_OR_CONTINUING' : this.terminal_state_from_counts(counts)
|
||||||
const pending = this.graph.count_by_status().pending || 0
|
|
||||||
if (pending > 0) {
|
|
||||||
this.state = 'REPAIRING_OR_CONTINUING'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.state = 'COMPLETED'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +166,7 @@ export class Scheduler {
|
|||||||
|
|
||||||
// INV-1: Emit task.started event (durable) for projection
|
// INV-1: Emit task.started event (durable) for projection
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task.id}_started`,
|
id: `evt_${task.id}_started`,
|
||||||
type: 'task.started',
|
type: 'task.started',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -185,7 +186,7 @@ export class Scheduler {
|
|||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
project_root: this.context.project_root,
|
project_root: this.context.project_root,
|
||||||
task_type: task.type || 'execute',
|
task_type: task.type || 'execute',
|
||||||
task_spec: {
|
task_spec: task.task_spec || {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title || task.id,
|
title: task.title || task.id,
|
||||||
description: task.description || '',
|
description: task.description || '',
|
||||||
@@ -196,7 +197,7 @@ export class Scheduler {
|
|||||||
this.agent_monitor.record_heartbeat(agent_id, task.id)
|
this.agent_monitor.record_heartbeat(agent_id, task.id)
|
||||||
|
|
||||||
// INV-1: Emit agent.started event (durable) for projection
|
// INV-1: Emit agent.started event (durable) for projection
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${agent_id}_started`,
|
id: `evt_${agent_id}_started`,
|
||||||
type: 'agent.started',
|
type: 'agent.started',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -218,7 +219,7 @@ export class Scheduler {
|
|||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// INV-1: emit task.failed event for projection
|
// INV-1: emit task.failed event for projection
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task.id}_failed`,
|
id: `evt_${task.id}_failed`,
|
||||||
type: 'task.failed',
|
type: 'task.failed',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -245,7 +246,7 @@ export class Scheduler {
|
|||||||
const hb = this.agent_monitor.get(l.agent_id)
|
const hb = this.agent_monitor.get(l.agent_id)
|
||||||
if (hb) {
|
if (hb) {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${hb.task_id}_lost`,
|
id: `evt_${hb.task_id}_lost`,
|
||||||
type: 'agent.lost',
|
type: 'agent.lost',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -269,7 +270,7 @@ export class Scheduler {
|
|||||||
case 'hard_cancel':
|
case 'hard_cancel':
|
||||||
case 'soft_cancel':
|
case 'soft_cancel':
|
||||||
if (task_id) {
|
if (task_id) {
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task_id}_cancelled`,
|
id: `evt_${task_id}_cancelled`,
|
||||||
type: 'agent.cancelled',
|
type: 'agent.cancelled',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -299,7 +300,7 @@ export class Scheduler {
|
|||||||
|
|
||||||
const attempt_id = `${task.id}_1`
|
const attempt_id = `${task.id}_1`
|
||||||
if (result.status === 'completed') {
|
if (result.status === 'completed') {
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task.id}_completed`,
|
id: `evt_${task.id}_completed`,
|
||||||
type: 'task.completed',
|
type: 'task.completed',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -320,7 +321,7 @@ export class Scheduler {
|
|||||||
})
|
})
|
||||||
this.graph.update_status(task.id, 'completed')
|
this.graph.update_status(task.id, 'completed')
|
||||||
// INV-1: Emit agent.completed event (durable) for projection
|
// INV-1: Emit agent.completed event (durable) for projection
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${handle.worker_id}_completed`,
|
id: `evt_${handle.worker_id}_completed`,
|
||||||
type: 'agent.completed',
|
type: 'agent.completed',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -333,7 +334,7 @@ export class Scheduler {
|
|||||||
})
|
})
|
||||||
this.agent_monitor.remove(handle.worker_id)
|
this.agent_monitor.remove(handle.worker_id)
|
||||||
} else if (result.status === 'blocked') {
|
} else if (result.status === 'blocked') {
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task.id}_blocked`,
|
id: `evt_${task.id}_blocked`,
|
||||||
type: 'task.blocked',
|
type: 'task.blocked',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -347,7 +348,7 @@ export class Scheduler {
|
|||||||
this.graph.update_status(task.id, 'blocked')
|
this.graph.update_status(task.id, 'blocked')
|
||||||
this.agent_monitor.remove(handle.worker_id)
|
this.agent_monitor.remove(handle.worker_id)
|
||||||
} else if (result.status === 'cancelled') {
|
} else if (result.status === 'cancelled') {
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task.id}_cancelled_result`,
|
id: `evt_${task.id}_cancelled_result`,
|
||||||
type: 'task.cancelled',
|
type: 'task.cancelled',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -361,7 +362,7 @@ export class Scheduler {
|
|||||||
this.graph.update_status(task.id, 'cancelled')
|
this.graph.update_status(task.id, 'cancelled')
|
||||||
this.agent_monitor.remove(handle.worker_id)
|
this.agent_monitor.remove(handle.worker_id)
|
||||||
} else {
|
} else {
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task.id}_failed_result`,
|
id: `evt_${task.id}_failed_result`,
|
||||||
type: 'task.failed',
|
type: 'task.failed',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -374,7 +375,7 @@ export class Scheduler {
|
|||||||
})
|
})
|
||||||
this.graph.update_status(task.id, 'failed')
|
this.graph.update_status(task.id, 'failed')
|
||||||
// INV-1: Emit agent.failed event (durable) for projection
|
// INV-1: Emit agent.failed event (durable) for projection
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${handle.worker_id}_failed`,
|
id: `evt_${handle.worker_id}_failed`,
|
||||||
type: 'agent.failed',
|
type: 'agent.failed',
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -440,6 +441,13 @@ export class Scheduler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private terminal_state_from_counts(counts: Record<string, number>): SchedulerState {
|
||||||
|
if ((counts.failed || 0) > 0) return 'TERMINATED'
|
||||||
|
if ((counts.blocked || 0) > 0) return 'BLOCKED'
|
||||||
|
if ((counts.cancelled || 0) > 0) return 'CANCELLED'
|
||||||
|
return 'COMPLETED'
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus).
|
* Rebuild scheduler state from SQLite (INV-5: from EventStore, not EventBus).
|
||||||
* Loads pending/running tasks from the tasks table and reconstructs the in-memory graph.
|
* Loads pending/running tasks from the tasks table and reconstructs the in-memory graph.
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface TaskNode {
|
|||||||
title?: string
|
title?: string
|
||||||
description?: string
|
description?: string
|
||||||
acceptance_criteria?: string[]
|
acceptance_criteria?: string[]
|
||||||
|
task_spec?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphValidation {
|
export interface GraphValidation {
|
||||||
|
|||||||
@@ -132,20 +132,6 @@ export class BuiltInToolRegistrar {
|
|||||||
'project.profile.write': def('project.profile.write', 'project', 'Write language profile/toolchain configuration',
|
'project.profile.write': def('project.profile.write', 'project', 'Write language profile/toolchain configuration',
|
||||||
{ language: { type: 'string', description: 'Language (cpp/c/rust/python)' }, profile_json: { type: 'object', description: 'Profile configuration' } }, ['language', 'profile_json'],
|
{ language: { type: 'string', description: 'Language (cpp/c/rust/python)' }, profile_json: { type: 'object', description: 'Profile configuration' } }, ['language', 'profile_json'],
|
||||||
{ read: false, write: true, network: false }),
|
{ read: false, write: true, network: false }),
|
||||||
// cpp toolchain
|
|
||||||
'cpp.detect': def('cpp.detect', 'debug', 'Detect C++ project structure, toolchain, and source files',
|
|
||||||
{ project_root: { type: 'string', description: 'Project root path' } }, []),
|
|
||||||
'cpp.cmake.configure': def('cpp.cmake.configure', 'build', 'Configure C++ build with CMake (Ninja preferred, Make fallback)',
|
|
||||||
{ generator: { type: 'string', description: 'Generator (Ninja/Unix Makefiles)' }, build_type: { type: 'string', description: 'Debug/Release/RelWithDebInfo' } }, [],
|
|
||||||
{ read: true, write: true, network: false }),
|
|
||||||
'cpp.build': def('cpp.build', 'build', 'Build C++ project via CMake',
|
|
||||||
{ target: { type: 'string', description: 'Build target' }, config: { type: 'string', description: 'Debug/Release' } }, []),
|
|
||||||
'cpp.test': def('cpp.test', 'test', 'Run C++ tests via ctest',
|
|
||||||
{ filter: { type: 'string', description: 'Test filter pattern' } }, []),
|
|
||||||
'cpp.static.cppcheck': def('cpp.static.cppcheck', 'static_analysis', 'Run cppcheck static analysis on C++ code',
|
|
||||||
{ path: { type: 'string', description: 'Path to analyze' }, severity: { type: 'string', description: 'Minimum severity' } }, []),
|
|
||||||
'cpp.clangd.query': def('cpp.clangd.query', 'static_analysis', 'Query clangd LSP for symbol definition or diagnostics',
|
|
||||||
{ file: { type: 'string', description: 'Source file path' }, line: { type: 'number', description: 'Line number' }, column: { type: 'number', description: 'Column number' } }, ['file']),
|
|
||||||
// debug
|
// debug
|
||||||
'debug.run': def('debug.run', 'debug', 'Run debugger on a target process or binary',
|
'debug.run': def('debug.run', 'debug', 'Run debugger on a target process or binary',
|
||||||
{ target: { type: 'string', description: 'Binary or process to debug' }, breakpoints: { type: 'array', items: { type: 'string' } } }, ['target']),
|
{ target: { type: 'string', description: 'Binary or process to debug' }, breakpoints: { type: 'array', items: { type: 'string' } } }, ['target']),
|
||||||
@@ -257,88 +243,6 @@ export class BuiltInToolRegistrar {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
'cpp.detect': async (call: any) => {
|
|
||||||
try {
|
|
||||||
const root = (call.arguments as any)?.project_root || project_root
|
|
||||||
const cmake = existsSync(join(root, 'CMakeLists.txt'))
|
|
||||||
const makefile = existsSync(join(root, 'Makefile'))
|
|
||||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
|
||||||
output: { has_cmake: cmake, has_makefile: makefile, build_system: cmake ? 'cmake' : makefile ? 'make' : 'none' },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
|
||||||
} catch (e: any) {
|
|
||||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
'cpp.cmake.configure': async (call: any) => {
|
|
||||||
try {
|
|
||||||
const { generator = 'Ninja', build_type = 'Debug' } = (call.arguments || {}) as any
|
|
||||||
const buildDir = join(project_root, 'build')
|
|
||||||
if (!existsSync(buildDir)) mkdirSync(buildDir, { recursive: true })
|
|
||||||
execFileSync('cmake', ['-G', generator, '-DCMAKE_BUILD_TYPE=' + build_type, '..'], { cwd: buildDir, stdio: 'pipe' })
|
|
||||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text', output: { generator, build_type, configured: true },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
|
||||||
} catch (e: any) {
|
|
||||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
'cpp.build': async (call: any) => {
|
|
||||||
try {
|
|
||||||
const { target, config = 'Debug' } = (call.arguments || {}) as any
|
|
||||||
const args = target ? ['--build', '.', '--config', config, '--target', target] : ['--build', '.', '--config', config]
|
|
||||||
const out = execFileSync('cmake', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
|
|
||||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
|
||||||
output: { built: true, output: out.toString().slice(-500) },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
|
||||||
} catch (e: any) {
|
|
||||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
'cpp.test': async (call: any) => {
|
|
||||||
try {
|
|
||||||
const { filter } = (call.arguments || {}) as any
|
|
||||||
const args = filter ? ['--output-on-failure', '-R', filter] : ['--output-on-failure']
|
|
||||||
const out = execFileSync('ctest', args, { cwd: join(project_root, 'build'), stdio: 'pipe', timeout: 300000 })
|
|
||||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
|
||||||
output: { passed: true, output: out.toString().slice(-1000) },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
|
||||||
} catch (e: any) {
|
|
||||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
'cpp.static.cppcheck': async (call: any) => {
|
|
||||||
try {
|
|
||||||
const { path = 'src' } = (call.arguments || {}) as any
|
|
||||||
const out = execFileSync('cppcheck', ['--enable=all', '--quiet', path], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 120000 })
|
|
||||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
|
||||||
output: { output: out.toString().slice(-500), issues_found: 0 },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
|
||||||
} catch (e: any) {
|
|
||||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
'cpp.clangd.query': async (call: any) => {
|
|
||||||
try {
|
|
||||||
const { file, line = 0, column = 0 } = (call.arguments || {}) as any
|
|
||||||
const out = execFileSync('clangd', ['--check=' + file], { cwd: project_root, stdio: 'pipe', encoding: 'utf-8', timeout: 30000 })
|
|
||||||
return { status: "ok", call_id: call.call_id, tool_name, type: 'text',
|
|
||||||
output: { file, line, column, diagnostics: out.toString().slice(-1000) },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'ok' } }
|
|
||||||
} catch (e: any) {
|
|
||||||
return { status: 'error', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: e.message, retryability: 'not_retryable', semantic_signature: tool_name },
|
|
||||||
metadata: { timestamp: new Date().toISOString(), call_id: call.call_id, tool_name, type: 'error' } }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
'debug.run': async (call: any) => {
|
'debug.run': async (call: any) => {
|
||||||
try {
|
try {
|
||||||
const { target } = (call.arguments || {}) as any
|
const { target } = (call.arguments || {}) as any
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from
|
|||||||
|
|
||||||
import { PermissionEngine, createPermissionEngine, type PermissionContext, type PermissionDecision, type PermissionAction } from '../security/PermissionEngine.js'
|
import { PermissionEngine, createPermissionEngine, type PermissionContext, type PermissionDecision, type PermissionAction } from '../security/PermissionEngine.js'
|
||||||
import type { AgentType } from '@aircoding/contracts'
|
import type { AgentType } from '@aircoding/contracts'
|
||||||
|
import { eventIngestor } from '../events/EventIngestor.js'
|
||||||
|
import { eventBus, type Subscription } from '../events/EventBus.js'
|
||||||
|
|
||||||
export type ToolExecutionReturn =
|
export type ToolExecutionReturn =
|
||||||
| ToolResultEnvelope
|
| ToolResultEnvelope
|
||||||
@@ -47,6 +49,7 @@ export class ToolRegistry {
|
|||||||
private executors: Map<string, ToolExecutor> = new Map()
|
private executors: Map<string, ToolExecutor> = new Map()
|
||||||
private permission_engine: PermissionEngine
|
private permission_engine: PermissionEngine
|
||||||
private project_root: string
|
private project_root: string
|
||||||
|
private readonly permission_timeout_ms = 5 * 60 * 1000
|
||||||
|
|
||||||
constructor(project_root: string) {
|
constructor(project_root: string) {
|
||||||
this.project_root = project_root
|
this.project_root = project_root
|
||||||
@@ -263,9 +266,39 @@ export class ToolRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'ask_user':
|
case 'ask_user': {
|
||||||
// Suspend; emit permission.prompt.requested
|
const prompt_id = `perm_${crypto.randomUUID()}`
|
||||||
return create_error_result(call.call_id, 'user_prompt_required', 'User confirmation required')
|
await eventIngestor.ingest({
|
||||||
|
id: `evt_${prompt_id}`,
|
||||||
|
type: 'permission.prompt.requested',
|
||||||
|
version: 1,
|
||||||
|
session_id: ctx.session_id,
|
||||||
|
project_id: ctx.project_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: { kind: 'tool', id: call.name },
|
||||||
|
route: ['tool_registry', 'permission'],
|
||||||
|
payload: {
|
||||||
|
prompt_id,
|
||||||
|
subject: call.name,
|
||||||
|
risk_level: decision.risk_level,
|
||||||
|
reason: decision.reason,
|
||||||
|
options: ['allow_once', 'deny'],
|
||||||
|
default_option: 'deny',
|
||||||
|
request_ref: { call_id: call.call_id, tool_name: call.name, agent_id: ctx.agent_id },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const selected = await this.wait_for_permission(prompt_id, ctx)
|
||||||
|
if (selected !== 'allow_once' && selected !== 'allow') {
|
||||||
|
return create_error_result(call.call_id, 'permission_denied', `User selected ${selected}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executor = this.executors.get(call.name)
|
||||||
|
if (!executor) {
|
||||||
|
return create_error_result(call.call_id, 'executor_not_found', 'Executor not registered')
|
||||||
|
}
|
||||||
|
return this.execute_executor_final(executor, call, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
case 'deny':
|
case 'deny':
|
||||||
return create_error_result(call.call_id, 'permission_denied', decision.reason)
|
return create_error_result(call.call_id, 'permission_denied', decision.reason)
|
||||||
@@ -313,6 +346,45 @@ export class ToolRegistry {
|
|||||||
return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === 'function')
|
return Boolean(value && typeof (value as any)[Symbol.asyncIterator] === 'function')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private wait_for_permission(prompt_id: string, ctx: ToolExecutionContext): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
let subscription: Subscription | undefined
|
||||||
|
const finish = (selected: string) => {
|
||||||
|
if (settled) return
|
||||||
|
settled = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (subscription) eventBus.unsubscribe(subscription)
|
||||||
|
resolve(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
void eventIngestor.ingest({
|
||||||
|
id: `evt_${prompt_id}_timeout`,
|
||||||
|
type: 'permission.prompt.resolved',
|
||||||
|
version: 1,
|
||||||
|
session_id: ctx.session_id,
|
||||||
|
project_id: ctx.project_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: { kind: 'tool', id: 'permission_timeout' },
|
||||||
|
route: ['tool_registry', 'permission'],
|
||||||
|
payload: {
|
||||||
|
prompt_id,
|
||||||
|
selected_option: 'deny',
|
||||||
|
decision_id: `decision_${crypto.randomUUID()}`,
|
||||||
|
resolved_by: 'timeout',
|
||||||
|
},
|
||||||
|
}).catch(() => finish('deny'))
|
||||||
|
}, this.permission_timeout_ms)
|
||||||
|
|
||||||
|
subscription = eventBus.subscribe({ session_id: ctx.session_id, types: ['permission.prompt.resolved'] }, (event) => {
|
||||||
|
const payload = event.payload as Record<string, unknown>
|
||||||
|
if (payload.prompt_id !== prompt_id) return
|
||||||
|
finish(String(payload.selected_option || 'deny'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute streaming tool.
|
* Execute streaming tool.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import type { ChildProcess } from 'child_process'
|
|||||||
import { WorkerProcess, type WorkerExitCode } from './WorkerProcess.js'
|
import { WorkerProcess, type WorkerExitCode } from './WorkerProcess.js'
|
||||||
import { WorkerProtocol, type WorkerMessage, type WorkerMessageType } from './WorkerProtocol.js'
|
import { WorkerProtocol, type WorkerMessage, type WorkerMessageType } from './WorkerProtocol.js'
|
||||||
import type { WorkerResult, WorkerStatus, AgentType } from '@aircoding/contracts'
|
import type { WorkerResult, WorkerStatus, AgentType } from '@aircoding/contracts'
|
||||||
|
import { eventIngestor } from '../events/EventIngestor.js'
|
||||||
|
import { eventSchemaRegistry } from '../events/EventSchemaRegistry.js'
|
||||||
import type { ToolRegistry } from '../tools/ToolRegistry.js'
|
import type { ToolRegistry } from '../tools/ToolRegistry.js'
|
||||||
import type { ProviderManager } from '@aircoding/llm'
|
import type { ProviderManager } from '@aircoding/llm'
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ export class WorkerManager {
|
|||||||
state: 'starting',
|
state: 'starting',
|
||||||
started_at: new Date().toISOString()
|
started_at: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
this.workers.set(config.agent_id, handle)
|
||||||
|
|
||||||
// Spawn worker process using Bun
|
// Spawn worker process using Bun
|
||||||
// Worker must run from AirCoding repo root so Bun can resolve modules
|
// Worker must run from AirCoding repo root so Bun can resolve modules
|
||||||
@@ -126,7 +129,6 @@ export class WorkerManager {
|
|||||||
})
|
})
|
||||||
|
|
||||||
handle.state = 'ready'
|
handle.state = 'ready'
|
||||||
this.workers.set(config.agent_id, handle)
|
|
||||||
|
|
||||||
// Set up timeout
|
// Set up timeout
|
||||||
if (config.timeout_ms) {
|
if (config.timeout_ms) {
|
||||||
@@ -238,6 +240,40 @@ export class WorkerManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle worker-emitted RuntimeEvent payloads through the single EventIngestor entry point.
|
||||||
|
proc.on_message('event', async (msg) => {
|
||||||
|
try {
|
||||||
|
const event_type = (msg.payload.event_type || msg.payload.type) as string
|
||||||
|
if (!event_type || !eventSchemaRegistry.isRegistered(event_type, 1)) {
|
||||||
|
console.error(`[WM] ignoring unregistered worker event: ${event_type || '(missing)'}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { event_type: _eventType, ...restPayload } = msg.payload
|
||||||
|
const payload = _eventType ? restPayload : (() => {
|
||||||
|
const { type: _legacyType, ...legacyPayload } = restPayload
|
||||||
|
return legacyPayload
|
||||||
|
})()
|
||||||
|
const event = {
|
||||||
|
id: (payload.event_id as string) || msg.id,
|
||||||
|
type: event_type,
|
||||||
|
version: 1,
|
||||||
|
timestamp: msg.timestamp || new Date().toISOString(),
|
||||||
|
session_id: this.execution_context?.session_id || msg.session_id,
|
||||||
|
project_id: this.execution_context?.project_id || '',
|
||||||
|
source: { kind: 'agent', id: agent_id, agent_type: this.worker_agent_type(agent_id) },
|
||||||
|
route: ['worker', agent_id, event_type],
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistence = eventSchemaRegistry.getPersistence(event_type, 1)
|
||||||
|
if (persistence === 'durable') await eventIngestor.ingest(event as any)
|
||||||
|
else if (persistence === 'ephemeral') await eventIngestor.ingest_ephemeral(event as any)
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('[WM] worker event ingest error:', e.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Handle worker.result → update handle
|
// Handle worker.result → update handle
|
||||||
proc.on_message('worker.result', (msg) => {
|
proc.on_message('worker.result', (msg) => {
|
||||||
const handle = this.workers.get(agent_id)
|
const handle = this.workers.get(agent_id)
|
||||||
@@ -324,20 +360,32 @@ export class WorkerManager {
|
|||||||
* Get result for a task.
|
* Get result for a task.
|
||||||
*/
|
*/
|
||||||
get_result_for_task(task_id: string): WorkerResult<unknown> | undefined {
|
get_result_for_task(task_id: string): WorkerResult<unknown> | undefined {
|
||||||
return this.list().find(w => w.config.task_spec?.id === task_id)?.result
|
return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)?.result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get handle for a task.
|
* Get handle for a task.
|
||||||
*/
|
*/
|
||||||
get_handle_for_task(task_id: string): WorkerHandle | undefined {
|
get_handle_for_task(task_id: string): WorkerHandle | undefined {
|
||||||
return this.list().find(w => w.config.task_spec?.id === task_id)
|
return this.list().find(w => w.config.task_spec?.id === task_id || w.config.task_spec?.task_id === task_id || w.worker_id === `agent_${task_id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Private
|
// Private
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
private worker_agent_type(agent_id: string): AgentType {
|
||||||
|
const handle = this.workers.get(agent_id)
|
||||||
|
const task_type = handle?.config.task_type || 'execute'
|
||||||
|
switch (task_type) {
|
||||||
|
case 'review': return 'reviewer' as AgentType
|
||||||
|
case 'debug': return 'debugger' as AgentType
|
||||||
|
case 'compact': return 'compactor' as AgentType
|
||||||
|
case 'mine_experience': return 'experience_miner' as AgentType
|
||||||
|
default: return 'executor' as AgentType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handle_worker_exit(agent_id: string, exit: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }): void {
|
private handle_worker_exit(agent_id: string, exit: { code: number | null; signal: NodeJS.Signals | null; semantic: string; description: string }): void {
|
||||||
const handle = this.workers.get(agent_id)
|
const handle = this.workers.get(agent_id)
|
||||||
if (!handle) return
|
if (!handle) return
|
||||||
@@ -371,9 +419,11 @@ export class WorkerManager {
|
|||||||
const raw_status = (payload.status as string) || 'completed'
|
const raw_status = (payload.status as string) || 'completed'
|
||||||
const status = raw_status === 'completed' || raw_status === 'cancelled' || raw_status === 'blocked' || raw_status === 'failed'
|
const status = raw_status === 'completed' || raw_status === 'cancelled' || raw_status === 'blocked' || raw_status === 'failed'
|
||||||
? raw_status
|
? raw_status
|
||||||
: raw_status === 'fixed' || raw_status === 'pass'
|
: raw_status === 'fixed' || raw_status === 'cannot_reproduce' || raw_status === 'pass' || raw_status === 'compacted' || raw_status === 'skipped' || raw_status === 'no_patterns'
|
||||||
? 'completed'
|
? 'completed'
|
||||||
: 'failed'
|
: raw_status === 'escalated'
|
||||||
|
? 'blocked'
|
||||||
|
: 'failed'
|
||||||
const changes = Array.isArray((payload as any).changes) ? (payload as any).changes : []
|
const changes = Array.isArray((payload as any).changes) ? (payload as any).changes : []
|
||||||
const changed_files = (payload.changed_files as string[] | undefined) || changes.map((c: any) => String(c.file)).filter(Boolean)
|
const changed_files = (payload.changed_files as string[] | undefined) || changes.map((c: any) => String(c.file)).filter(Boolean)
|
||||||
const verification_payload = payload.verification as any
|
const verification_payload = payload.verification as any
|
||||||
@@ -381,13 +431,15 @@ export class WorkerManager {
|
|||||||
: verification_payload ? [{ command: 'worker verification', passed: Boolean(verification_payload.passed), output: String(verification_payload.output || '') }] as any[]
|
: verification_payload ? [{ command: 'worker verification', passed: Boolean(verification_payload.passed), output: String(verification_payload.output || '') }] as any[]
|
||||||
: []
|
: []
|
||||||
const summary = (payload.summary as string)
|
const summary = (payload.summary as string)
|
||||||
|
|| (payload.summary_content as string)
|
||||||
|
|| (payload.root_cause as string)
|
||||||
|| (payload.error ? String(payload.error) : '')
|
|| (payload.error ? String(payload.error) : '')
|
||||||
|| (changed_files.length > 0 ? `Changed files: ${changed_files.join(', ')}` : `Worker ${status}`)
|
|| (changed_files.length > 0 ? `Changed files: ${changed_files.join(', ')}` : `Worker ${status}`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || '' as any,
|
task_id: (payload.task_id as string) || (handle.config.task_spec?.id as string) || (handle.config.task_spec?.task_id as string) || '' as any,
|
||||||
agent_id: (payload.agent_id as string) || handle.config.agent_id as any,
|
agent_id: (payload.agent_id as string) || handle.config.agent_id as any,
|
||||||
agent_type: (payload.agent_type as AgentType) || 'executor',
|
agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id),
|
||||||
status: status as WorkerStatus,
|
status: status as WorkerStatus,
|
||||||
summary,
|
summary,
|
||||||
changed_files,
|
changed_files,
|
||||||
|
|||||||
@@ -1,59 +1,55 @@
|
|||||||
/**
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
* Regression test: EvidenceStore SQLite persistence
|
import { Database } from 'bun:sqlite'
|
||||||
*
|
import { mkdtempSync, rmSync } from 'fs'
|
||||||
* Verifies that EvidenceStore uses SQLite (bun:sqlite) instead of
|
import { tmpdir } from 'os'
|
||||||
* in-memory Map for persistent storage.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test'
|
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { createEvidenceStore } from '../../src/artifacts/EvidenceStore.js'
|
||||||
const SOURCE_PATH = join(
|
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||||
import.meta.dir,
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'src',
|
|
||||||
'artifacts',
|
|
||||||
'EvidenceStore.ts'
|
|
||||||
)
|
|
||||||
|
|
||||||
const source = readFileSync(SOURCE_PATH, 'utf-8')
|
|
||||||
|
|
||||||
describe('EvidenceStore SQLite persistence', () => {
|
describe('EvidenceStore SQLite persistence', () => {
|
||||||
test('EvidenceStore does not use in-memory Map', () => {
|
const created: string[] = []
|
||||||
// Should not have Map< for storage
|
|
||||||
expect(source).not.toMatch(/evidenceStore:\s*Map</)
|
afterEach(() => {
|
||||||
// Should not use .set() on a map
|
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||||
expect(source).not.toContain('this.evidenceStore.set(')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('EvidenceStore constructor accepts Database parameter', () => {
|
test('persists evidence refs in SQLite and can list them by entity', async () => {
|
||||||
// Constructor should accept a Database parameter
|
const dir = mkdtempSync(join(tmpdir(), 'air-evidence-store-'))
|
||||||
expect(source).toContain('db: Database')
|
created.push(dir)
|
||||||
// Should import Database from bun:sqlite
|
const dbPath = join(dir, 'evidence.db')
|
||||||
expect(source).toContain("from 'bun:sqlite'")
|
|
||||||
})
|
|
||||||
|
|
||||||
test('EvidenceStore has initSchema method', () => {
|
const db1 = new Database(dbPath)
|
||||||
expect(source).toContain('initSchema()')
|
const store1 = createEvidenceStore('session_evidence' as any, db1, createNullEventIngestor() as any)
|
||||||
// Should be called in constructor
|
const createdRef = await store1.create({
|
||||||
expect(source).toContain('this.initSchema()')
|
kind: 'command_output',
|
||||||
})
|
ref: 'artifact://stdout.txt',
|
||||||
|
claim: 'command produced expected output',
|
||||||
|
task_id: 'task_1' as any,
|
||||||
|
location_json: { line: 1 },
|
||||||
|
})
|
||||||
|
expect(createdRef.evidence_ref_id).toStartWith('evi_')
|
||||||
|
expect((await store1.list_for_entity('task', 'task_1'))[0]).toMatchObject({
|
||||||
|
evidence_ref_id: createdRef.evidence_ref_id,
|
||||||
|
kind: 'command_output',
|
||||||
|
ref: 'artifact://stdout.txt',
|
||||||
|
claim: 'command produced expected output',
|
||||||
|
location_json: { line: 1 },
|
||||||
|
})
|
||||||
|
db1.close()
|
||||||
|
|
||||||
test('EvidenceStore creates evidence_refs table', () => {
|
const db2 = new Database(dbPath)
|
||||||
expect(source).toContain('CREATE TABLE IF NOT EXISTS evidence_refs')
|
const rows = db2.query('SELECT evidence_ref_id, session_id, kind, ref, claim, location_json, task_id FROM evidence_refs').all() as any[]
|
||||||
// Should have key columns
|
expect(rows).toHaveLength(1)
|
||||||
expect(source).toContain('evidence_ref_id TEXT PRIMARY KEY')
|
expect(rows[0]).toMatchObject({
|
||||||
expect(source).toContain('session_id TEXT NOT NULL')
|
evidence_ref_id: createdRef.evidence_ref_id,
|
||||||
expect(source).toContain('kind TEXT NOT NULL')
|
session_id: 'session_evidence',
|
||||||
})
|
kind: 'command_output',
|
||||||
|
ref: 'artifact://stdout.txt',
|
||||||
test('EvidenceStore uses INSERT INTO for create', () => {
|
claim: 'command produced expected output',
|
||||||
expect(source).toContain('INSERT INTO evidence_refs')
|
task_id: 'task_1',
|
||||||
})
|
})
|
||||||
|
expect(JSON.parse(rows[0].location_json)).toEqual({ line: 1 })
|
||||||
test('EvidenceStore applies WAL PRAGMA', () => {
|
expect(db2.query("PRAGMA journal_mode").get()).toEqual({ journal_mode: 'wal' })
|
||||||
expect(source).toContain('PRAGMA journal_mode = WAL')
|
db2.close()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,111 +1,88 @@
|
|||||||
/**
|
import { afterEach, describe, expect, it } from 'bun:test'
|
||||||
* C1 regression: Knowledge Store schema alignment.
|
import { existsSync, mkdtempSync, rmSync } from 'fs'
|
||||||
* Bug: DebugKnowledgeStore and LearnedMemoryStore used .air/shared/ paths,
|
import { tmpdir } from 'os'
|
||||||
* had non-canonical column names, and were missing PRAGMAs.
|
|
||||||
* Fix: moved to .air/local/, renamed columns, added WAL/synchronous/foreign_keys PRAGMAs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect } from 'bun:test'
|
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { DebugKnowledgeStore } from '../../src/knowledge/DebugKnowledgeStore.js'
|
||||||
|
import { LearnedMemoryStore } from '../../src/knowledge/LearnedMemoryStore.js'
|
||||||
|
|
||||||
describe('C1: Knowledge Store schema alignment', () => {
|
describe('C1: Knowledge Store schema alignment', () => {
|
||||||
const debug_src = readFileSync(
|
const created: string[] = []
|
||||||
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'DebugKnowledgeStore.ts'),
|
|
||||||
'utf-8'
|
|
||||||
)
|
|
||||||
const memory_src = readFileSync(
|
|
||||||
join(import.meta.dir, '..', '..', 'src', 'knowledge', 'LearnedMemoryStore.ts'),
|
|
||||||
'utf-8'
|
|
||||||
)
|
|
||||||
|
|
||||||
it('DebugKnowledgeStore DB path uses .air/local/ not .air/shared/', () => {
|
afterEach(() => {
|
||||||
expect(debug_src).toContain("'.air', 'local', 'debug-records.db'")
|
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||||
expect(debug_src).not.toContain("'.air', 'shared', 'debug-records.db'")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('LearnedMemoryStore DB path uses .air/local/ not .air/shared/', () => {
|
it('DebugKnowledgeStore stores and queries records from .air/local', () => {
|
||||||
expect(memory_src).toContain("'.air', 'local', 'learned-memory.db'")
|
const root = mkdtempSync(join(tmpdir(), 'air-debug-store-'))
|
||||||
expect(memory_src).not.toContain("'.air', 'shared', 'learned-memory.db'")
|
created.push(root)
|
||||||
|
const store = new DebugKnowledgeStore(root)
|
||||||
|
store.open()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
|
store.insert({
|
||||||
|
id: 'debug_1',
|
||||||
|
failure_signature: 'compiler:error:missing-header',
|
||||||
|
task_id: 'task_1',
|
||||||
|
root_cause: 'missing include path',
|
||||||
|
fix_ref: 'fix://1',
|
||||||
|
summary: 'Add include path before rebuilding',
|
||||||
|
evidence_json: JSON.stringify(['evi_1']),
|
||||||
|
verification_json: JSON.stringify(['build passed']),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metadata_json: JSON.stringify({ source: 'test' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(existsSync(join(root, '.air', 'local', 'debug-records.db'))).toBe(true)
|
||||||
|
expect(existsSync(join(root, '.air', 'shared', 'debug-records.db'))).toBe(false)
|
||||||
|
expect(store.lookup_by_signature('compiler:error:missing-header')).toHaveLength(1)
|
||||||
|
expect(store.lookup_by_task('task_1')[0]).toMatchObject({
|
||||||
|
id: 'debug_1',
|
||||||
|
failure_signature: 'compiler:error:missing-header',
|
||||||
|
task_id: 'task_1',
|
||||||
|
root_cause: 'missing include path',
|
||||||
|
fix_ref: 'fix://1',
|
||||||
|
summary: 'Add include path before rebuilding',
|
||||||
|
})
|
||||||
|
|
||||||
|
store.update('debug_1', { summary: 'Updated summary', updated_at: now })
|
||||||
|
expect(store.lookup_by_signature('compiler:error:missing-header')[0].summary).toBe('Updated summary')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('DebugRecord has failure_signature not signature', () => {
|
it('LearnedMemoryStore stores candidates/promoted memories from .air/local', () => {
|
||||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
const root = mkdtempSync(join(tmpdir(), 'air-memory-store-'))
|
||||||
expect(iface_match).not.toBeNull()
|
created.push(root)
|
||||||
const iface_body = iface_match![1]
|
const store = new LearnedMemoryStore(root)
|
||||||
|
store.open()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
expect(iface_body).toContain('failure_signature')
|
store.insert({
|
||||||
// Should not have bare 'signature' field (failure_signature contains 'signature' as substring, so check for the exact field pattern)
|
id: 'mem_1',
|
||||||
expect(iface_body).not.toMatch(/^\s*signature\s*:/m)
|
memory_type: 'project_rule',
|
||||||
})
|
summary: 'Use Bun for package scripts',
|
||||||
|
content: 'Project commands should use Bun unless explicitly overridden.',
|
||||||
|
source_entity_type: 'task',
|
||||||
|
source_entity_id: 'task_1',
|
||||||
|
status: 'candidate',
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
metadata_json: JSON.stringify({ confidence: 0.8 }),
|
||||||
|
})
|
||||||
|
|
||||||
it('DebugRecord has summary and fix_ref fields', () => {
|
expect(existsSync(join(root, '.air', 'local', 'learned-memory.db'))).toBe(true)
|
||||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
expect(existsSync(join(root, '.air', 'shared', 'learned-memory.db'))).toBe(false)
|
||||||
expect(iface_match).not.toBeNull()
|
expect(store.lookup_by_type('project_rule')).toHaveLength(1)
|
||||||
const iface_body = iface_match![1]
|
expect(store.lookup_by_type('project_rule')[0]).toMatchObject({
|
||||||
|
id: 'mem_1',
|
||||||
|
memory_type: 'project_rule',
|
||||||
|
status: 'candidate',
|
||||||
|
source_entity_type: 'task',
|
||||||
|
source_entity_id: 'task_1',
|
||||||
|
})
|
||||||
|
|
||||||
expect(iface_body).toContain('summary')
|
store.update_status('mem_1', 'promoted')
|
||||||
expect(iface_body).toContain('fix_ref')
|
expect(store.lookup_by_type('project_rule')[0].status).toBe('promoted')
|
||||||
})
|
store.update_status('mem_1', 'archived')
|
||||||
|
expect(store.lookup_by_type('project_rule')).toHaveLength(0)
|
||||||
it('DebugRecord does not have error_kind or session_id', () => {
|
|
||||||
const iface_match = debug_src.match(/export interface DebugRecord\s*\{([\s\S]*?)\}/)
|
|
||||||
expect(iface_match).not.toBeNull()
|
|
||||||
const iface_body = iface_match![1]
|
|
||||||
|
|
||||||
expect(iface_body).not.toContain('error_kind')
|
|
||||||
expect(iface_body).not.toContain('session_id')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('DebugKnowledgeStore applies WAL PRAGMA', () => {
|
|
||||||
expect(debug_src).toContain('PRAGMA journal_mode = WAL')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('LearnedMemoryStore table is learned_memories (plural)', () => {
|
|
||||||
expect(memory_src).toContain('learned_memories')
|
|
||||||
// Ensure we don't have the singular form used as table name
|
|
||||||
expect(memory_src).not.toMatch(/FROM learned_memory\b/)
|
|
||||||
expect(memory_src).not.toMatch(/INTO learned_memory\b/)
|
|
||||||
expect(memory_src).not.toMatch(/UPDATE learned_memory\b/)
|
|
||||||
expect(memory_src).not.toMatch(/TABLE.*learned_memory\b/)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('MemoryEntry.memory_type has 4 spec values', () => {
|
|
||||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
|
||||||
expect(iface_match).not.toBeNull()
|
|
||||||
const iface_body = iface_match![1]
|
|
||||||
|
|
||||||
expect(iface_body).toContain("'project_rule'")
|
|
||||||
expect(iface_body).toContain("'toolchain_rule'")
|
|
||||||
expect(iface_body).toContain("'skill_update'")
|
|
||||||
expect(iface_body).toContain("'debug_experience'")
|
|
||||||
expect(iface_body).toContain('memory_type')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('MemoryEntry.status has 4 spec values: candidate, promoted, archived, rejected', () => {
|
|
||||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
|
||||||
expect(iface_match).not.toBeNull()
|
|
||||||
const iface_body = iface_match![1]
|
|
||||||
|
|
||||||
expect(iface_body).toContain("'candidate'")
|
|
||||||
expect(iface_body).toContain("'promoted'")
|
|
||||||
expect(iface_body).toContain("'archived'")
|
|
||||||
expect(iface_body).toContain("'rejected'")
|
|
||||||
})
|
|
||||||
|
|
||||||
it('MemoryEntry.status default is candidate not draft', () => {
|
|
||||||
// Check that the CREATE TABLE DDL uses 'candidate' as default
|
|
||||||
expect(memory_src).toContain("DEFAULT 'candidate'")
|
|
||||||
expect(memory_src).not.toContain("DEFAULT 'draft'")
|
|
||||||
})
|
|
||||||
|
|
||||||
it('MemoryEntry uses source_entity_type + source_entity_id', () => {
|
|
||||||
const iface_match = memory_src.match(/export interface MemoryEntry\s*\{([\s\S]*?)\}/)
|
|
||||||
expect(iface_match).not.toBeNull()
|
|
||||||
const iface_body = iface_match![1]
|
|
||||||
|
|
||||||
expect(iface_body).toContain('source_entity_type')
|
|
||||||
expect(iface_body).toContain('source_entity_id')
|
|
||||||
expect(iface_body).not.toContain('source_task_ids')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
131
packages/runtime/test/regression/projection-store-apply.test.ts
Executable file
131
packages/runtime/test/regression/projection-store-apply.test.ts
Executable file
@@ -0,0 +1,131 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { ProjectionStore } from '../../src/projection/ProjectionStore.js'
|
||||||
|
import type { RuntimeEvent } from '@aircoding/contracts'
|
||||||
|
|
||||||
|
function event(type: string, payload: Record<string, unknown>): RuntimeEvent<Record<string, unknown>> {
|
||||||
|
return {
|
||||||
|
id: `evt_${type}_${Math.random().toString(36).slice(2)}`,
|
||||||
|
type,
|
||||||
|
version: 1,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
session_id: 'session_projection_apply' as any,
|
||||||
|
project_id: 'project_projection_apply' as any,
|
||||||
|
source: { kind: 'system' },
|
||||||
|
route: ['test', type],
|
||||||
|
payload,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProjectionStore.apply', () => {
|
||||||
|
it('applies task and agent lifecycle events into a live snapshot', () => {
|
||||||
|
const store = new ProjectionStore()
|
||||||
|
const updates: string[] = []
|
||||||
|
store.subscribe((projection) => {
|
||||||
|
updates.push(`${projection.tasks[0]?.status || 'none'}:${projection.agents[0]?.status || 'none'}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
store.apply(event('task.created', {
|
||||||
|
task_id: 'task_1',
|
||||||
|
type: 'execute',
|
||||||
|
title: 'Create file',
|
||||||
|
task_spec_json: {},
|
||||||
|
dependencies: [],
|
||||||
|
metadata: {},
|
||||||
|
}))
|
||||||
|
store.apply(event('task.started', {
|
||||||
|
task_id: 'task_1',
|
||||||
|
agent_id: 'agent_task_1',
|
||||||
|
attempt_id: 'task_1_1',
|
||||||
|
attempt_index: 0,
|
||||||
|
workspace_id: 'ws_task_1',
|
||||||
|
}))
|
||||||
|
store.apply(event('agent.started', {
|
||||||
|
agent_id: 'agent_task_1',
|
||||||
|
agent_type: 'executor',
|
||||||
|
task_id: 'task_1',
|
||||||
|
metadata: {},
|
||||||
|
}))
|
||||||
|
store.apply(event('agent.completed', {
|
||||||
|
agent_id: 'agent_task_1',
|
||||||
|
task_id: 'task_1',
|
||||||
|
summary: 'done',
|
||||||
|
metadata: {},
|
||||||
|
}))
|
||||||
|
store.apply(event('task.completed', {
|
||||||
|
task_id: 'task_1',
|
||||||
|
agent_id: 'agent_task_1',
|
||||||
|
attempt_id: 'task_1_1',
|
||||||
|
worker_result_json: { status: 'completed' },
|
||||||
|
summary: 'done',
|
||||||
|
changed_files: ['hello.txt'],
|
||||||
|
evidence_refs: [],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const snapshot = store.get_snapshot('session_projection_apply')
|
||||||
|
expect(snapshot).toBeDefined()
|
||||||
|
expect(snapshot!.tasks).toHaveLength(1)
|
||||||
|
expect(snapshot!.tasks[0].status).toBe('completed')
|
||||||
|
expect(snapshot!.tasks[0].agent_id).toBe('agent_task_1')
|
||||||
|
expect(snapshot!.tasks[0].attempts).toBe(1)
|
||||||
|
expect(snapshot!.agents).toHaveLength(1)
|
||||||
|
expect(snapshot!.agents[0].status).toBe('completed')
|
||||||
|
expect(updates.some((u) => u.startsWith('completed:completed'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies tool, permission, and blocker events', () => {
|
||||||
|
const store = new ProjectionStore()
|
||||||
|
|
||||||
|
store.apply(event('tool.started', {
|
||||||
|
tool_run_id: 'tool_1',
|
||||||
|
tool_name: 'fs.write',
|
||||||
|
input_json: {},
|
||||||
|
metadata: {},
|
||||||
|
}))
|
||||||
|
store.apply(event('tool.completed', {
|
||||||
|
tool_run_id: 'tool_1',
|
||||||
|
output_json: { ok: true },
|
||||||
|
duration_ms: 12,
|
||||||
|
artifact_ids: [],
|
||||||
|
evidence_refs: [],
|
||||||
|
metadata: {},
|
||||||
|
}))
|
||||||
|
store.apply(event('permission.prompt.requested', {
|
||||||
|
prompt_id: 'perm_1',
|
||||||
|
subject: 'shell.run',
|
||||||
|
risk_level: 'medium',
|
||||||
|
reason: 'risk score 70 requires user confirmation',
|
||||||
|
options: ['allow_once', 'deny'],
|
||||||
|
default_option: 'deny',
|
||||||
|
request_ref: {},
|
||||||
|
}))
|
||||||
|
store.apply(event('task.created', {
|
||||||
|
task_id: 'task_blocked',
|
||||||
|
type: 'execute',
|
||||||
|
title: 'Blocked task',
|
||||||
|
task_spec_json: {},
|
||||||
|
dependencies: [],
|
||||||
|
metadata: {},
|
||||||
|
}))
|
||||||
|
store.apply(event('task.blocked', {
|
||||||
|
task_id: 'task_blocked',
|
||||||
|
agent_id: 'agent_task_blocked',
|
||||||
|
reason: 'worker blocked',
|
||||||
|
blocker_kind: 'worker_blocked',
|
||||||
|
evidence_refs: [],
|
||||||
|
suggested_next_step: 'review blocker',
|
||||||
|
}))
|
||||||
|
store.apply(event('permission.prompt.resolved', {
|
||||||
|
prompt_id: 'perm_1',
|
||||||
|
selected_option: 'deny',
|
||||||
|
decision_id: 'decision_1',
|
||||||
|
resolved_by: 'test',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const snapshot = store.get_snapshot('session_projection_apply')
|
||||||
|
expect(snapshot).toBeDefined()
|
||||||
|
expect(snapshot!.tool_runs).toEqual([{ tool_run_id: 'tool_1', tool_name: 'fs.write', status: 'ok', duration_ms: 12 }])
|
||||||
|
expect(snapshot!.permission_prompts).toHaveLength(0)
|
||||||
|
expect(snapshot!.tasks.find((task) => task.id === 'task_blocked')?.status).toBe('blocked')
|
||||||
|
expect(snapshot!.blockers).toEqual([{ task_id: 'task_blocked', reason: 'worker blocked', blocker_kind: 'worker_blocked' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,55 +1,95 @@
|
|||||||
/**
|
import { afterEach, describe, expect, test } from 'bun:test'
|
||||||
* Regression test: Recovery implementation completeness
|
import { Database } from 'bun:sqlite'
|
||||||
*
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||||
* Verifies that checkPidLiveness and scanOrphanReferences have real
|
import { tmpdir } from 'os'
|
||||||
* implementations, not just stub return values.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, test, expect } from 'bun:test'
|
|
||||||
import { readFileSync } from 'fs'
|
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
|
import { Recovery } from '../../src/storage/Recovery.js'
|
||||||
const SOURCE_PATH = join(
|
|
||||||
import.meta.dir,
|
|
||||||
'..',
|
|
||||||
'..',
|
|
||||||
'src',
|
|
||||||
'storage',
|
|
||||||
'Recovery.ts'
|
|
||||||
)
|
|
||||||
|
|
||||||
const source = readFileSync(SOURCE_PATH, 'utf-8')
|
|
||||||
|
|
||||||
describe('Recovery implementation', () => {
|
describe('Recovery implementation', () => {
|
||||||
test('checkPidLiveness is not a stub (has implementation code)', () => {
|
const created: string[] = []
|
||||||
// Should have actual implementation with loop logic
|
|
||||||
expect(source).toContain('for (const agent of agents)')
|
afterEach(() => {
|
||||||
expect(source).toContain("action: alive ? 'keep' : 'mark_lost'")
|
for (const dir of created.splice(0)) rmSync(dir, { recursive: true, force: true })
|
||||||
// Should have more than just a bare return []
|
|
||||||
expect(source).toContain('const reports: PidLivenessReport[] = []')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('checkPidLiveness uses process.kill for liveness check', () => {
|
function makeRecovery(): { recovery: Recovery; root: string; artifactRoot: string; dbPath: string } {
|
||||||
// Should use process.kill(pid, 0) for signal-0 liveness check
|
const root = mkdtempSync(join(tmpdir(), 'air-recovery-'))
|
||||||
expect(source).toContain('process.kill(agent.pid, 0)')
|
created.push(root)
|
||||||
|
const artifactRoot = join(root, 'artifacts')
|
||||||
|
const dbPath = join(root, 'session.db')
|
||||||
|
const db = new Database(dbPath)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE sessions (id TEXT PRIMARY KEY);
|
||||||
|
CREATE TABLE tasks (id TEXT PRIMARY KEY, session_id TEXT);
|
||||||
|
CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT);
|
||||||
|
CREATE TABLE task_attempts (id TEXT PRIMARY KEY, task_id TEXT);
|
||||||
|
CREATE TABLE agents (id TEXT PRIMARY KEY, session_id TEXT);
|
||||||
|
CREATE TABLE tool_runs (id TEXT PRIMARY KEY, session_id TEXT);
|
||||||
|
CREATE TABLE command_runs (id TEXT PRIMARY KEY, session_id TEXT);
|
||||||
|
CREATE TABLE artifacts (id TEXT PRIMARY KEY, session_id TEXT);
|
||||||
|
CREATE TABLE evidence_refs (evidence_ref_id TEXT PRIMARY KEY, session_id TEXT);
|
||||||
|
INSERT INTO tasks (id, session_id) VALUES ('task_orphan', 'missing_session');
|
||||||
|
INSERT INTO task_attempts (id, task_id) VALUES ('attempt_orphan', 'missing_task');
|
||||||
|
`)
|
||||||
|
db.close()
|
||||||
|
return {
|
||||||
|
recovery: new Recovery({
|
||||||
|
sessionId: 'session_recovery' as any,
|
||||||
|
projectId: 'project_recovery' as any,
|
||||||
|
artifactRoot,
|
||||||
|
dbPath,
|
||||||
|
projectRoot: root,
|
||||||
|
}),
|
||||||
|
root,
|
||||||
|
artifactRoot,
|
||||||
|
dbPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('checks PID liveness with keep/mark_lost actions', () => {
|
||||||
|
const { recovery } = makeRecovery()
|
||||||
|
const reports = recovery.checkPidLiveness([
|
||||||
|
{ agent_id: 'self', pid: process.pid },
|
||||||
|
{ agent_id: 'missing', pid: 99999999 },
|
||||||
|
])
|
||||||
|
recovery.close()
|
||||||
|
|
||||||
|
expect(reports).toEqual([
|
||||||
|
{ agent_id: 'self', pid: process.pid, alive: true, action: 'keep' },
|
||||||
|
{ agent_id: 'missing', pid: 99999999, alive: false, action: 'mark_lost' },
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('scanOrphanReferences returns OrphanReferenceReport structure', () => {
|
test('scans orphan references from SQLite tables', async () => {
|
||||||
// Should define fkChecks array with the 8 invariant checks
|
const { recovery } = makeRecovery()
|
||||||
expect(source).toContain('fkChecks')
|
const report = await recovery.scan()
|
||||||
expect(source).toContain("table: 'tasks'")
|
recovery.close()
|
||||||
expect(source).toContain("table: 'messages'")
|
|
||||||
expect(source).toContain("table: 'task_attempts'")
|
|
||||||
expect(source).toContain("table: 'agents'")
|
|
||||||
expect(source).toContain("table: 'tool_runs'")
|
|
||||||
expect(source).toContain("table: 'command_runs'")
|
|
||||||
expect(source).toContain("table: 'artifacts'")
|
|
||||||
expect(source).toContain("table: 'evidence_refs'")
|
|
||||||
|
|
||||||
// Should iterate over checks
|
expect(report.orphanReferences.totalFound).toBeGreaterThanOrEqual(2)
|
||||||
expect(source).toContain('for (const check of fkChecks)')
|
expect(report.orphanReferences.archived).toContainEqual({
|
||||||
|
table: 'tasks',
|
||||||
|
id: 'missing_session',
|
||||||
|
reason: 'FK-off: session_id → sessions (1 rows)',
|
||||||
|
})
|
||||||
|
expect(report.orphanReferences.archived).toContainEqual({
|
||||||
|
table: 'task_attempts',
|
||||||
|
id: 'missing_task',
|
||||||
|
reason: 'FK-off: task_id → tasks (1 rows)',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Should return a proper report
|
test('quarantines non-artifact temporary orphan files', async () => {
|
||||||
expect(source).toContain('return report')
|
const { recovery, artifactRoot } = makeRecovery()
|
||||||
|
const tmpDir = join(artifactRoot, 'tmp')
|
||||||
|
const orphanPath = join(tmpDir, 'scratch.tmp')
|
||||||
|
await Bun.write(orphanPath, 'orphan')
|
||||||
|
|
||||||
|
const report = await recovery.scan()
|
||||||
|
recovery.close()
|
||||||
|
|
||||||
|
expect(report.orphanArtifacts.totalFound).toBe(1)
|
||||||
|
expect(report.orphanArtifacts.quarantined).toHaveLength(1)
|
||||||
|
expect(existsSync(report.orphanArtifacts.quarantined[0])).toBe(true)
|
||||||
|
expect(existsSync(orphanPath)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BuiltInToolRegistrar } from '../../src/tools/BuiltInToolRegistrar.js'
|
|||||||
import { Scheduler } from '../../src/scheduler/Scheduler.js'
|
import { Scheduler } from '../../src/scheduler/Scheduler.js'
|
||||||
import { MainAgent } from '../../src/agents/main/MainAgent.js'
|
import { MainAgent } from '../../src/agents/main/MainAgent.js'
|
||||||
import { ContextAssembler } from '../../src/context/ContextAssembler.js'
|
import { ContextAssembler } from '../../src/context/ContextAssembler.js'
|
||||||
|
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||||
|
|
||||||
function createRegistry(projectRoot: string): ToolRegistry {
|
function createRegistry(projectRoot: string): ToolRegistry {
|
||||||
const registry = new ToolRegistry(projectRoot)
|
const registry = new ToolRegistry(projectRoot)
|
||||||
@@ -59,8 +60,8 @@ describe('Release critical gates', () => {
|
|||||||
get_handle_for_task: () => undefined,
|
get_handle_for_task: () => undefined,
|
||||||
get_result_for_task: () => undefined,
|
get_result_for_task: () => undefined,
|
||||||
}
|
}
|
||||||
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any)
|
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
|
||||||
scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
|
await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
|
||||||
scheduler.get_graph().update_status('task-1' as any, 'running')
|
scheduler.get_graph().update_status('task-1' as any, 'running')
|
||||||
|
|
||||||
await scheduler.step()
|
await scheduler.step()
|
||||||
@@ -68,6 +69,34 @@ describe('Release critical gates', () => {
|
|||||||
expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0)
|
expect(scheduler.get_graph().get_tasks_by_status('completed').length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('scheduler surfaces blocked worker results as BLOCKED, not COMPLETED', async () => {
|
||||||
|
const workerManager = {
|
||||||
|
has_running: () => false,
|
||||||
|
get_handle_for_task: () => ({ worker_id: 'agent-task-1' }),
|
||||||
|
get_result_for_task: () => ({
|
||||||
|
task_id: 'task-1',
|
||||||
|
agent_id: 'agent-task-1',
|
||||||
|
agent_type: 'executor',
|
||||||
|
status: 'blocked',
|
||||||
|
summary: 'blocked by worker',
|
||||||
|
changed_files: [],
|
||||||
|
artifacts: [],
|
||||||
|
verification: [],
|
||||||
|
risks: [],
|
||||||
|
follow_up_tasks: [],
|
||||||
|
evidence_refs: [],
|
||||||
|
result: {},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const scheduler = new Scheduler({ session_id: 's' as any, project_id: 'p' as any, project_root: process.cwd() }, workerManager as any, createNullEventIngestor())
|
||||||
|
await scheduler.create_tasks([{ id: 'task-1' as any, type: 'execute', title: 'Task' }])
|
||||||
|
scheduler.get_graph().update_status('task-1' as any, 'running')
|
||||||
|
|
||||||
|
const finalState = await scheduler.run_until_idle()
|
||||||
|
expect(finalState).toBe('BLOCKED')
|
||||||
|
expect(scheduler.get_graph().get_tasks_by_status('blocked').length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('MainAgent answer mode uses assembled project context', async () => {
|
it('MainAgent answer mode uses assembled project context', async () => {
|
||||||
const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-'))
|
const projectRoot = mkdtempSync(join(tmpdir(), 'air-context-'))
|
||||||
writeFileSync(join(projectRoot, 'visible.txt'), 'visible')
|
writeFileSync(join(projectRoot, 'visible.txt'), 'visible')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { describe, it, expect } from 'bun:test'
|
import { describe, it, expect } from 'bun:test'
|
||||||
import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js'
|
import { Scheduler, type SchedulerState } from '../../src/scheduler/Scheduler.js'
|
||||||
|
import { createNullEventIngestor } from '../../src/events/EventIngestor.js'
|
||||||
|
|
||||||
describe('B1: Scheduler wire-up', () => {
|
describe('B1: Scheduler wire-up', () => {
|
||||||
it('SchedulerState includes BLOCKED and CANCELLED', () => {
|
it('SchedulerState includes BLOCKED and CANCELLED', () => {
|
||||||
@@ -54,10 +55,12 @@ describe('B1: Scheduler wire-up', () => {
|
|||||||
session_id: 'test-session' as any,
|
session_id: 'test-session' as any,
|
||||||
project_id: 'test-project' as any,
|
project_id: 'test-project' as any,
|
||||||
project_root: '/tmp/test'
|
project_root: '/tmp/test'
|
||||||
}
|
},
|
||||||
|
undefined,
|
||||||
|
createNullEventIngestor(),
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler.create_tasks([
|
await scheduler.create_tasks([
|
||||||
{ id: 't1' as any, type: 'code', title: 'Task 1' },
|
{ id: 't1' as any, type: 'code', title: 'Task 1' },
|
||||||
{ id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] }
|
{ id: 't2' as any, type: 'code', title: 'Task 2', depends_on: ['t1' as any] }
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ describe('A5+A4: ToolRegistry permission fixes', () => {
|
|||||||
expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/)
|
expect(build_match![0]).not.toMatch(/permission_profile:\s*undefined/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('permission denial branches preserve original call_id', () => {
|
it('permission branches preserve original call_id', () => {
|
||||||
expect(src).toContain("create_error_result(call.call_id, 'user_prompt_required'")
|
expect(src).toContain('permission.prompt.requested')
|
||||||
|
expect(src).toContain('request_ref: { call_id: call.call_id')
|
||||||
expect(src).toContain("create_error_result(call.call_id, 'permission_denied'")
|
expect(src).toContain("create_error_result(call.call_id, 'permission_denied'")
|
||||||
expect(src).not.toContain("create_error_result('', 'user_prompt_required'")
|
expect(src).not.toContain("create_error_result('', 'user_prompt_required'")
|
||||||
expect(src).not.toContain("create_error_result('', 'permission_denied'")
|
expect(src).not.toContain("create_error_result('', 'permission_denied'")
|
||||||
|
|||||||
@@ -45,9 +45,13 @@ describe('D3: Worker result envelope', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('wrap_worker_result maps role results into WorkerResult with safe defaults', () => {
|
it('wrap_worker_result maps role results into WorkerResult with safe defaults', () => {
|
||||||
expect(source).toContain("agent_type: (payload.agent_type as AgentType) || 'executor'")
|
expect(source).toContain('agent_type: (payload.agent_type as AgentType) || this.worker_agent_type(handle.config.agent_id)')
|
||||||
expect(source).toContain("const raw_status = (payload.status as string) || 'completed'")
|
expect(source).toContain("const raw_status = (payload.status as string) || 'completed'")
|
||||||
expect(source).toContain("raw_status === 'fixed' || raw_status === 'pass'")
|
expect(source).toContain("raw_status === 'fixed'")
|
||||||
|
expect(source).toContain("raw_status === 'pass'")
|
||||||
|
expect(source).toContain("raw_status === 'cannot_reproduce'")
|
||||||
|
expect(source).toContain("raw_status === 'compacted'")
|
||||||
|
expect(source).toContain("raw_status === 'no_patterns'")
|
||||||
expect(source).toContain('changes.map((c: any) => String(c.file))')
|
expect(source).toContain('changes.map((c: any) => String(c.file))')
|
||||||
expect(source).toContain("verification_payload ? [{ command: 'worker verification'")
|
expect(source).toContain("verification_payload ? [{ command: 'worker verification'")
|
||||||
expect(source).toContain('result: (payload.result as unknown) || payload')
|
expect(source).toContain('result: (payload.result as unknown) || payload')
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export class CppToolRegistrar {
|
|||||||
permissions: { read: true, write: false, network: false }, streaming: false
|
permissions: { read: true, write: false, network: false }, streaming: false
|
||||||
}, async (call) => {
|
}, async (call) => {
|
||||||
const result = detector.detect()
|
const result = detector.detect()
|
||||||
return { call_id: call.id, tool_name: 'cpp.detect', type: 'text', content: result, metadata: { timestamp: new Date().toISOString() } }
|
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.detect', output: result, metadata: { timestamp: new Date().toISOString() } }
|
||||||
})
|
})
|
||||||
|
|
||||||
// cpp.configure
|
// cpp.configure
|
||||||
@@ -47,7 +47,11 @@ export class CppToolRegistrar {
|
|||||||
permissions: { read: true, write: true, network: false }, streaming: false
|
permissions: { read: true, write: true, network: false }, streaming: false
|
||||||
}, async (call) => {
|
}, async (call) => {
|
||||||
const result = configurator.configure({ project_root, generator: call.arguments?.generator as any, build_type: call.arguments?.build_type as any })
|
const result = configurator.configure({ project_root, generator: call.arguments?.generator as any, build_type: call.arguments?.build_type as any })
|
||||||
return { call_id: call.id, tool_name: 'cpp.configure', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } }
|
if (result.ok) {
|
||||||
|
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.configure', output: result, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
} else {
|
||||||
|
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.configure', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.error || 'configure failed', retryability: 'not_retryable', semantic_signature: 'cpp.configure' }, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// cpp.build
|
// cpp.build
|
||||||
@@ -58,7 +62,11 @@ export class CppToolRegistrar {
|
|||||||
permissions: { read: true, write: true, network: false }, streaming: false
|
permissions: { read: true, write: true, network: false }, streaming: false
|
||||||
}, async (call) => {
|
}, async (call) => {
|
||||||
const result = builder.build(project_root + '/build', call.arguments?.target as string)
|
const result = builder.build(project_root + '/build', call.arguments?.target as string)
|
||||||
return { call_id: call.id, tool_name: 'cpp.build', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } }
|
if (result.ok) {
|
||||||
|
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.build', output: { built: true, output: result.output, diagnostics: result.diagnostics, elapsed_ms: result.elapsed_ms }, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
} else {
|
||||||
|
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.build', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'build failed', retryability: 'not_retryable', semantic_signature: 'cpp.build' }, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// cpp.test
|
// cpp.test
|
||||||
@@ -69,7 +77,11 @@ export class CppToolRegistrar {
|
|||||||
permissions: { read: true, write: false, network: false }, streaming: false
|
permissions: { read: true, write: false, network: false }, streaming: false
|
||||||
}, async (call) => {
|
}, async (call) => {
|
||||||
const result = tester.run_tests(project_root + '/build')
|
const result = tester.run_tests(project_root + '/build')
|
||||||
return { call_id: call.id, tool_name: 'cpp.test', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } }
|
if (result.ok) {
|
||||||
|
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.test', output: result, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
} else {
|
||||||
|
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.test', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'test failed', retryability: 'not_retryable', semantic_signature: 'cpp.test' }, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// cpp.cppcheck
|
// cpp.cppcheck
|
||||||
@@ -80,7 +92,11 @@ export class CppToolRegistrar {
|
|||||||
permissions: { read: true, write: false, network: false }, streaming: false
|
permissions: { read: true, write: false, network: false }, streaming: false
|
||||||
}, async (call) => {
|
}, async (call) => {
|
||||||
const result = cppcheck.run(project_root, { enable_all: call.arguments?.enable_all as boolean, check_config: call.arguments?.check_config as boolean })
|
const result = cppcheck.run(project_root, { enable_all: call.arguments?.enable_all as boolean, check_config: call.arguments?.check_config as boolean })
|
||||||
return { call_id: call.id, tool_name: 'cpp.cppcheck', type: result.ok ? 'text' : 'error', content: result, metadata: { timestamp: new Date().toISOString() } }
|
if (result.ok) {
|
||||||
|
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.cppcheck', output: result, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
} else {
|
||||||
|
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.cppcheck', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.output || 'cppcheck failed', retryability: 'not_retryable', semantic_signature: 'cpp.cppcheck' }, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// cpp.clangd
|
// cpp.clangd
|
||||||
@@ -91,7 +107,11 @@ export class CppToolRegistrar {
|
|||||||
permissions: { read: true, write: false, network: false }, streaming: false
|
permissions: { read: true, write: false, network: false }, streaming: false
|
||||||
}, async (call) => {
|
}, async (call) => {
|
||||||
const result = await clangd.query_symbol(call.arguments?.file as string, call.arguments?.line as number, call.arguments?.column as number)
|
const result = await clangd.query_symbol(call.arguments?.file as string, call.arguments?.line as number, call.arguments?.column as number)
|
||||||
return { call_id: call.id, tool_name: 'cpp.clangd', type: 'text', content: result, metadata: { timestamp: new Date().toISOString() } }
|
if (result.ok) {
|
||||||
|
return { status: 'ok', call_id: call.call_id, tool_name: 'cpp.clangd', output: result, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
} else {
|
||||||
|
return { status: 'error', call_id: call.call_id, tool_name: 'cpp.clangd', error: { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: result.error || 'clangd query failed', retryability: 'not_retryable', semantic_signature: 'cpp.clangd' }, metadata: { timestamp: new Date().toISOString() } }
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./src/index.ts",
|
"types": "./src/index.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts",
|
||||||
|
"./preload": "./src/preload.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -15,7 +16,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aircoding/contracts": "workspace:*",
|
"@aircoding/contracts": "workspace:*",
|
||||||
"@aircoding/runtime": "workspace:*"
|
"@opentui/core": "0.3.0",
|
||||||
|
"@opentui/solid": "0.3.0",
|
||||||
|
"solid-js": "1.9.10"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^25.9.1",
|
||||||
|
|||||||
@@ -1,53 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* ProjectionClient - Local TUI-side projection consumer
|
* ProjectionClient - Local TUI-side projection consumer.
|
||||||
* TUI copies minimal projection types from contracts to avoid INV-4 violation
|
* TUI keeps a contracts-only copy of the ProjectionClient surface so it never imports runtime.
|
||||||
* (TUI must only depend on contracts; dd §13.2 / c4/code-view §2 rule 3).
|
|
||||||
*
|
|
||||||
* The runtime package provides the authoritative ProjectionClient in
|
|
||||||
* `runtime/projection/ProjectionClient.ts`. TUI defines its own local copy
|
|
||||||
* with the same surface so that subscriptions work in-process.
|
|
||||||
*
|
*
|
||||||
* @module packages/tui/src/ProjectionClient
|
* @module packages/tui/src/ProjectionClient
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SessionID, ProjectID, TaskID, AgentID, ISOTimeString } from '@aircoding/contracts'
|
import type { ProjectionSubscriber, SessionProjection } from './types.js'
|
||||||
|
|
||||||
export interface SessionProjection {
|
export type {
|
||||||
session_id: SessionID
|
SessionProjection,
|
||||||
project_id: ProjectID
|
TaskProjection,
|
||||||
status: string
|
AgentProjection,
|
||||||
title?: string
|
ToolRunProjection,
|
||||||
tasks: TaskProjection[]
|
CommandRunProjection,
|
||||||
agents: AgentProjection[]
|
ArtifactProjection,
|
||||||
}
|
PermissionPromptProjection,
|
||||||
|
BlockerProjection,
|
||||||
export interface TaskProjection {
|
ProjectionSubscriber,
|
||||||
id: TaskID
|
} from './types.js'
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
title: string
|
|
||||||
retry_count: number
|
|
||||||
attempts: number
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentProjection {
|
|
||||||
id: AgentID
|
|
||||||
type: string
|
|
||||||
status: string
|
|
||||||
task_id?: TaskID
|
|
||||||
last_heartbeat?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ProjectionSubscriber = (projection: SessionProjection) => void
|
|
||||||
|
|
||||||
export class ProjectionClient {
|
export class ProjectionClient {
|
||||||
private snapshot: SessionProjection | null = null
|
private snapshot: SessionProjection | null = null
|
||||||
private subscribers: Set<ProjectionSubscriber> = new Set()
|
private subscribers: Set<ProjectionSubscriber> = new Set()
|
||||||
|
|
||||||
/**
|
|
||||||
* Receive and cache a projection snapshot.
|
|
||||||
*/
|
|
||||||
receive_snapshot(projection: SessionProjection): void {
|
receive_snapshot(projection: SessionProjection): void {
|
||||||
this.snapshot = projection
|
this.snapshot = projection
|
||||||
for (const sub of this.subscribers) {
|
for (const sub of this.subscribers) {
|
||||||
@@ -55,17 +30,11 @@ export class ProjectionClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to projection updates.
|
|
||||||
*/
|
|
||||||
subscribe(subscriber: ProjectionSubscriber): () => void {
|
subscribe(subscriber: ProjectionSubscriber): () => void {
|
||||||
this.subscribers.add(subscriber)
|
this.subscribers.add(subscriber)
|
||||||
return () => this.subscribers.delete(subscriber)
|
return () => this.subscribers.delete(subscriber)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current snapshot.
|
|
||||||
*/
|
|
||||||
get_snapshot(): SessionProjection | null {
|
get_snapshot(): SessionProjection | null {
|
||||||
return this.snapshot
|
return this.snapshot
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,311 +1,712 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
/**
|
/**
|
||||||
* TuiApp - Main TUI application shell
|
* TuiApp - OpenTUI/Solid application shell
|
||||||
* DD §13.2. Real terminal rendering using ANSI escape codes.
|
* DD §13.2. Projection-only display plus single OpenTUI textarea input owner.
|
||||||
*
|
*
|
||||||
* @module packages/tui/src/TuiApp
|
* @module packages/tui/src/TuiApp
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { SessionView } from './components/SessionView.js'
|
import { createCliRenderer, type CliRenderer, type TextareaRenderable, type KeyEvent } from '@opentui/core'
|
||||||
import { TaskListView } from './components/TaskListView.js'
|
import { render, useRenderer, useTerminalDimensions } from '@opentui/solid'
|
||||||
import { AgentStatusView } from './components/AgentStatusView.js'
|
import { createEffect, createSignal, For, onCleanup, onMount, Show } from 'solid-js'
|
||||||
import { HudView } from './components/HudView.js'
|
import type { SessionProjection } from './types.js'
|
||||||
import type { SessionProjection } from './ProjectionClient.js'
|
|
||||||
|
|
||||||
export interface TuiAppProps {
|
export interface TuiAppProps {
|
||||||
client: {
|
client: {
|
||||||
subscribe(handler: (projection: SessionProjection) => void): () => void
|
subscribe(handler: (projection: SessionProjection) => void): () => void
|
||||||
receive_snapshot(projection: SessionProjection): void
|
|
||||||
get_snapshot(): SessionProjection | null
|
get_snapshot(): SessionProjection | null
|
||||||
}
|
}
|
||||||
|
onSubmit?: (input: string) => void | Promise<void>
|
||||||
|
onSlashCommand?: (input: string) => void | Promise<void>
|
||||||
|
onResolvePermission?: (prompt_id: string, selected_option: string) => void | Promise<void>
|
||||||
|
onExit?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TuiAppState {
|
export interface TuiAppState {
|
||||||
projection: SessionProjection | null
|
projection: SessionProjection | null
|
||||||
active_view: 'tasks' | 'agents' | 'tools' | 'diff' | 'help'
|
active_view: 'tasks' | 'agents' | 'tools' | 'diff' | 'help'
|
||||||
|
busy: boolean
|
||||||
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANSI = {
|
const THEME = {
|
||||||
reset: '\x1b[0m',
|
bg: '#0b0f14',
|
||||||
bright: '\x1b[1m',
|
surface: '#111827',
|
||||||
dim: '\x1b[2m',
|
surface2: '#1f2937',
|
||||||
underscore: '\x1b[4m',
|
text: '#e5e7eb',
|
||||||
blink: '\x1b[5m',
|
muted: '#9ca3af',
|
||||||
reverse: '\x1b[7m',
|
faint: '#6b7280',
|
||||||
hidden: '\x1b[8m',
|
accent: '#22d3ee',
|
||||||
fg: {
|
success: '#22c55e',
|
||||||
black: '\x1b[30m',
|
warning: '#f59e0b',
|
||||||
red: '\x1b[31m',
|
error: '#ef4444',
|
||||||
green: '\x1b[32m',
|
border: '#374151',
|
||||||
yellow: '\x1b[33m',
|
}
|
||||||
blue: '\x1b[34m',
|
|
||||||
magenta: '\x1b[35m',
|
const PROMPT_HISTORY_LIMIT = 200
|
||||||
cyan: '\x1b[36m',
|
const TEXTAREA_MIN_ROWS = 1
|
||||||
white: '\x1b[37m',
|
const TEXTAREA_MAX_ROWS = 6
|
||||||
gray: '\x1b[90m',
|
const EXIT_CONFIRM_MS = 5000
|
||||||
},
|
|
||||||
bg: {
|
type PromptHistoryState = {
|
||||||
black: '\x1b[40m',
|
items: string[]
|
||||||
red: '\x1b[41m',
|
index: number | null
|
||||||
green: '\x1b[42m',
|
draft: string
|
||||||
yellow: '\x1b[43m',
|
}
|
||||||
blue: '\x1b[44m',
|
|
||||||
magenta: '\x1b[45m',
|
type PromptHistoryMove = {
|
||||||
cyan: '\x1b[46m',
|
state: PromptHistoryState
|
||||||
white: '\x1b[47m',
|
apply: boolean
|
||||||
},
|
text?: string
|
||||||
clear: '\x1b[2J\x1b[H',
|
cursor?: number
|
||||||
clearLine: '\x1b[2K',
|
}
|
||||||
cursor: {
|
|
||||||
home: '\x1b[H',
|
function createPromptHistory(): PromptHistoryState {
|
||||||
save: '\x1b[s',
|
return { items: [], index: null, draft: '' }
|
||||||
restore: '\x1b[u',
|
}
|
||||||
hide: '\x1b[?25l',
|
|
||||||
show: '\x1b[?25h',
|
function pushPromptHistory(state: PromptHistoryState, prompt: string): PromptHistoryState {
|
||||||
up: (n = 1) => `\x1b[${n}A`,
|
const text = prompt.trim()
|
||||||
down: (n = 1) => `\x1b[${n}B`,
|
if (!text) return state
|
||||||
right: (n = 1) => `\x1b[${n}C`,
|
if (state.items[state.items.length - 1] === text) {
|
||||||
left: (n = 1) => `\x1b[${n}D`,
|
return { ...state, index: null, draft: '' }
|
||||||
}
|
}
|
||||||
|
return { items: [...state.items, text].slice(-PROMPT_HISTORY_LIMIT), index: null, draft: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePromptHistory(state: PromptHistoryState, dir: -1 | 1, text: string, cursor: number): PromptHistoryMove {
|
||||||
|
if (state.items.length === 0) return { state, apply: false }
|
||||||
|
if (dir === -1 && cursor !== 0) return { state, apply: false }
|
||||||
|
if (dir === 1 && cursor !== text.length) return { state, apply: false }
|
||||||
|
|
||||||
|
if (state.index === null) {
|
||||||
|
if (dir === 1) return { state, apply: false }
|
||||||
|
const idx = state.items.length - 1
|
||||||
|
return { state: { ...state, index: idx, draft: text }, text: state.items[idx], cursor: 0, apply: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = state.index + dir
|
||||||
|
if (idx < 0) return { state, apply: false }
|
||||||
|
if (idx >= state.items.length) {
|
||||||
|
return { state: { ...state, index: null }, text: state.draft, cursor: state.draft.length, apply: true }
|
||||||
|
}
|
||||||
|
return { state: { ...state, index: idx }, text: state.items[idx], cursor: dir === -1 ? 0 : state.items[idx].length, apply: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TuiApp {
|
export class TuiApp {
|
||||||
private client: TuiAppProps['client']
|
private client: TuiAppProps['client']
|
||||||
private state: TuiAppState
|
private onSubmit?: TuiAppProps['onSubmit']
|
||||||
private unsubscribe: (() => void) | null = null
|
private onSlashCommand?: TuiAppProps['onSlashCommand']
|
||||||
private running: boolean = false
|
private onResolvePermission?: TuiAppProps['onResolvePermission']
|
||||||
|
private onExit?: TuiAppProps['onExit']
|
||||||
|
private renderer: CliRenderer | null = null
|
||||||
|
private unsubscribeClient: (() => void) | null = null
|
||||||
|
private setProjection?: (projection: SessionProjection | null) => void
|
||||||
|
private setView?: (view: TuiAppState['active_view']) => void
|
||||||
|
private setBusy?: (busy: boolean) => void
|
||||||
|
private setStatus?: (status: string) => void
|
||||||
|
|
||||||
constructor(props: TuiAppProps) {
|
constructor(props: TuiAppProps) {
|
||||||
this.client = props.client
|
this.client = props.client
|
||||||
this.state = { projection: null, active_view: 'tasks' }
|
this.onSubmit = props.onSubmit
|
||||||
|
this.onSlashCommand = props.onSlashCommand
|
||||||
|
this.onResolvePermission = props.onResolvePermission
|
||||||
|
this.onExit = props.onExit
|
||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
this.unsubscribe = this.client.subscribe((projection) => {
|
if (this.renderer) return
|
||||||
this.state.projection = projection
|
|
||||||
this.render()
|
this.renderer = await createCliRenderer({
|
||||||
|
targetFps: 30,
|
||||||
|
maxFps: 60,
|
||||||
|
useMouse: false,
|
||||||
|
autoFocus: false,
|
||||||
|
exitOnCtrlC: false,
|
||||||
|
screenMode: 'alternate-screen',
|
||||||
|
externalOutputMode: 'capture-stdout',
|
||||||
|
consoleMode: 'disabled',
|
||||||
|
clearOnShutdown: true,
|
||||||
|
openConsoleOnError: false,
|
||||||
|
useKittyKeyboard: {},
|
||||||
|
backgroundColor: THEME.bg,
|
||||||
})
|
})
|
||||||
|
this.renderer.setBackgroundColor(THEME.bg)
|
||||||
|
|
||||||
const snapshot = this.client.get_snapshot()
|
await render(() => (
|
||||||
if (snapshot) {
|
<AirCodingView
|
||||||
this.state.projection = snapshot
|
initialProjection={this.client.get_snapshot()}
|
||||||
}
|
bindState={(bindings) => {
|
||||||
|
this.setProjection = bindings.setProjection
|
||||||
|
this.setView = bindings.setView
|
||||||
|
this.setBusy = bindings.setBusy
|
||||||
|
this.setStatus = bindings.setStatus
|
||||||
|
}}
|
||||||
|
onSubmit={(input) => this.submit(input)}
|
||||||
|
onSlashCommand={(input) => this.slash(input)}
|
||||||
|
onResolvePermission={(prompt_id, selected_option) => this.resolvePermission(prompt_id, selected_option)}
|
||||||
|
onExit={() => this.exit()}
|
||||||
|
/>
|
||||||
|
), this.renderer)
|
||||||
|
|
||||||
this.running = true
|
this.unsubscribeClient = this.client.subscribe((projection) => {
|
||||||
this.setup_input()
|
this.setProjection?.(projection)
|
||||||
this.render()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.running = false
|
this.unsubscribeClient?.()
|
||||||
this.unsubscribe?.()
|
this.unsubscribeClient = null
|
||||||
this.unsubscribe = null
|
this.setProjection = undefined
|
||||||
process.stdout.write(ANSI.cursor.show + ANSI.reset)
|
this.setView = undefined
|
||||||
|
this.setBusy = undefined
|
||||||
|
this.setStatus = undefined
|
||||||
|
|
||||||
|
if (this.renderer && !this.renderer.isDestroyed) {
|
||||||
|
this.renderer.setTerminalTitle('')
|
||||||
|
this.renderer.externalOutputMode = 'passthrough'
|
||||||
|
this.renderer.destroy()
|
||||||
|
}
|
||||||
|
this.renderer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
set_view(view: TuiAppState['active_view']): void {
|
set_view(view: TuiAppState['active_view']): void {
|
||||||
this.state.active_view = view
|
this.setView?.(view)
|
||||||
this.render()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private setup_input(): void {
|
set_busy(busy: boolean, status?: string): void {
|
||||||
if (process.stdin.isTTY) {
|
this.setBusy?.(busy)
|
||||||
process.stdin.setRawMode(true)
|
if (status) this.setStatus?.(status)
|
||||||
process.stdin.resume()
|
}
|
||||||
process.stdin.setEncoding('utf8')
|
|
||||||
|
|
||||||
process.stdin.on('data', (key: string) => {
|
set_status(status: string): void {
|
||||||
this.handle_input(key)
|
this.setStatus?.(status)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
private async submit(input: string): Promise<void> {
|
||||||
|
const text = input.trim()
|
||||||
|
if (!text) return
|
||||||
|
this.setBusy?.(true)
|
||||||
|
this.setStatus?.(`Running: ${text.slice(0, 72)}`)
|
||||||
|
try {
|
||||||
|
await this.onSubmit?.(text)
|
||||||
|
this.setStatus?.('Ready')
|
||||||
|
} catch (error) {
|
||||||
|
this.setStatus?.(error instanceof Error ? error.message : String(error))
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
this.setBusy?.(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private handle_input(key: string): void {
|
private async slash(input: string): Promise<void> {
|
||||||
switch (key) {
|
const text = input.trim()
|
||||||
case 'q':
|
if (!text) return
|
||||||
case '': // Ctrl+C
|
if (text === '/quit' || text === '/exit') {
|
||||||
this.stop()
|
await this.exit()
|
||||||
process.exit(0)
|
return
|
||||||
break
|
|
||||||
case '1':
|
|
||||||
this.set_view('tasks')
|
|
||||||
break
|
|
||||||
case '2':
|
|
||||||
this.set_view('agents')
|
|
||||||
break
|
|
||||||
case '3':
|
|
||||||
this.set_view('tools')
|
|
||||||
break
|
|
||||||
case '4':
|
|
||||||
this.set_view('diff')
|
|
||||||
break
|
|
||||||
case '?':
|
|
||||||
case 'h':
|
|
||||||
this.set_view('help')
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
this.setStatus?.(`Command: ${text}`)
|
||||||
|
await this.onSlashCommand?.(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
private render(): void {
|
private async resolvePermission(prompt_id: string, selected_option: string): Promise<void> {
|
||||||
if (!this.running) return
|
if (!this.onResolvePermission) {
|
||||||
|
this.setStatus?.('Permission selection requires runtime resolver')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.setStatus?.(`Permission: ${selected_option}`)
|
||||||
|
await this.onResolvePermission(prompt_id, selected_option)
|
||||||
|
}
|
||||||
|
|
||||||
const p = this.state.projection
|
private async exit(): Promise<void> {
|
||||||
const lines: string[] = []
|
await this.onExit?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Header
|
type StateBindings = {
|
||||||
lines.push(ANSI.clear)
|
setProjection: (projection: SessionProjection | null) => void
|
||||||
lines.push(ANSI.fg.cyan + ANSI.bright + '═══════════════════════════════════════════════════════════════' + ANSI.reset)
|
setView: (view: TuiAppState['active_view']) => void
|
||||||
lines.push(ANSI.fg.cyan + ANSI.bright + ' AirCoding v1.0.0-alpha' + ANSI.reset + ANSI.fg.gray + ' │ ' + (p ? `${p.tasks.length} tasks` : 'No session') + ' │ ' + this.get_status_indicator(p) + ANSI.reset)
|
setBusy: (busy: boolean) => void
|
||||||
lines.push(ANSI.fg.cyan + '═══════════════════════════════════════════════════════════════' + ANSI.reset)
|
setStatus: (status: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation hints
|
type FooterPhase = 'idle' | 'running' | 'permission' | 'confirm_exit' | 'error'
|
||||||
lines.push(ANSI.fg.gray + ' [1]Tasks [2]Agents [3]Tools [4]Diff [h]Help [q]Quit' + ANSI.reset)
|
|
||||||
|
|
||||||
// Content area
|
function AirCodingView(props: {
|
||||||
lines.push('')
|
initialProjection: SessionProjection | null
|
||||||
|
bindState: (bindings: StateBindings) => void
|
||||||
|
onSubmit: (input: string) => void | Promise<void>
|
||||||
|
onSlashCommand: (input: string) => void | Promise<void>
|
||||||
|
onResolvePermission: (prompt_id: string, selected_option: string) => void | Promise<void>
|
||||||
|
onExit: () => void | Promise<void>
|
||||||
|
}) {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const [projection, setProjection] = createSignal<SessionProjection | null>(props.initialProjection)
|
||||||
|
const [activeView, setActiveView] = createSignal<TuiAppState['active_view']>('tasks')
|
||||||
|
const [busy, setBusy] = createSignal(false)
|
||||||
|
const [status, setStatus] = createSignal('Ready')
|
||||||
|
const [footerPhase, setFooterPhase] = createSignal<FooterPhase>('idle')
|
||||||
|
const [toast, setToast] = createSignal('')
|
||||||
|
let textarea: TextareaRenderable | undefined
|
||||||
|
let history = createPromptHistory()
|
||||||
|
let pasteTick: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let exitConfirmUntil = 0
|
||||||
|
|
||||||
if (this.state.active_view === 'help') {
|
props.bindState({
|
||||||
lines.push(...this.render_help())
|
setProjection,
|
||||||
} else if (!p) {
|
setView: setActiveView,
|
||||||
lines.push(ANSI.fg.yellow + ' No active session. Run "air init" then "air run".' + ANSI.reset)
|
setBusy: (next) => {
|
||||||
lines.push('')
|
setBusy(next)
|
||||||
lines.push(ANSI.fg.gray + ' Press q to exit.' + ANSI.reset)
|
setFooterPhase(next ? 'running' : 'idle')
|
||||||
|
},
|
||||||
|
setStatus: (next) => {
|
||||||
|
setStatus(next)
|
||||||
|
if (/error|failed|blocked|失败|错误/i.test(next)) setFooterPhase('error')
|
||||||
|
else if (!busy()) setFooterPhase('idle')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const focusPrompt = () => {
|
||||||
|
if (textarea && !textarea.isDestroyed) textarea.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitPrompt = () => {
|
||||||
|
if (!textarea || textarea.isDestroyed || busy()) return
|
||||||
|
const text = textarea.plainText.trim()
|
||||||
|
if (!text) return
|
||||||
|
history = pushPromptHistory(history, text)
|
||||||
|
textarea.setText('')
|
||||||
|
exitConfirmUntil = 0
|
||||||
|
setFooterPhase('running')
|
||||||
|
setStatus(text.startsWith('/') ? `Command: ${text}` : `Sending: ${text.slice(0, 72)}`)
|
||||||
|
if (text.startsWith('/')) {
|
||||||
|
void props.onSlashCommand(text)
|
||||||
} else {
|
} else {
|
||||||
switch (this.state.active_view) {
|
void props.onSubmit(text)
|
||||||
case 'tasks':
|
}
|
||||||
lines.push(...this.render_tasks(p))
|
focusPrompt()
|
||||||
break
|
}
|
||||||
case 'agents':
|
|
||||||
lines.push(...this.render_agents(p))
|
const refreshPasteLayout = () => {
|
||||||
break
|
if (pasteTick) clearTimeout(pasteTick)
|
||||||
case 'tools':
|
pasteTick = setTimeout(() => {
|
||||||
lines.push(...this.render_tools(p))
|
pasteTick = undefined
|
||||||
break
|
if (!textarea || textarea.isDestroyed) return
|
||||||
case 'diff':
|
textarea.getLayoutNode().markDirty()
|
||||||
lines.push(...this.render_diff(p))
|
renderer.requestRender()
|
||||||
break
|
void renderer.idle().then(() => renderer.requestRender()).catch(() => {})
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyHistoryMove = (dir: -1 | 1) => {
|
||||||
|
if (!textarea || textarea.isDestroyed) return false
|
||||||
|
const text = textarea.plainText
|
||||||
|
const move = movePromptHistory(history, dir, text, textarea.cursorOffset)
|
||||||
|
history = move.state
|
||||||
|
if (!move.apply) return false
|
||||||
|
textarea.setText(move.text ?? '')
|
||||||
|
textarea.cursorOffset = move.cursor ?? 0
|
||||||
|
textarea.getLayoutNode().markDirty()
|
||||||
|
renderer.requestRender()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const activePermission = () => projection()?.permission_prompts[0]
|
||||||
|
|
||||||
|
const resolvePermissionByIndex = (index: number) => {
|
||||||
|
const prompt = activePermission()
|
||||||
|
if (!prompt) return false
|
||||||
|
const options = prompt.options.length > 0 ? prompt.options : ['allow', 'deny']
|
||||||
|
const selected = options[index]
|
||||||
|
if (!selected) return false
|
||||||
|
setFooterPhase('permission')
|
||||||
|
setStatus(`Permission: ${selected}`)
|
||||||
|
void props.onResolvePermission(prompt.prompt_id, selected)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyEvent) => {
|
||||||
|
const prompt = activePermission()
|
||||||
|
if (prompt) {
|
||||||
|
if (event.name === 'left' || event.name === 'h') {
|
||||||
|
event.preventDefault()
|
||||||
|
setToast('Use 1/2/3 to choose a permission option')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (/^[1-9]$/.test(event.name) && resolvePermissionByIndex(Number(event.name) - 1)) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((event.name === 'a' || event.name === 'y') && resolvePermissionByIndex(0)) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ((event.name === 'd' || event.name === 'n') && resolvePermissionByIndex(Math.min(1, (prompt.options.length || 2) - 1))) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer
|
if (event.ctrl && event.name === 'c') {
|
||||||
lines.push('')
|
event.preventDefault()
|
||||||
lines.push(ANSI.fg.gray + '─'.repeat(76) + ANSI.reset)
|
if (textarea && !textarea.isDestroyed && textarea.plainText.length > 0) {
|
||||||
lines.push(ANSI.fg.gray + ' Status: ' + this.get_status_text(p) + ' │ Session: ' + (p?.session_id || 'N/A') + ANSI.reset)
|
textarea.setText('')
|
||||||
|
history = { ...history, index: null, draft: '' }
|
||||||
|
setFooterPhase('idle')
|
||||||
|
setStatus('Draft cleared; press Ctrl+C again to exit')
|
||||||
|
focusPrompt()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
if (now < exitConfirmUntil) {
|
||||||
|
void props.onExit()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exitConfirmUntil = now + EXIT_CONFIRM_MS
|
||||||
|
setFooterPhase('confirm_exit')
|
||||||
|
setStatus('Press Ctrl+C again within 5s to exit')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
process.stdout.write(lines.join('\n') + '\n')
|
if (event.name === 'up' && applyHistoryMove(-1)) {
|
||||||
}
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
private get_status_indicator(p: SessionProjection | null): string {
|
if (event.name === 'down' && applyHistoryMove(1)) {
|
||||||
if (!p) return ANSI.fg.gray + 'IDLE' + ANSI.reset
|
event.preventDefault()
|
||||||
switch (p.status) {
|
return
|
||||||
case 'running': return ANSI.fg.green + '● RUNNING' + ANSI.reset
|
}
|
||||||
case 'completed': return ANSI.fg.blue + '● COMPLETED' + ANSI.reset
|
|
||||||
case 'error': return ANSI.fg.red + '● ERROR' + ANSI.reset
|
if (event.name === 'escape') {
|
||||||
default: return ANSI.fg.gray + '● ' + p.status.toUpperCase() + ANSI.reset
|
event.preventDefault()
|
||||||
|
exitConfirmUntil = 0
|
||||||
|
setActiveView('tasks')
|
||||||
|
setFooterPhase(busy() ? 'running' : activePermission() ? 'permission' : 'idle')
|
||||||
|
focusPrompt()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.ctrl && event.name === 'l') {
|
||||||
|
event.preventDefault()
|
||||||
|
renderer.requestRender()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.ctrl || event.meta) return
|
||||||
|
|
||||||
|
const next = shortcutToView(event.name)
|
||||||
|
if (next) {
|
||||||
|
event.preventDefault()
|
||||||
|
setActiveView(next)
|
||||||
|
focusPrompt()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get_status_text(p: SessionProjection | null): string {
|
onMount(() => {
|
||||||
if (!p) return 'No session'
|
renderer.setTerminalTitle('AirCoding')
|
||||||
return `${p.status} | ${p.tasks.length} tasks | ${p.agents.length} agents`
|
focusPrompt()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
if (pasteTick) clearTimeout(pasteTick)
|
||||||
|
renderer.setTerminalTitle('')
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const hasPermission = (projection()?.permission_prompts.length ?? 0) > 0
|
||||||
|
if (hasPermission) setFooterPhase('permission')
|
||||||
|
else if (!busy() && footerPhase() === 'permission') setFooterPhase('idle')
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
projection()
|
||||||
|
activeView()
|
||||||
|
busy()
|
||||||
|
status()
|
||||||
|
footerPhase()
|
||||||
|
toast()
|
||||||
|
renderer.requestRender()
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box width="100%" height="100%" flexDirection="column" backgroundColor={THEME.bg}>
|
||||||
|
<Header projection={projection()} />
|
||||||
|
<Nav active={activeView()} />
|
||||||
|
<box flexGrow={1} flexShrink={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||||
|
<Content projection={projection()} active={activeView()} height={Math.max(8, dimensions().height - 11)} />
|
||||||
|
</box>
|
||||||
|
<Prompt
|
||||||
|
busy={busy()}
|
||||||
|
phase={footerPhase()}
|
||||||
|
status={status()}
|
||||||
|
toast={toast()}
|
||||||
|
permission={activePermission()}
|
||||||
|
textareaRef={(area) => { textarea = area }}
|
||||||
|
onSubmit={submitPrompt}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={refreshPasteLayout}
|
||||||
|
onContentChange={() => renderer.requestRender()}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Header(props: { projection: SessionProjection | null }) {
|
||||||
|
const taskCount = () => props.projection?.tasks.length ?? 0
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingTop={1} backgroundColor={THEME.surface}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text fg={THEME.accent}>AirCoding v1.0.0-alpha</text>
|
||||||
|
<text fg={statusColor(props.projection?.status)}>{(props.projection?.status ?? 'idle').toUpperCase()}</text>
|
||||||
|
</box>
|
||||||
|
<text fg={THEME.muted}>{props.projection?.title ?? 'No active session'} · {taskCount()} tasks · {props.projection?.agents.length ?? 0} agents</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Nav(props: { active: TuiAppState['active_view'] }) {
|
||||||
|
const items: Array<[TuiAppState['active_view'], string]> = [
|
||||||
|
['tasks', 'Ctrl+1 Tasks'],
|
||||||
|
['agents', 'Ctrl+2 Agents'],
|
||||||
|
['tools', 'Ctrl+3 Tools'],
|
||||||
|
['diff', 'Ctrl+4 Diff'],
|
||||||
|
['help', 'Ctrl+H Help'],
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<box flexDirection="row" gap={1} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} backgroundColor={THEME.surface2}>
|
||||||
|
<For each={items}>{([view, label]) => (
|
||||||
|
<text fg={props.active === view ? THEME.accent : THEME.muted}>{label}</text>
|
||||||
|
)}</For>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Content(props: { projection: SessionProjection | null; active: TuiAppState['active_view']; height: number }) {
|
||||||
|
const currentProjection = () => props.projection
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={currentProjection()} fallback={<EmptySession />}>
|
||||||
|
<box flexDirection="column" gap={1} height={props.height}>
|
||||||
|
<Show when={props.active === 'tasks'}>
|
||||||
|
<TasksView projection={currentProjection()!} />
|
||||||
|
</Show>
|
||||||
|
<Show when={props.active === 'agents'}>
|
||||||
|
<AgentsView projection={currentProjection()!} />
|
||||||
|
</Show>
|
||||||
|
<Show when={props.active === 'tools'}>
|
||||||
|
<ToolsView projection={currentProjection()!} />
|
||||||
|
</Show>
|
||||||
|
<Show when={props.active === 'diff'}>
|
||||||
|
<DiffView projection={currentProjection()!} />
|
||||||
|
</Show>
|
||||||
|
<Show when={props.active === 'help'}>
|
||||||
|
<HelpView />
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptySession() {
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={THEME.warning}>No active session projection.</text>
|
||||||
|
<text fg={THEME.muted}>Run air init and air run from a project directory.</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksView(props: { projection: SessionProjection }) {
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={THEME.accent}>Tasks</text>
|
||||||
|
<Show when={props.projection.tasks.length > 0} fallback={<text fg={THEME.muted}>No tasks yet.</text>}>
|
||||||
|
<For each={props.projection.tasks.slice(0, 14)}>{(task) => (
|
||||||
|
<box flexDirection="column">
|
||||||
|
<text fg={statusColor(task.status)}>{statusMark(task.status)} {task.title || task.id}</text>
|
||||||
|
<text fg={THEME.faint}> {task.id} · {task.type} · {task.status} · attempts {task.attempts}</text>
|
||||||
|
</box>
|
||||||
|
)}</For>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentsView(props: { projection: SessionProjection }) {
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={THEME.accent}>Agents</text>
|
||||||
|
<Show when={props.projection.agents.length > 0} fallback={<text fg={THEME.muted}>No agents yet.</text>}>
|
||||||
|
<For each={props.projection.agents.slice(0, 14)}>{(agent) => (
|
||||||
|
<box flexDirection="column">
|
||||||
|
<text fg={statusColor(agent.status)}>{statusMark(agent.status)} {agent.type}</text>
|
||||||
|
<text fg={THEME.faint}> {agent.id} · {agent.status}{agent.task_id ? ` · task ${agent.task_id}` : ''}</text>
|
||||||
|
</box>
|
||||||
|
)}</For>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolsView(props: { projection: SessionProjection }) {
|
||||||
|
const p = () => props.projection as SessionProjection & { tool_runs?: Array<{ tool_run_id: string; tool_name: string; status: string; duration_ms?: number }> }
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={THEME.accent}>Tool runs</text>
|
||||||
|
<Show when={(p().tool_runs ?? []).length > 0} fallback={<text fg={THEME.muted}>No tool runs yet.</text>}>
|
||||||
|
<For each={(p().tool_runs ?? []).slice(-14).reverse()}>{(tool) => (
|
||||||
|
<box flexDirection="row" gap={1}>
|
||||||
|
<text fg={statusColor(tool.status)}>{statusMark(tool.status)}</text>
|
||||||
|
<text fg={THEME.text}>{tool.tool_name}</text>
|
||||||
|
<text fg={THEME.faint}>{tool.status}{tool.duration_ms ? ` · ${tool.duration_ms}ms` : ''}</text>
|
||||||
|
</box>
|
||||||
|
)}</For>
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffView(props: { projection: SessionProjection }) {
|
||||||
|
const completed = () => props.projection.tasks.filter((task) => task.status === 'completed').length
|
||||||
|
const failed = () => props.projection.tasks.filter((task) => task.status === 'failed').length
|
||||||
|
const blocked = () => props.projection.tasks.filter((task) => task.status === 'blocked').length
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={THEME.accent}>Session summary</text>
|
||||||
|
<text fg={THEME.success}>Completed: {completed()}</text>
|
||||||
|
<text fg={THEME.error}>Failed: {failed()}</text>
|
||||||
|
<text fg={THEME.warning}>Blocked: {blocked()}</text>
|
||||||
|
<text fg={THEME.muted}>Use /results for produced files. Projection data is sourced from runtime events.</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpView() {
|
||||||
|
return (
|
||||||
|
<box flexDirection="column" gap={1}>
|
||||||
|
<text fg={THEME.accent}>Help</text>
|
||||||
|
<text fg={THEME.text}>Type a task in the prompt and press Enter.</text>
|
||||||
|
<text fg={THEME.text}>Slash commands: /help, /status, /tools, /tasks, /results, /quit.</text>
|
||||||
|
<text fg={THEME.text}>Navigation: Ctrl+1 tasks, Ctrl+2 agents, Ctrl+3 tools, Ctrl+4 diff, Ctrl+H help, Esc tasks.</text>
|
||||||
|
<text fg={THEME.text}>Prompt: Up/Down browse history at text boundaries; paste refreshes layout automatically.</text>
|
||||||
|
<text fg={THEME.muted}>Ctrl+C clears draft first, then asks for a second Ctrl+C within 5s to exit. Permission prompts use 1/2/3 or a/d.</text>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Prompt(props: {
|
||||||
|
busy: boolean
|
||||||
|
phase: FooterPhase
|
||||||
|
status: string
|
||||||
|
toast: string
|
||||||
|
permission?: SessionProjection['permission_prompts'][number]
|
||||||
|
textareaRef: (area?: TextareaRenderable) => void
|
||||||
|
onSubmit: () => void
|
||||||
|
onKeyDown: (event: KeyEvent) => void
|
||||||
|
onPaste: () => void
|
||||||
|
onContentChange: () => void
|
||||||
|
}) {
|
||||||
|
const permissionOptions = () => props.permission?.options.length ? props.permission.options : ['allow', 'deny']
|
||||||
|
const phaseLabel = () => {
|
||||||
|
if (props.permission) return 'Permission'
|
||||||
|
if (props.phase === 'confirm_exit') return 'Confirm exit'
|
||||||
|
if (props.phase === 'error') return 'Attention'
|
||||||
|
return props.busy ? 'Running' : 'Ready'
|
||||||
|
}
|
||||||
|
const phaseColor = () => {
|
||||||
|
if (props.permission || props.phase === 'confirm_exit') return THEME.warning
|
||||||
|
if (props.phase === 'error') return THEME.error
|
||||||
|
return props.busy ? THEME.warning : THEME.success
|
||||||
}
|
}
|
||||||
|
|
||||||
private render_help(): string[] {
|
return (
|
||||||
return [
|
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingBottom={1} backgroundColor={THEME.surface}>
|
||||||
ANSI.fg.cyan + ANSI.bright + ' Help' + ANSI.reset,
|
<Show when={props.permission}>
|
||||||
'',
|
<box flexDirection="column" paddingBottom={1}>
|
||||||
' Keyboard shortcuts:',
|
<text fg={THEME.warning}>Permission required: {props.permission?.subject || props.permission?.tool_name || props.permission?.prompt_id}</text>
|
||||||
' 1 - Tasks view Show task list and status',
|
<text fg={THEME.muted}>Risk: {props.permission?.risk_level || 'unknown'} · {props.permission?.reason || 'No reason provided'}</text>
|
||||||
' 2 - Agents view Show agent status and activity',
|
<box flexDirection="row" gap={1}>
|
||||||
' 3 - Tools view Show available tools and usage',
|
<For each={permissionOptions()}>{(option, index) => (
|
||||||
' 4 - Diff view Show file changes',
|
<text fg={index() === 0 ? THEME.success : THEME.warning}>{index() + 1}. {option}</text>
|
||||||
' h - Help Show this help',
|
)}</For>
|
||||||
' q - Quit Exit AirCoding',
|
</box>
|
||||||
'',
|
</box>
|
||||||
' Getting started:',
|
</Show>
|
||||||
' air init <project> Initialize a project',
|
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
|
||||||
' air run Start coding session',
|
<text fg={phaseColor()}>{phaseLabel()}</text>
|
||||||
' air doctor Run diagnostics',
|
<text fg={props.phase === 'error' ? THEME.error : THEME.muted}>{props.toast || props.status}</text>
|
||||||
]
|
</box>
|
||||||
}
|
<textarea
|
||||||
|
width="100%"
|
||||||
|
minHeight={TEXTAREA_MIN_ROWS}
|
||||||
|
maxHeight={TEXTAREA_MAX_ROWS}
|
||||||
|
wrapMode="word"
|
||||||
|
placeholder={props.busy ? 'Task is running...' : 'Ask AirCoding to change this project, or type /help'}
|
||||||
|
placeholderColor={THEME.faint}
|
||||||
|
textColor={THEME.text}
|
||||||
|
focusedTextColor={THEME.text}
|
||||||
|
backgroundColor={THEME.bg}
|
||||||
|
focusedBackgroundColor={THEME.bg}
|
||||||
|
cursorColor={THEME.accent}
|
||||||
|
focused={!props.busy}
|
||||||
|
onSubmit={props.onSubmit}
|
||||||
|
onKeyDown={props.onKeyDown}
|
||||||
|
onPaste={props.onPaste}
|
||||||
|
onContentChange={props.onContentChange}
|
||||||
|
ref={props.textareaRef}
|
||||||
|
/>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private render_tasks(p: SessionProjection): string[] {
|
function shortcutToView(name: string): TuiAppState['active_view'] | undefined {
|
||||||
const lines: string[] = []
|
switch (name) {
|
||||||
lines.push(ANSI.fg.cyan + ANSI.bright + ' Tasks' + ANSI.reset)
|
case '1': return 'tasks'
|
||||||
|
case '2': return 'agents'
|
||||||
if (p.tasks.length === 0) {
|
case '3': return 'tools'
|
||||||
lines.push(ANSI.fg.gray + ' No tasks yet.' + ANSI.reset)
|
case '4': return 'diff'
|
||||||
return lines
|
case 'h': return 'help'
|
||||||
}
|
default: return undefined
|
||||||
|
}
|
||||||
for (const task of p.tasks.slice(0, 10)) {
|
}
|
||||||
const status_color = task.status === 'completed' ? ANSI.fg.green : task.status === 'failed' ? ANSI.fg.red : ANSI.fg.yellow
|
|
||||||
lines.push(` ${status_color}●${ANSI.reset} ${task.title || task.id}`)
|
function statusColor(status: string | undefined): string {
|
||||||
lines.push(ANSI.fg.gray + ` ID: ${task.id} | Status: ${task.status}` + ANSI.reset)
|
switch (status) {
|
||||||
}
|
case 'completed':
|
||||||
|
case 'ok':
|
||||||
if (p.tasks.length > 10) {
|
case 'active':
|
||||||
lines.push(ANSI.fg.gray + ` ... and ${p.tasks.length - 10} more tasks` + ANSI.reset)
|
case 'running':
|
||||||
}
|
return THEME.success
|
||||||
|
case 'failed':
|
||||||
return lines
|
case 'error':
|
||||||
}
|
case 'lost':
|
||||||
|
return THEME.error
|
||||||
private render_agents(p: SessionProjection): string[] {
|
case 'blocked':
|
||||||
const lines: string[] = []
|
case 'pending':
|
||||||
lines.push(ANSI.fg.cyan + ANSI.bright + ' Agents' + ANSI.reset)
|
case 'cancelled':
|
||||||
|
return THEME.warning
|
||||||
if (p.agents.length === 0) {
|
default:
|
||||||
lines.push(ANSI.fg.gray + ' No active agents.' + ANSI.reset)
|
return THEME.muted
|
||||||
return lines
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const agent of p.agents.slice(0, 10)) {
|
function statusMark(status: string | undefined): string {
|
||||||
const status_color = agent.status === 'running' ? ANSI.fg.green : agent.status === 'idle' ? ANSI.fg.gray : ANSI.fg.yellow
|
switch (status) {
|
||||||
lines.push(` ${status_color}●${ANSI.reset} ${agent.type}`)
|
case 'completed':
|
||||||
lines.push(ANSI.fg.gray + ` ID: ${agent.id?.slice(0, 8)}... | Status: ${agent.status}` + ANSI.reset)
|
case 'ok':
|
||||||
}
|
return '✓'
|
||||||
|
case 'failed':
|
||||||
return lines
|
case 'error':
|
||||||
}
|
return '✗'
|
||||||
|
case 'running':
|
||||||
private render_tools(p: SessionProjection): string[] {
|
case 'active':
|
||||||
const lines: string[] = []
|
return '●'
|
||||||
lines.push(ANSI.fg.cyan + ANSI.bright + ' Recent Tool Calls' + ANSI.reset)
|
case 'blocked':
|
||||||
|
return '!'
|
||||||
const recent_calls = p.tasks.slice(0, 10)
|
default:
|
||||||
|
return '○'
|
||||||
if (recent_calls.length === 0) {
|
|
||||||
lines.push(ANSI.fg.gray + ' No tool calls yet.' + ANSI.reset)
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const call of recent_calls) {
|
|
||||||
lines.push(` ${ANSI.fg.green}✓${ANSI.reset} ${call.title || call.type}`)
|
|
||||||
lines.push(ANSI.fg.gray + ` Task: ${call.id?.slice(0, 8)}... | Status: ${call.status}` + ANSI.reset)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
private render_diff(p: SessionProjection): string[] {
|
|
||||||
const lines: string[] = []
|
|
||||||
lines.push(ANSI.fg.cyan + ANSI.bright + ' Recent Changes' + ANSI.reset)
|
|
||||||
|
|
||||||
const task_count = p.tasks.length
|
|
||||||
const completed = p.tasks.filter(t => t.status === 'completed').length
|
|
||||||
const failed = p.tasks.filter(t => t.status === 'failed').length
|
|
||||||
|
|
||||||
if (task_count === 0) {
|
|
||||||
lines.push(ANSI.fg.gray + ' No changes yet.' + ANSI.reset)
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(` ${ANSI.fg.green}✓${ANSI.reset} Completed: ${completed}`)
|
|
||||||
lines.push(` ${ANSI.fg.red}✗${ANSI.reset} Failed: ${failed}`)
|
|
||||||
lines.push(` ${ANSI.fg.yellow}○${ANSI.reset} Pending: ${task_count - completed - failed}`)
|
|
||||||
lines.push('')
|
|
||||||
lines.push(ANSI.fg.gray + ` Total tasks: ${task_count}` + ANSI.reset)
|
|
||||||
|
|
||||||
return lines
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* TUI package — Terminal UI components
|
* TUI package — Terminal UI components
|
||||||
*
|
*
|
||||||
* INV-4: TUI imports ONLY contracts + ProjectionClient from runtime.
|
* INV-4: TUI imports no runtime package; it consumes projection snapshots through a local client surface.
|
||||||
* Uses OpenTUI @opentui/* as renderer (npm-dep, do NOT reimplement).
|
* Uses OpenTUI @opentui/* as renderer (npm-dep, do NOT reimplement).
|
||||||
*
|
*
|
||||||
* @module packages/tui
|
* @module packages/tui
|
||||||
|
|||||||
4
packages/tui/src/preload.ts
Executable file
4
packages/tui/src/preload.ts
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
const openTuiPreload = '@opentui/solid/preload'
|
||||||
|
await import(openTuiPreload)
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* TUI shared types
|
* TUI shared projection types.
|
||||||
* TUI imports ONLY contracts. No runtime imports.
|
* TUI stays runtime-free and consumes projection snapshots only.
|
||||||
*
|
*
|
||||||
* @module packages/tui/src/types
|
* @module packages/tui/src/types
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { SessionID, ProjectID, TaskID, AgentID, ToolRunID, ISOTimeString } from '@aircoding/contracts'
|
type SessionID = string
|
||||||
|
type ProjectID = string
|
||||||
|
type TaskID = string
|
||||||
|
type AgentID = string
|
||||||
|
type ToolRunID = string
|
||||||
|
type CommandRunID = string
|
||||||
|
type ArtifactID = string
|
||||||
|
|
||||||
export interface SessionProjection {
|
export interface SessionProjection {
|
||||||
session_id: SessionID
|
session_id: SessionID
|
||||||
@@ -14,6 +20,12 @@ export interface SessionProjection {
|
|||||||
title?: string
|
title?: string
|
||||||
tasks: TaskProjection[]
|
tasks: TaskProjection[]
|
||||||
agents: AgentProjection[]
|
agents: AgentProjection[]
|
||||||
|
tool_runs: ToolRunProjection[]
|
||||||
|
command_runs: CommandRunProjection[]
|
||||||
|
artifacts: ArtifactProjection[]
|
||||||
|
permission_prompts: PermissionPromptProjection[]
|
||||||
|
blockers: BlockerProjection[]
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskProjection {
|
export interface TaskProjection {
|
||||||
@@ -24,6 +36,7 @@ export interface TaskProjection {
|
|||||||
retry_count: number
|
retry_count: number
|
||||||
attempts: number
|
attempts: number
|
||||||
created_at: string
|
created_at: string
|
||||||
|
agent_id?: AgentID
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentProjection {
|
export interface AgentProjection {
|
||||||
@@ -34,4 +47,40 @@ export interface AgentProjection {
|
|||||||
last_heartbeat?: string
|
last_heartbeat?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolRunProjection {
|
||||||
|
tool_run_id: ToolRunID
|
||||||
|
tool_name: string
|
||||||
|
status: string
|
||||||
|
duration_ms?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandRunProjection {
|
||||||
|
command_run_id: CommandRunID
|
||||||
|
command: string
|
||||||
|
status: string
|
||||||
|
exit_code?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactProjection {
|
||||||
|
artifact_id: ArtifactID
|
||||||
|
type: string
|
||||||
|
uri: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionPromptProjection {
|
||||||
|
prompt_id: string
|
||||||
|
subject: string
|
||||||
|
risk_level: string
|
||||||
|
reason: string
|
||||||
|
options: string[]
|
||||||
|
default_option?: string
|
||||||
|
tool_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockerProjection {
|
||||||
|
task_id: TaskID
|
||||||
|
reason: string
|
||||||
|
blocker_kind: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ProjectionSubscriber = (projection: SessionProjection) => void
|
export type ProjectionSubscriber = (projection: SessionProjection) => void
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export class WorkerRuntime {
|
|||||||
* Emit an event to the parent.
|
* Emit an event to the parent.
|
||||||
*/
|
*/
|
||||||
emit(type: string, payload: Record<string, unknown>): void {
|
emit(type: string, payload: Record<string, unknown>): void {
|
||||||
this.send_message('event', { type, ...payload })
|
this.send_message('event', { event_type: type, ...payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,6 +8,11 @@
|
|||||||
|
|
||||||
import { WorkerRuntime } from '../WorkerRuntime.js'
|
import { WorkerRuntime } from '../WorkerRuntime.js'
|
||||||
|
|
||||||
|
const SUMMARY_PREFIX = `This is a compacted summary of earlier context. Treat it as reference only.
|
||||||
|
The latest user message and any newer runtime events after this summary are the source of truth.
|
||||||
|
If this summary conflicts with newer instructions, follow the newer instructions.
|
||||||
|
Preserve active tasks, unresolved questions, architectural constraints, verification status, and remaining work.`
|
||||||
|
|
||||||
export interface CompactorResult {
|
export interface CompactorResult {
|
||||||
status: 'compacted' | 'skipped' | 'blocked'
|
status: 'compacted' | 'skipped' | 'blocked'
|
||||||
summary_content: string
|
summary_content: string
|
||||||
@@ -22,7 +27,15 @@ export class CompactorRole {
|
|||||||
this.runtime = runtime
|
this.runtime = runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(compact_spec: { task_id: string; current_tokens: number; threshold: number }): Promise<CompactorResult> {
|
async run(compact_spec: {
|
||||||
|
task_id?: string
|
||||||
|
current_tokens?: number
|
||||||
|
threshold?: number
|
||||||
|
target_budget_tokens?: number
|
||||||
|
range_start_message_id?: string
|
||||||
|
range_end_message_id?: string
|
||||||
|
source_content?: string
|
||||||
|
}): Promise<CompactorResult> {
|
||||||
const result: CompactorResult = {
|
const result: CompactorResult = {
|
||||||
status: 'skipped',
|
status: 'skipped',
|
||||||
summary_content: '',
|
summary_content: '',
|
||||||
@@ -30,48 +43,105 @@ export class CompactorRole {
|
|||||||
compacted_layers: []
|
compacted_layers: []
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const task_id = compact_spec.task_id || 'compact_task'
|
||||||
this.runtime.emit('compaction.started', { task_id: compact_spec.task_id })
|
const current_tokens = compact_spec.current_tokens ?? 0
|
||||||
|
const threshold = compact_spec.threshold ?? compact_spec.target_budget_tokens ?? 80000
|
||||||
|
const range_start_message_id = compact_spec.range_start_message_id || ''
|
||||||
|
const range_end_message_id = compact_spec.range_end_message_id || ''
|
||||||
|
|
||||||
// Check if compaction is needed
|
try {
|
||||||
if (compact_spec.current_tokens < compact_spec.threshold) {
|
this.runtime.emit('context.compaction.started', {
|
||||||
result.status = 'skipped'
|
event_id: `evt_compaction_started_${crypto.randomUUID()}`,
|
||||||
result.summary_content = `Tokens (${compact_spec.current_tokens}) below threshold (${compact_spec.threshold}) — no compaction needed`
|
task_id,
|
||||||
|
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
|
||||||
|
range_start_message_id,
|
||||||
|
range_end_message_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (current_tokens > 0 && current_tokens < threshold) {
|
||||||
|
result.summary_content = `Tokens (${current_tokens}) below threshold (${threshold}); no compaction needed.`
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use LLM to generate summary of the conversation
|
const token_estimate_before = current_tokens || threshold
|
||||||
const tokens_to_free = compact_spec.current_tokens - Math.floor(compact_spec.threshold * 0.6)
|
const target_after = Math.max(1, Math.floor(threshold * 0.6))
|
||||||
const compaction_messages = [
|
const source_content = compact_spec.source_content || `Current token estimate: ${token_estimate_before}; target budget: ${threshold}.`
|
||||||
{ role: 'system', content: 'Summarize the key facts, decisions, and code changes from the conversation history. Keep it concise but complete. Include file paths, function names, and architectural decisions.' },
|
|
||||||
{ role: 'user', content: `Compaction requested: ${compact_spec.current_tokens} tokens in context, threshold is ${compact_spec.threshold}. Generate a compact summary to free approximately ${tokens_to_free} tokens.` }
|
|
||||||
]
|
|
||||||
|
|
||||||
try {
|
const summary = await this.build_summary(source_content, token_estimate_before, threshold)
|
||||||
const summary = await this.runtime.call_llm({
|
const summary_id = `summary_${crypto.randomUUID()}`
|
||||||
messages: compaction_messages,
|
const token_estimate_after = Math.min(target_after, Math.max(1, Math.floor(summary.length / 4)))
|
||||||
max_tokens: 2048,
|
|
||||||
temperature: 0.2
|
|
||||||
})
|
|
||||||
|
|
||||||
result.summary_content = summary.content || '# Compaction Summary\n\nContext has been compacted to reduce token usage.'
|
result.summary_content = summary
|
||||||
result.tokens_freed = tokens_to_free
|
result.tokens_freed = Math.max(0, token_estimate_before - token_estimate_after)
|
||||||
result.compacted_layers = ['conversation', 'tool_output']
|
result.compacted_layers = ['conversation', 'tool_output', 'images']
|
||||||
result.status = 'compacted'
|
result.status = 'compacted'
|
||||||
} catch {
|
|
||||||
result.summary_content = '# Compaction Summary\n\nSummary generation failed — using basic compaction.'
|
|
||||||
result.tokens_freed = compact_spec.current_tokens - Math.floor(compact_spec.current_tokens * 0.6)
|
|
||||||
result.compacted_layers = ['conversation']
|
|
||||||
result.status = 'compacted'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.runtime.checkpoint('compaction_completed', { task_id: compact_spec.task_id })
|
this.runtime.emit('summary.created', {
|
||||||
|
event_id: `evt_${summary_id}`,
|
||||||
|
summary_id,
|
||||||
|
type: 'compaction',
|
||||||
|
range_start_message_id,
|
||||||
|
range_end_message_id,
|
||||||
|
content_json: {
|
||||||
|
prefix: SUMMARY_PREFIX,
|
||||||
|
summary,
|
||||||
|
active_task: task_id,
|
||||||
|
remaining_work: [],
|
||||||
|
resolved_questions: [],
|
||||||
|
pending_questions: [],
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
token_estimate_before,
|
||||||
|
token_estimate_after,
|
||||||
|
compacted_layers: result.compacted_layers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
this.runtime.emit('context.compaction.completed', {
|
||||||
|
event_id: `evt_compaction_completed_${crypto.randomUUID()}`,
|
||||||
|
task_id,
|
||||||
|
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
|
||||||
|
summary_id,
|
||||||
|
range_start_message_id,
|
||||||
|
range_end_message_id,
|
||||||
|
token_estimate_before,
|
||||||
|
token_estimate_after,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.runtime.checkpoint('compaction_completed', { task_id, summary_id, tokens_freed: result.tokens_freed })
|
||||||
return result
|
return result
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
result.status = 'blocked'
|
result.status = 'blocked'
|
||||||
result.summary_content = error instanceof Error ? error.message : String(error)
|
result.summary_content = message
|
||||||
|
this.runtime.emit('context.compaction.failed', {
|
||||||
|
event_id: `evt_compaction_failed_${crypto.randomUUID()}`,
|
||||||
|
task_id,
|
||||||
|
agent_id: process.env.AIRCODING_AGENT_ID || 'compactor',
|
||||||
|
range_start_message_id,
|
||||||
|
range_end_message_id,
|
||||||
|
error: { message },
|
||||||
|
evidence_refs: [],
|
||||||
|
metadata: {},
|
||||||
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async build_summary(source_content: string, current_tokens: number, threshold: number): Promise<string> {
|
||||||
|
try {
|
||||||
|
const response = await this.runtime.call_llm({
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: `${SUMMARY_PREFIX}\n\nReturn a structured summary with sections: Active task, Key facts, Decisions, Changed files, Verification, Remaining work, Pending questions.` },
|
||||||
|
{ role: 'user', content: `Compact this context from ~${current_tokens} tokens toward ${threshold}.\n\n${source_content}` },
|
||||||
|
],
|
||||||
|
max_tokens: 2048,
|
||||||
|
temperature: 0.2,
|
||||||
|
})
|
||||||
|
return `${SUMMARY_PREFIX}\n\n${(response.content || '').trim() || 'No detailed summary was produced.'}`
|
||||||
|
} catch {
|
||||||
|
return `${SUMMARY_PREFIX}\n\nActive task: context compaction.\nKey facts: source context was too large or summarizer was unavailable.\nRemaining work: rehydrate from durable events and latest user message before continuing.`
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,38 @@
|
|||||||
|
|
||||||
import { WorkerRuntime } from '../WorkerRuntime.js'
|
import { WorkerRuntime } from '../WorkerRuntime.js'
|
||||||
|
|
||||||
|
type FailoverReason =
|
||||||
|
| 'auth'
|
||||||
|
| 'auth_permanent'
|
||||||
|
| 'billing'
|
||||||
|
| 'rate_limit'
|
||||||
|
| 'overloaded'
|
||||||
|
| 'server_error'
|
||||||
|
| 'timeout'
|
||||||
|
| 'context_overflow'
|
||||||
|
| 'payload_too_large'
|
||||||
|
| 'image_too_large'
|
||||||
|
| 'model_not_found'
|
||||||
|
| 'provider_policy_blocked'
|
||||||
|
| 'content_policy_blocked'
|
||||||
|
| 'format_error'
|
||||||
|
| 'invalid_encrypted_content'
|
||||||
|
| 'multimodal_tool_content_unsupported'
|
||||||
|
| 'thinking_signature'
|
||||||
|
| 'long_context_tier'
|
||||||
|
| 'oauth_long_context_beta_forbidden'
|
||||||
|
| 'llama_cpp_grammar_pattern'
|
||||||
|
| 'unknown'
|
||||||
|
|
||||||
|
interface ClassifiedError {
|
||||||
|
reason: FailoverReason
|
||||||
|
message: string
|
||||||
|
retryable: boolean
|
||||||
|
should_compress: boolean
|
||||||
|
should_rotate_credential: boolean
|
||||||
|
should_fallback: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export interface DebuggerResult {
|
export interface DebuggerResult {
|
||||||
status: 'fixed' | 'cannot_reproduce' | 'blocked' | 'escalated'
|
status: 'fixed' | 'cannot_reproduce' | 'blocked' | 'escalated'
|
||||||
root_cause: string
|
root_cause: string
|
||||||
@@ -23,7 +55,7 @@ export class DebuggerRole {
|
|||||||
this.runtime = runtime
|
this.runtime = runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(debug_spec: { task_id: string; error_report: string; affected_files: string[] }): Promise<DebuggerResult> {
|
async run(debug_spec: { task_id?: string; error_report?: string; affected_files?: string[]; verification_refs?: string[] }): Promise<DebuggerResult> {
|
||||||
const result: DebuggerResult = {
|
const result: DebuggerResult = {
|
||||||
status: 'cannot_reproduce',
|
status: 'cannot_reproduce',
|
||||||
root_cause: '',
|
root_cause: '',
|
||||||
@@ -32,54 +64,56 @@ export class DebuggerRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.runtime.emit('debug.started', { task_id: debug_spec.task_id })
|
const task_id = debug_spec.task_id || 'unknown_task'
|
||||||
|
const error_report = debug_spec.error_report || ''
|
||||||
|
const affected_files = debug_spec.affected_files ?? []
|
||||||
|
const classified = this.classify_error(error_report)
|
||||||
|
|
||||||
// Step 1: Gather evidence — read affected files
|
result.diagnostic_chain.push(`1. Classified failure as ${classified.reason}`)
|
||||||
result.diagnostic_chain.push('1. Gathering evidence from affected files')
|
result.diagnostic_chain.push(` retryable=${classified.retryable} compress=${classified.should_compress} rotate_credential=${classified.should_rotate_credential} fallback=${classified.should_fallback}`)
|
||||||
for (const file of debug_spec.affected_files) {
|
|
||||||
|
result.diagnostic_chain.push('2. Gathering evidence from affected files')
|
||||||
|
for (const file of affected_files) {
|
||||||
try {
|
try {
|
||||||
await this.runtime.call_tool('fs.read', { path: file })
|
const read = await this.runtime.call_tool('fs.read', { path: file })
|
||||||
result.evidence_refs.push(`file:${file}`)
|
if (read.type === 'error') {
|
||||||
|
result.diagnostic_chain.push(` Failed to read: ${file}`)
|
||||||
|
} else {
|
||||||
|
result.evidence_refs.push(`file:${file}`)
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
result.diagnostic_chain.push(` Failed to read: ${file}`)
|
result.diagnostic_chain.push(` Failed to read: ${file}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Analyze error signatures using LLM
|
result.diagnostic_chain.push('3. Analyzing root cause')
|
||||||
result.diagnostic_chain.push('2. Analyzing error signatures')
|
|
||||||
const messages = [
|
|
||||||
{ role: 'system', content: 'You are a debugging expert. Analyze the error report and suggest a fix.' },
|
|
||||||
{ role: 'user', content: `Error report:\n${debug_spec.error_report}\n\nAffected files: ${debug_spec.affected_files.join(', ')}\n\nDiagnose the root cause and propose a fix. Be specific about which file and what change.` }
|
|
||||||
]
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.3 })
|
const analysis = await this.runtime.call_llm({
|
||||||
result.root_cause = analysis.content || 'Unable to determine root cause'
|
messages: [
|
||||||
result.diagnostic_chain.push(` Analysis: ${result.root_cause.slice(0, 100)}...`)
|
{ role: 'system', content: 'You are a diagnostic agent. Identify the likely root cause and recovery path. Do not claim a fix was applied unless a tool edit actually succeeded.' },
|
||||||
|
{ role: 'user', content: `Classified error: ${JSON.stringify(classified)}\n\nError report:\n${error_report}\n\nAffected files: ${affected_files.join(', ') || '(none)'}` },
|
||||||
|
],
|
||||||
|
max_tokens: 2048,
|
||||||
|
temperature: 0.2,
|
||||||
|
})
|
||||||
|
result.root_cause = (analysis.content || '').trim() || this.default_root_cause(classified)
|
||||||
} catch {
|
} catch {
|
||||||
result.root_cause = 'LLM analysis unavailable — manual diagnosis required'
|
result.root_cause = this.default_root_cause(classified)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Attempt fix
|
result.status = this.status_for(classified)
|
||||||
result.diagnostic_chain.push('3. Attempting fix')
|
const debug_record_id = `debug_${crypto.randomUUID()}`
|
||||||
if (result.root_cause.includes('fix:') || result.root_cause.includes('change:') || result.root_cause.includes('Fix:')) {
|
this.runtime.emit('debug.record.created', {
|
||||||
const fix_match = result.root_cause.match(/fix:\s*([^\n]+)/i) || result.root_cause.match(/change:\s*([^\n]+)/i)
|
event_id: `evt_${debug_record_id}`,
|
||||||
if (fix_match && debug_spec.affected_files.length > 0) {
|
debug_record_id,
|
||||||
result.fix_applied = { file: debug_spec.affected_files[0], change: fix_match[1] }
|
task_id,
|
||||||
result.status = 'fixed'
|
failure_signature: classified.reason,
|
||||||
result.diagnostic_chain.push(' Fix applied to ' + debug_spec.affected_files[0])
|
summary: result.root_cause.slice(0, 1000),
|
||||||
}
|
evidence_refs: result.evidence_refs,
|
||||||
}
|
verification_refs: debug_spec.verification_refs ?? [],
|
||||||
|
})
|
||||||
|
|
||||||
// Step 4: Verify fix
|
this.runtime.checkpoint('debug_completed', { task_id, reason: classified.reason, status: result.status })
|
||||||
if (result.status === 'fixed') {
|
|
||||||
result.diagnostic_chain.push('4. Verification')
|
|
||||||
try {
|
|
||||||
await this.runtime.call_tool('shell.run', { command: 'echo "Verification passed — fix applied"', timeout: 30000 })
|
|
||||||
} catch { /* verification skipped */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
this.runtime.checkpoint('debug_completed', { task_id: debug_spec.task_id })
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -88,4 +122,53 @@ export class DebuggerRole {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private classify_error(report: string): ClassifiedError {
|
||||||
|
const text = report.toLowerCase()
|
||||||
|
const reason: FailoverReason = this.reason_for(text)
|
||||||
|
return {
|
||||||
|
reason,
|
||||||
|
message: report,
|
||||||
|
retryable: !['auth_permanent', 'billing', 'model_not_found', 'provider_policy_blocked', 'content_policy_blocked', 'format_error', 'invalid_encrypted_content'].includes(reason),
|
||||||
|
should_compress: reason === 'context_overflow' || reason === 'payload_too_large' || reason === 'image_too_large',
|
||||||
|
should_rotate_credential: reason === 'auth' || reason === 'auth_permanent',
|
||||||
|
should_fallback: ['rate_limit', 'overloaded', 'server_error', 'timeout', 'model_not_found', 'long_context_tier', 'oauth_long_context_beta_forbidden'].includes(reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private reason_for(text: string): FailoverReason {
|
||||||
|
if (/context|token|maximum context|too many tokens|context_length/.test(text)) return 'context_overflow'
|
||||||
|
if (/payload too large|request too large|413/.test(text)) return 'payload_too_large'
|
||||||
|
if (/image.*too large|vision.*size/.test(text)) return 'image_too_large'
|
||||||
|
if (/rate limit|too many requests|429/.test(text)) return 'rate_limit'
|
||||||
|
if (/overloaded|capacity|529/.test(text)) return 'overloaded'
|
||||||
|
if (/timeout|timed out|etimedout|504/.test(text)) return 'timeout'
|
||||||
|
if (/500|502|503|server error|bad gateway|service unavailable/.test(text)) return 'server_error'
|
||||||
|
if (/invalid api key|unauthorized|401|forbidden|403|auth/.test(text)) return /invalid api key|revoked|expired/.test(text) ? 'auth_permanent' : 'auth'
|
||||||
|
if (/billing|quota|insufficient credits|payment/.test(text)) return 'billing'
|
||||||
|
if (/model.*not found|unknown model|404/.test(text)) return 'model_not_found'
|
||||||
|
if (/policy|safety|blocked by provider/.test(text)) return 'provider_policy_blocked'
|
||||||
|
if (/content policy|unsafe content/.test(text)) return 'content_policy_blocked'
|
||||||
|
if (/json|schema|format|parse/.test(text)) return 'format_error'
|
||||||
|
if (/encrypted content/.test(text)) return 'invalid_encrypted_content'
|
||||||
|
if (/multimodal.*tool|tool.*image/.test(text)) return 'multimodal_tool_content_unsupported'
|
||||||
|
if (/thinking.*signature|signature mismatch/.test(text)) return 'thinking_signature'
|
||||||
|
if (/long context/.test(text)) return 'long_context_tier'
|
||||||
|
if (/oauth.*long context|beta.*forbidden/.test(text)) return 'oauth_long_context_beta_forbidden'
|
||||||
|
if (/grammar|llama.cpp|llama_cpp/.test(text)) return 'llama_cpp_grammar_pattern'
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
private default_root_cause(classified: ClassifiedError): string {
|
||||||
|
if (classified.should_compress) return `Likely ${classified.reason}; compress context or reduce payload before retry.`
|
||||||
|
if (classified.should_rotate_credential) return `Likely ${classified.reason}; credential or authorization requires attention before retry.`
|
||||||
|
if (classified.should_fallback) return `Likely ${classified.reason}; retry with backoff or fallback provider/model.`
|
||||||
|
return `Failure classified as ${classified.reason}; manual diagnosis required.`
|
||||||
|
}
|
||||||
|
|
||||||
|
private status_for(classified: ClassifiedError): DebuggerResult['status'] {
|
||||||
|
if (classified.should_rotate_credential || classified.reason === 'billing' || classified.reason === 'content_policy_blocked') return 'escalated'
|
||||||
|
if (classified.retryable || classified.should_compress || classified.should_fallback) return 'cannot_reproduce'
|
||||||
|
return 'blocked'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ export class ExecutorRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise<ExecutorResult> {
|
async run(task_spec: { id: string; title: string; description: string; acceptance_criteria: string[] }): Promise<ExecutorResult> {
|
||||||
this.runtime.emit('task.attempt.started', { task_id: task_spec.id })
|
this.runtime.checkpoint('task_attempt_started', { task_id: task_spec.id })
|
||||||
|
|
||||||
const model = (task_spec as any).model || process.env.AIRCODING_MODEL || 'glm-5.1'
|
const model = (task_spec as any).model || process.env.AIRCODING_MODEL || 'glm-5.1'
|
||||||
const projectRoot = process.env.AIRCODING_PROJECT_ROOT || '.'
|
const projectRoot = process.env.AIRCODING_PROJECT_ROOT || '.'
|
||||||
@@ -41,7 +41,10 @@ export class ExecutorRole {
|
|||||||
role: 'system',
|
role: 'system',
|
||||||
content: `You are an AI coding assistant. Complete coding tasks by writing code files.
|
content: `You are an AI coding assistant. Complete coding tasks by writing code files.
|
||||||
|
|
||||||
Use structured tool calls whenever possible. Available tools include fs.read, fs.write, fs.edit, fs.list, shell.run, cpp.detect, cpp.build, and cpp.test.
|
Use structured tool calls whenever possible. Available tools include:
|
||||||
|
- fs.read, fs.write, fs.edit, fs.list — filesystem operations
|
||||||
|
- shell.run — shell command execution
|
||||||
|
- cpp.detect, cpp.configure, cpp.build, cpp.test, cpp.cppcheck, cpp.clangd — C++ toolchain
|
||||||
|
|
||||||
If native tools are unavailable, output strict JSON tool calls only in this form:
|
If native tools are unavailable, output strict JSON tool calls only in this form:
|
||||||
|
|
||||||
@@ -201,10 +204,6 @@ After all required files are written and required verification has passed, write
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.runtime.emit('task.blocked', {
|
|
||||||
task_id: task_spec.id,
|
|
||||||
error: error instanceof Error ? error.message : String(error)
|
|
||||||
})
|
|
||||||
return { status: 'blocked', error: error instanceof Error ? error.message : String(error) }
|
return { status: 'blocked', error: error instanceof Error ? error.message : String(error) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
import { WorkerRuntime } from '../WorkerRuntime.js'
|
import { WorkerRuntime } from '../WorkerRuntime.js'
|
||||||
|
|
||||||
|
const MEMORY_TYPES = new Set(['project_rule', 'toolchain_rule', 'skill_update', 'debug_experience'])
|
||||||
|
|
||||||
export interface ExperienceMinerResult {
|
export interface ExperienceMinerResult {
|
||||||
status: 'completed' | 'no_patterns' | 'blocked'
|
status: 'completed' | 'no_patterns' | 'blocked'
|
||||||
entries: Array<{
|
entries: Array<{
|
||||||
@@ -26,7 +28,7 @@ export class ExperienceMinerRole {
|
|||||||
this.runtime = runtime
|
this.runtime = runtime
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(mine_spec: { task_ids: string[]; focus_categories?: string[] }): Promise<ExperienceMinerResult> {
|
async run(mine_spec: { task_ids?: string[]; focus_categories?: string[]; source_refs?: Array<Record<string, unknown>>; evidence_refs?: string[] }): Promise<ExperienceMinerResult> {
|
||||||
const result: ExperienceMinerResult = {
|
const result: ExperienceMinerResult = {
|
||||||
status: 'no_patterns',
|
status: 'no_patterns',
|
||||||
entries: [],
|
entries: [],
|
||||||
@@ -34,68 +36,73 @@ export class ExperienceMinerRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.runtime.emit('mining.started', { task_ids: mine_spec.task_ids })
|
const task_ids = mine_spec.task_ids ?? []
|
||||||
|
const evidence_refs = mine_spec.evidence_refs ?? task_ids.map((task_id) => `task:${task_id}`)
|
||||||
|
const focus = mine_spec.focus_categories?.length ? mine_spec.focus_categories : ['project_rule', 'toolchain_rule', 'debug_experience']
|
||||||
|
|
||||||
// Read completed task results to extract patterns
|
if (task_ids.length === 0 && evidence_refs.length === 0) {
|
||||||
const task_summaries: string[] = []
|
result.summary = 'No task or evidence refs available for memory mining'
|
||||||
for (const task_id of mine_spec.task_ids) {
|
|
||||||
try {
|
|
||||||
// Emit that we're reading a task
|
|
||||||
this.runtime.emit('mining.task', { task_id })
|
|
||||||
task_summaries.push(`Task ${task_id}: completed`)
|
|
||||||
} catch { /* skip failed task reads */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (task_summaries.length === 0) {
|
|
||||||
result.status = 'no_patterns'
|
|
||||||
result.summary = 'No completed tasks available for mining'
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use LLM to extract patterns
|
|
||||||
const messages = [
|
const messages = [
|
||||||
{ role: 'system', content: 'You are an experience mining expert. Extract reusable patterns, best practices, and lessons learned from completed tasks. Output one pattern per line in format: CATEGORY: pattern description' },
|
{ role: 'system', content: 'Extract durable learning candidates only when supported by evidence. Output one candidate per line as memory_type: concise summary. Valid memory_type values: project_rule, toolchain_rule, skill_update, debug_experience. Do not promote or archive memories.' },
|
||||||
{ role: 'user', content: `Analyze these completed tasks and extract reusable patterns:\n${task_summaries.join('\n')}\n\nFocus categories: ${(mine_spec.focus_categories || ['implementation', 'debugging', 'testing']).join(', ')}` }
|
{ role: 'user', content: `Evidence refs:\n${evidence_refs.join('\n')}\n\nTask ids: ${task_ids.join(', ') || '(none)'}\nFocus categories: ${focus.join(', ')}` }
|
||||||
]
|
]
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.3 })
|
const analysis = await this.runtime.call_llm({ messages, max_tokens: 2048, temperature: 0.2 })
|
||||||
const lines = (analysis.content || '').split('\n').filter(l => l.includes(':'))
|
for (const line of (analysis.content || '').split('\n')) {
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const colon_idx = line.indexOf(':')
|
const colon_idx = line.indexOf(':')
|
||||||
if (colon_idx > 0) {
|
if (colon_idx <= 0) continue
|
||||||
const category = line.slice(0, colon_idx).trim().toLowerCase()
|
const category = line.slice(0, colon_idx).trim().toLowerCase()
|
||||||
const pattern = line.slice(colon_idx + 1).trim()
|
const memory_type = MEMORY_TYPES.has(category) ? category : 'project_rule'
|
||||||
if (pattern.length > 5) {
|
const pattern = line.slice(colon_idx + 1).trim()
|
||||||
result.entries.push({
|
if (pattern.length < 8) continue
|
||||||
category,
|
result.entries.push({ category: memory_type, pattern, source_task_id: task_ids[0] || '', description: pattern })
|
||||||
pattern,
|
|
||||||
source_task_id: mine_spec.task_ids[0] || '',
|
|
||||||
description: pattern
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// LLM unavailable — extract basic patterns from task metadata
|
|
||||||
result.entries.push({
|
result.entries.push({
|
||||||
category: 'execution',
|
category: 'project_rule',
|
||||||
pattern: 'Tasks completed via Executor→LLM→Tool loop',
|
pattern: `Review evidence before promoting memory from ${evidence_refs[0] || task_ids[0]}`,
|
||||||
source_task_id: mine_spec.task_ids[0] || '',
|
source_task_id: task_ids[0] || '',
|
||||||
description: 'Standard execution pattern for code changes'
|
description: 'LLM unavailable; created a conservative candidate that requires human/runtime review before promotion.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.entries.length === 0 && evidence_refs.length > 0) {
|
||||||
|
const category = focus.find((item) => MEMORY_TYPES.has(item)) || 'project_rule'
|
||||||
|
result.entries.push({
|
||||||
|
category,
|
||||||
|
pattern: `Review evidence before promoting memory from ${evidence_refs[0]}`,
|
||||||
|
source_task_id: task_ids[0] || '',
|
||||||
|
description: 'Created a conservative candidate because no structured LLM-supported pattern was returned.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of result.entries) {
|
||||||
|
const candidate_id = `mem_${crypto.randomUUID()}`
|
||||||
|
this.runtime.emit('memory.candidate.created', {
|
||||||
|
event_id: `evt_${candidate_id}`,
|
||||||
|
candidate_id,
|
||||||
|
source_ref: {
|
||||||
|
entity_type: entry.source_task_id ? 'task' : 'evidence',
|
||||||
|
entity_id: entry.source_task_id || evidence_refs[0] || '',
|
||||||
|
},
|
||||||
|
memory_type: entry.category,
|
||||||
|
summary: entry.pattern,
|
||||||
|
evidence_refs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.entries.length === 0) {
|
if (result.entries.length === 0) {
|
||||||
result.status = 'no_patterns'
|
result.summary = `No supported memory candidates extracted from ${task_ids.length} tasks`
|
||||||
result.summary = `No patterns extracted from ${mine_spec.task_ids.length} tasks`
|
|
||||||
} else {
|
} else {
|
||||||
result.status = 'completed'
|
result.status = 'completed'
|
||||||
result.summary = `Mined ${result.entries.length} patterns from ${mine_spec.task_ids.length} tasks`
|
result.summary = `Created ${result.entries.length} memory candidates from ${task_ids.length} tasks`
|
||||||
}
|
}
|
||||||
|
|
||||||
this.runtime.checkpoint('mining_completed', { patterns_found: result.entries.length })
|
this.runtime.checkpoint('experience_mining_completed', { candidates: result.entries.length })
|
||||||
return result
|
return result
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export class ReviewerRole {
|
|||||||
const result: ReviewerResult = { status: 'pass', findings: [], summary: '' }
|
const result: ReviewerResult = { status: 'pass', findings: [], summary: '' }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.runtime.emit('review.started', { task_id: review_spec.task_id })
|
this.runtime.checkpoint('review_started', { task_id: review_spec.task_id })
|
||||||
|
|
||||||
for (const file of review_spec.change_files) {
|
for (const file of review_spec.change_files) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
401
状态交接.md
401
状态交接.md
@@ -1,230 +1,181 @@
|
|||||||
# AirCoding 集成修复第一轮 - 状态交接
|
# AirCoding 全量上下文导出 — 回话恢复用
|
||||||
|
|
||||||
> 用于在另一台设备继续。包含上下文、真实问题、修复方案、验证结果、剩余事项和复现命令。
|
> 导出时间:2026-06-08
|
||||||
|
> 用途:在新会话中还原本轮全部状态(决策、plan、问题根因、参考复用映射、执行框架)。
|
||||||
## 1. 项目与远端
|
> 使用方法:新会话中 `Read /home/airlongdian/DataDevices/AirWorkSpace/AirCoding/状态交接.md` 即可恢复全部上下文。
|
||||||
|
|
||||||
- 仓库根:`/home/airlongdian/DataDevices/AirWorkSpace/AirCoding`
|
|
||||||
- 仓库地址:`http://admin:L753865h@39.106.174.106/admin/AirCoding.git`
|
|
||||||
- 当前分支:`GLM5-Achieve`
|
|
||||||
- 推送目标:`origin/GLM5-Achieve`
|
|
||||||
- 仓库不在 Git 根级 `package.json` 下,子包为 monorepo;用 Bun 运行命令。
|
|
||||||
|
|
||||||
## 2. 本轮目标与边界
|
|
||||||
|
|
||||||
用户要求:架构与设计冻结、需求已明确;不允许回退或降级;汇总多轮四视角审查报告的真实问题并修复;修复后跑真实 UAT 演示。
|
|
||||||
|
|
||||||
**架构与设计冻结点(不要修改)**:
|
|
||||||
- 7 个 monorepo 包:`contracts / cli / tui / runtime / llm / toolchain-cpp / workers`
|
|
||||||
- 5 个 Domain Invariants(INV-1~INV-5)
|
|
||||||
- 工具契约 `ToolResultEnvelope { status, output, error, artifact_ids, evidence_ref_ids, metadata }`(`packages/contracts/src/tool.ts`)
|
|
||||||
- Worker/Scheduler IPC 协议
|
|
||||||
- 状态机:`MainAgent` 15 态、`Scheduler` 13 态
|
|
||||||
|
|
||||||
**真实阻断问题(来源:MiniMax-M3 / Deepseek / GLM5.1 / Gpt5.5 四份审查报告)**:
|
|
||||||
- 工具结果 `content` 与 `output` 契约不一致
|
|
||||||
- `shell.run` AsyncGenerator 无法被普通 `call()` 消费
|
|
||||||
- Scheduler 在 `!has_running()` 时把所有 running task 标 completed
|
|
||||||
- Worker 无结果退出后状态丢失
|
|
||||||
- MainAgent 回答未接入 ContextAssembler
|
|
||||||
- `run.ts` 危险操作 confirmation y/n 路由断裂
|
|
||||||
- ExecutorRole DONE 判定宽松、code block 全局反转义破坏源码
|
|
||||||
- E2E/release gates 不覆盖真实成功场景
|
|
||||||
- 报告 `集成测试阶段GLM5.1审查结果.md` / `集成测试阶段Gpt5.5审查结果.md` 在仓库根目录
|
|
||||||
|
|
||||||
## 3. 已完成的源码修复
|
|
||||||
|
|
||||||
### 3.1 工具契约与 shell.run
|
|
||||||
- `packages/runtime/src/tools/BuiltInToolRegistrar.ts`:`create_real_executor()` 中 18 个工具成功返回从 `{ type, content }` 改为 canonical `{ status: 'ok', output, metadata }`。
|
|
||||||
- `packages/runtime/src/tools/ToolRegistry.ts`:`ToolExecutor` 支持 `Promise | AsyncIterable` 返回;`execute_branch` 通过 `execute_executor_final` 消费 streaming final envelope;`call_streaming` 透传 chunks 并保证 final 出现。
|
|
||||||
- `packages/runtime/src/tools/shell/index.ts`:streaming executor 立即 yield `command.started`,stdout/stderr chunk 收集后 yield,final envelope 包含 `metadata.is_final = true` 与 `output.{exit_code, stdout, stderr, timed_out}`。
|
|
||||||
|
|
||||||
### 3.2 Worker/Scheduler 结果闭环
|
|
||||||
- `packages/runtime/src/workers/WorkerProcess.ts`:新增 `on_exit` 回调,触发退出时把信息分发给 WorkerManager。
|
|
||||||
- `packages/runtime/src/workers/WorkerManager.ts`:
|
|
||||||
- `WorkerHandle.state` 增加 `failed`,`worker.result` 按 payload.status 决定 handle state;保留 wrapped result。
|
|
||||||
- `wrap_worker_result` 把 `changes[].file → changed_files`、verification object → `verification[]`、role status `fixed/pass → completed`、others → `failed`。
|
|
||||||
- `handle_worker_exit` 在 worker 退出但无 result 时构造 `failed/cancelled` result。
|
|
||||||
- 暴露 `get_result_for_task` / `get_handle_for_task`。
|
|
||||||
- 给 ToolRegistry 调用上下文传 `project_root` + `agent_type: 'executor'`,不再用 `cwd` 伪字段。
|
|
||||||
- `packages/runtime/src/scheduler/Scheduler.ts`:`MONITORING` 改为按 `WorkerResult.status` 消费 `task.completed` / `task.blocked` / `task.cancelled` / `task.failed` durable events;删除"`!has_running()` 直接 completed"逻辑。
|
|
||||||
|
|
||||||
### 3.3 MainAgent 上下文、确认门、ArchitectureDesigner
|
|
||||||
- `packages/runtime/src/context/ContextAssembler.ts`:
|
|
||||||
- 修复 L6 `evidence_store.list_for_entity` 调用签名。
|
|
||||||
- 修复 L8 兼容 `role: 'tool' / 'tool_result' / 'tool_use'`。
|
|
||||||
- 新增 L3 `project_files` 快照层(实现 `build_project_files_snapshot`)。
|
|
||||||
- 移除 additional_layers 重复追加。
|
|
||||||
- `packages/runtime/src/app/RuntimeApp.ts`:`start()` 中实例化 `CapabilityRegistry`、绑定 ToolRegistry、注入 DoctorService;创建 `MessageRepository` / `EvidenceRepository` 并 `context_assembler.set_data_sources`。
|
|
||||||
- `packages/runtime/src/app/ServiceRegistry.ts`:同步接入 `CapabilityRegistry`、把 WorkerManager/Scheduler 走同一个 tool registry。
|
|
||||||
- `packages/runtime/src/agents/main/MainAgent.ts`:
|
|
||||||
- 新增 `context_assembler / architecture_designer / project_root / agent_id / task_id` 字段。
|
|
||||||
- `chat_with_llm()` 使用 `ContextAssembler.assemble` 生成 L0-L9 消息再追加 user message。
|
|
||||||
- `classify_via_llm` 改用 `complete_text` 与 `ProviderManager` 既有 API 对齐。
|
|
||||||
- 危险操作正则覆盖中文:`删除|删掉|清除|移除|销毁`。
|
|
||||||
- 委托前调用 `ArchitectureDesigner.assess_impact`,`reject_or_escalate/requires_replan` 转 `ARCHITECTURE_DESIGNING`,`requires_user_confirmation` 转 `CONFIRMING`。
|
|
||||||
- 新增 `infer_changed_files` 推断受影响组件。
|
|
||||||
- `packages/cli/src/commands/run.ts`:
|
|
||||||
- MainAgent 构造时传入 `context_assembler` 与 `project_root`。
|
|
||||||
- 把调度逻辑抽取为 `dispatchTask`,避免重复代码。
|
|
||||||
- `pendingConfirmation` 状态机:进入 CONFIRMING 时只展示提示并保存原始任务;`y` → `handle_confirmation(true)` 后 `dispatchTask`;`n` → `handle_confirmation(false)` 提示取消,不创建任务。
|
|
||||||
- `/results` 优先使用 `WorkerResult.changed_files`,过滤 `.air` 内部文件。
|
|
||||||
- `packages/cli/src/commands/ask.ts`:MainAgent 接入 `context_assembler`/`project_root`;`execute_task` 中 `ctx` 改为 canonical ToolExecutionContext。
|
|
||||||
|
|
||||||
### 3.4 ExecutorRole
|
|
||||||
- `packages/workers/src/roles/ExecutorRole.ts`:
|
|
||||||
- 引入 `is_done_signal(text)` 严格判定(仅当整行等于 `DONE` 或 `TASK_COMPLETE`)。
|
|
||||||
- 工具失败且 LLM 输出 DONE 时不返回 `completed`,而是让 LLM 修复。
|
|
||||||
- 移除 code block 内容的全局 `\\n/\\t/\\\\/\\"` 反转义,保留原文。
|
|
||||||
|
|
||||||
### 3.5 Permission 与 TUI
|
|
||||||
- `packages/runtime/src/tools/ToolRegistry.ts`:`ask_user`/`deny` 分支保留 `call.call_id`。
|
|
||||||
- `packages/cli/src/commands/release.ts`:增加 `findRepoRoot` / `findBun`,将 release gates 改为运行 `air e2e`、runtime regression、depcruise,timeout 600s,并输出失败尾部。
|
|
||||||
|
|
||||||
## 4. 新增门禁与测试
|
|
||||||
|
|
||||||
- `packages/runtime/test/regression/release-critical-gates.test.ts`(已接入 e2e P0-REL):
|
|
||||||
1. `built-in tool success envelopes use output, not content`
|
|
||||||
2. `shell.run returns a final envelope through call and streaming APIs`
|
|
||||||
3. `scheduler does not mark running tasks completed without a worker result`
|
|
||||||
4. `MainAgent answer mode uses assembled project context`
|
|
||||||
5. `destructive requests enter confirmation and rejection returns to idle`
|
|
||||||
- `packages/cli/test/run-command-regression.test.ts`(已接入 e2e P0-REL):
|
|
||||||
- 锁定 `run.ts` 使用 WorkerResult.changed_files、过滤 `.air`、维护 pendingConfirmation。
|
|
||||||
- `packages/runtime/test/regression/worker-result-envelope.test.ts` 更新断言为新 `wrap_worker_result` 实现。
|
|
||||||
- `packages/runtime/test/regression/tool-registry-permission.test.ts` 新增 ask_user/deny 保留 call_id 断言。
|
|
||||||
- `packages/cli/src/commands/e2e.ts` 新增 `P0: Release-critical functional gates`。
|
|
||||||
|
|
||||||
## 5. 验证结果
|
|
||||||
|
|
||||||
```
|
|
||||||
$ /home/airlongdian/.bun/bin/bun run packages/cli/src/index.ts e2e
|
|
||||||
14/14 gates passed
|
|
||||||
- P0: Monorepo structure
|
|
||||||
- P0: depcruise dependency boundary (INV-4)
|
|
||||||
- P0: tsc strict typecheck (0 errors)
|
|
||||||
- P0: Release-critical functional gates
|
|
||||||
- P1: Storage/Events
|
|
||||||
- P2: Tools/Permission
|
|
||||||
- P3: Provider/Context
|
|
||||||
- P4: Worker IPC
|
|
||||||
- P5: C++ Toolchain
|
|
||||||
- P6: Projection/TUI
|
|
||||||
- P7: Agents
|
|
||||||
- P8: Full regression suite
|
|
||||||
- SEC: Command injection regression
|
|
||||||
- CAP: Capability trust regression
|
|
||||||
|
|
||||||
$ /home/airlongdian/.bun/bin/bun run packages/cli/src/index.ts release --dry-run
|
|
||||||
3/3 gates passed - Release READY
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 真实 UAT 演示(临时目录 `/tmp/air-uat.Y2H5dz`)
|
|
||||||
|
|
||||||
- LLM 配置:`OPENAI_API_KEY=sk-...`、`OPENAI_BASE_URL=http://newapi.airlongdian.fun`(注意:env 中 `AIRCODING_*` 会被适配器再次加 `/v1`,应直接用 `OPENAI_*`)、`AIRCODING_MODEL=glm-5.1`。
|
|
||||||
- 场景结果:
|
|
||||||
1. `air ask "创建一个 hello.txt,内容是 HelloWorld"` → 真实写入 `HelloWorld` 磁盘。
|
|
||||||
2. `air ask "我的项目里有哪些文件?"` → 回答引用项目文件快照(`hello.txt`),无幻觉。
|
|
||||||
3. `air run` 输入 `请删除 hello.txt` 后 `n` → 提示 "Cancelled. No task was created.",文件保留。
|
|
||||||
4. `air run` 输入 `请删除 hello.txt` 后 `y` → 调度 `shell.run rm`,文件被删除。
|
|
||||||
- UAT 临时发现并修复:MainAgent 原危险正则不匹配中文,补充 `删除|删掉|清除|移除|销毁`。
|
|
||||||
|
|
||||||
## 7. 复现命令(在新设备上验证)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
REPO=/home/airlongdian/DataDevices/AirWorkSpace/AirCoding
|
|
||||||
cd "$REPO"
|
|
||||||
|
|
||||||
# 1) Type check
|
|
||||||
/home/airlongdian/.bun/bin/bun run node_modules/.bin/tsc --noEmit -p tsconfig.check.json
|
|
||||||
|
|
||||||
# 2) E2E
|
|
||||||
/home/airlongdian/.bun/bin/bun run packages/cli/src/index.ts e2e
|
|
||||||
|
|
||||||
# 3) Release dry-run
|
|
||||||
/home/airlongdian/.bun/bin/bun run packages/cli/src/index.ts release --dry-run
|
|
||||||
|
|
||||||
# 4) 临时目录演示
|
|
||||||
TMP=$(mktemp -d /tmp/air-uat.XXXXXX)
|
|
||||||
cd "$TMP"
|
|
||||||
/home/airlongdian/.bun/bin/bun run "$REPO/packages/cli/src/index.ts" init .
|
|
||||||
OPENAI_API_KEY=<your_key> \
|
|
||||||
OPENAI_BASE_URL=http://newapi.airlongdian.fun \
|
|
||||||
AIRCODING_MODEL=glm-5.1 \
|
|
||||||
AIRCODING_REPO_ROOT="$REPO" \
|
|
||||||
/home/airlongdian/.bun/bin/bun run "$REPO/packages/cli/src/index.ts" ask "创建一个 hello.txt,内容是 HelloWorld"
|
|
||||||
cat hello.txt
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 关键文件清单
|
|
||||||
|
|
||||||
修改:
|
|
||||||
- `packages/cli/src/commands/ask.ts`
|
|
||||||
- `packages/cli/src/commands/e2e.ts`
|
|
||||||
- `packages/cli/src/commands/release.ts`
|
|
||||||
- `packages/cli/src/commands/run.ts`
|
|
||||||
- `packages/runtime/src/agents/main/MainAgent.ts`
|
|
||||||
- `packages/runtime/src/app/RuntimeApp.ts`
|
|
||||||
- `packages/runtime/src/app/ServiceRegistry.ts`
|
|
||||||
- `packages/runtime/src/context/ContextAssembler.ts`
|
|
||||||
- `packages/runtime/src/scheduler/Scheduler.ts`
|
|
||||||
- `packages/runtime/src/tools/BuiltInToolRegistrar.ts`
|
|
||||||
- `packages/runtime/src/tools/ToolRegistry.ts`
|
|
||||||
- `packages/runtime/src/tools/shell/index.ts`
|
|
||||||
- `packages/runtime/src/workers/WorkerManager.ts`
|
|
||||||
- `packages/runtime/src/workers/WorkerProcess.ts`
|
|
||||||
- `packages/runtime/test/regression/tool-registry-permission.test.ts`
|
|
||||||
- `packages/runtime/test/regression/worker-result-envelope.test.ts`
|
|
||||||
- `packages/workers/src/roles/ExecutorRole.ts`
|
|
||||||
|
|
||||||
新增:
|
|
||||||
- `packages/runtime/test/regression/release-critical-gates.test.ts`
|
|
||||||
- `packages/cli/test/run-command-regression.test.ts`
|
|
||||||
|
|
||||||
报告(在仓库根目录):
|
|
||||||
- `集成测试阶段Deepseek审查结果.md`
|
|
||||||
- `集成测试阶段GLM5.1审查结果.md`
|
|
||||||
- `集成测试阶段Gpt5.5审查结果.md`
|
|
||||||
- `集成测试阶段MiniMax-M3审查结果.md`
|
|
||||||
- `集成测试阶段GLM5.1审查结果.md`
|
|
||||||
- `状态交接.md`(本文件)
|
|
||||||
|
|
||||||
## 9. 提交策略
|
|
||||||
|
|
||||||
本轮一次性提交,commit message:
|
|
||||||
|
|
||||||
```
|
|
||||||
fix: integrate audit findings (round 1) - tools, worker, scheduler, main agent
|
|
||||||
|
|
||||||
- Unify ToolResultEnvelope (output vs content) for built-in tools
|
|
||||||
- Fix shell.run AsyncGenerator consumption in ToolRegistry.call
|
|
||||||
- Scheduler: consume WorkerResult.status instead of marking all running tasks completed
|
|
||||||
- WorkerProcess/WorkerManager: surface exit events and generate failed/cancelled result
|
|
||||||
- MainAgent: integrate ContextAssembler, Chinese destructive regex, ArchitectureDesigner impact gate
|
|
||||||
- run.ts: pendingConfirmation flow, dispatch extracted, .air files filtered from /results
|
|
||||||
- CapabilityRegistry wired into RuntimeApp and ServiceRegistry; DoctorService uses it
|
|
||||||
- release.ts: findRepoRoot/findBun, run air e2e + depcruise + runtime regression
|
|
||||||
- New gates: release-critical-gates, CLI run command regression
|
|
||||||
- 14/14 e2e gates pass; 3/3 release dry-run pass
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. 已知与待办
|
|
||||||
|
|
||||||
- TUI 完整输入/快捷键统一收口仍是可改进点(UAT 已在非 TTY 走通 readline),建议后续在 TUI 内增加任务输入框。
|
|
||||||
- Projection 事实源收口已做(`/results` 走 WorkerResult,ProjectionClient 接收 ProjectionStore 推送),但 TUI 渲染仍以手工 snapshot 为初值;下一步可以由 EventStore/ProjectionStore 完全驱动。
|
|
||||||
- 新增 `集成测试阶段Gpt5.5审查结果.md` 等报告应作为证据纳入未来 release notes。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**会话记录**(从对话恢复继续的关键节点):
|
## 1. 仓库与分支
|
||||||
1. 四份审计报告汇总去重 → 真实问题清单。
|
|
||||||
2. Phase 1: 工具契约 + shell.run 修复(ToolRegistry 消费 AsyncIterable、shell final envelope)。
|
|
||||||
3. Phase 2: WorkerProcess exit handler → WorkerManager → Scheduler MONITORING 改按 WorkerResult 状态入事件。
|
|
||||||
4. Phase 3: MainAgent 接入 ContextAssembler,CONFIRMING y/n 路由,ArchitectureDesigner 主路径,CapabilityRegistry 接入,Permission call_id 保留。
|
|
||||||
5. Phase 4: ExecutorRole 严格 DONE、保留 code block 原文、失败不 completed。
|
|
||||||
6. Phase 5: 新增 release-critical-gates / CLI run regression,air e2e 14/14。
|
|
||||||
7. Phase 6: run.ts `/results` 走 WorkerResult,release.ts 找 repo root。
|
|
||||||
8. 真实 UAT:air ask 创建/追问;air run 中文删除拒绝/确认;运行中 UAT 触发并修复中文确认门正则。
|
|
||||||
9. 准备状态交接与一次性 git 提交推送。
|
|
||||||
|
|
||||||
会话总耗时较长,过程已通过自动化避免重复。回到本机可直接按 §7 复现并继续。
|
- 仓库根:`/home/airlongdian/DataDevices/AirWorkSpace/AirCoding`(软链 `/run/media/airlongdian/EasyU/AirCoding` 指向同一目录)
|
||||||
|
- 远端:`<remote-url-credentials-stored-locally>`
|
||||||
|
- 第一轮修复分支:`GLM5-Achieve`(已推送,commit ddefcbb)
|
||||||
|
- bun 路径:`/home/airlongdian/.bun/bin/bun`
|
||||||
|
- LLM 配置(真实端点,用 OPENAI_ 前缀,不要用 AIRCODING_API_URL 否则适配器重复加 /v1):
|
||||||
|
- `OPENAI_API_KEY=${OPENAI_API_KEY}`
|
||||||
|
- `OPENAI_BASE_URL=http://newapi.airlongdian.fun`
|
||||||
|
- `AIRCODING_MODEL=glm-5.1`
|
||||||
|
- `AIRCODING_REPO_ROOT=/home/airlongdian/DataDevices/AirWorkSpace/AirCoding`
|
||||||
|
|
||||||
|
## 2. 本轮之前做了什么(第一轮集成修复,commit ddefcbb)
|
||||||
|
|
||||||
|
真实修复(已验证):
|
||||||
|
- 工具契约 output/content 统一(BuiltInToolRegistrar)
|
||||||
|
- shell.run AsyncGenerator 消费(ToolRegistry.execute_executor_final)
|
||||||
|
- Scheduler 删除"!has_running() 全标 completed"假完成逻辑,改为按 WorkerResult.status 终结
|
||||||
|
- WorkerProcess on_exit → WorkerManager handle_worker_exit(无结果退出生成 failed result)
|
||||||
|
- MainAgent 接入 ContextAssembler(L0-L9 + project_files 快照)
|
||||||
|
- run.ts 确认门 y/n 路由(pendingConfirmation 状态机)+ 中英文危险词正则
|
||||||
|
- ExecutorRole 严格 DONE(is_done_signal 整行匹配)、code block 原文保留
|
||||||
|
- CapabilityRegistry 接入 RuntimeApp/DoctorService/ServiceRegistry
|
||||||
|
- ArchitectureDesigner 接入 MainAgent 主路径(impact gate)
|
||||||
|
- Permission ask_user/deny 保留 call_id
|
||||||
|
- release.ts findRepoRoot/findBun + 真实 gates
|
||||||
|
- /results 优先 WorkerResult.changed_files,过滤 .air
|
||||||
|
- 新增 release-critical-gates.test.ts(5 条真实行为 gate)+ CLI run-command-regression.test.ts
|
||||||
|
|
||||||
|
未修复(第一轮遗留):
|
||||||
|
- EventStore.setRepositories 运行路径从不调用 → domain 表恒空
|
||||||
|
- task.created 从不发出
|
||||||
|
- TUI 是手写 ANSI,非 OpenTUI
|
||||||
|
- air ask 旁路 Scheduler/Worker 架构(自带内联循环)
|
||||||
|
- ProjectionStore 运行时不被事件驱动,run.ts 手工假 snapshot
|
||||||
|
|
||||||
|
门禁:air e2e 14/14,release --dry-run 3/3。但绿灯和可用性正交。
|
||||||
|
|
||||||
|
## 3. opus 四视角交叉审查结论(2026-06-08,四 opus 子代理并行)
|
||||||
|
|
||||||
|
四视角综合判定:**可内测演示(限 air ask),不可对外 Alpha 发布**。
|
||||||
|
|
||||||
|
新发现的核心问题:
|
||||||
|
- **P0-1** EventStore.setRepositories 运行路径从不调用 → INV-1/FR-004 运行时整体失效
|
||||||
|
- **P0-2** Scheduler.create_tasks 不发 task.created(注释谎称发出)
|
||||||
|
- **P0-3** TUI 非 OpenTUI/Solid(手写 ANSI),@opentui 零依赖
|
||||||
|
- **P0-4** air ask 旁路整个调度/Worker 架构
|
||||||
|
- **P0-5** failed 任务被调度机当成 COMPLETED
|
||||||
|
- **P0-6** Worker exit/result 竞态('exit' vs 'close')
|
||||||
|
- **P0-7** fs.edit 参数名不匹配(old_str/new_str vs find/replace),Agent 调用恒失败
|
||||||
|
- Worker 心跳不刷新,>5min 任务被误判 lost
|
||||||
|
|
||||||
|
审计报告文件(仓库根):
|
||||||
|
- `集成测试阶段MiniMax-M3审查结果.md`
|
||||||
|
- `集成测试阶段Deepseek审查结果.md`
|
||||||
|
- `集成测试阶段GLM5.1审查结果.md`
|
||||||
|
- `集成测试阶段Gpt5.5审查结果.md`
|
||||||
|
- `集成测试阶段opus审查结果.md`(本轮新增)
|
||||||
|
|
||||||
|
## 4. 用户核心要求(本轮拍板,优先级明确)
|
||||||
|
|
||||||
|
1. **无工作量优先级,三条主线全部实现**(事件地基 + 执行体 + UI)
|
||||||
|
2. **界面对齐 opencode**(复用它现成的 @opentui/solid TUI,不要自己写简陋版)
|
||||||
|
3. **执行体对齐 claude code cli**(read-before-edit / verification-before-completion / 结构化工具调用 → 代码层强制,不只是 prompt 文字)
|
||||||
|
4. **复用 reference/ 全部项目**,不是"参考模式重写"——能直接移植就移植,冗余依赖可接受,最终产品编译质量不受影响即可
|
||||||
|
5. **需求 > 架构冻结**:当原始需求与冻结的 UML/contracts 冲突时,改 UML 服从需求,记录但不停等批准
|
||||||
|
6. **廉价模型执行 + 主模型复审**:每个主线由廉价模型做机械执行,主模型每阶段复审(读 diff、查 DB、跑验收,不信自报)
|
||||||
|
7. **经验学习闭环不要漏**(hermes 的 debug 经验总结、curator、skill 系统)
|
||||||
|
|
||||||
|
## 5. 根因分析(为什么"约束很详细还是做歪了")
|
||||||
|
|
||||||
|
三个逃生舱:
|
||||||
|
1. **unknown? 逃生舱**:contracts 用 `tools?: unknown[]` 占位 → 实现合法不做,tsc 不报错
|
||||||
|
2. **源码字符串断言冒充验收**:28 测试 18 个是 `readFileSync(src).toContain('词')` → 空壳能过
|
||||||
|
3. **completed 由"写了代码"触发**,不由"达成意图"触发:#158 TUI 标 completed 实为手写 ANSI
|
||||||
|
|
||||||
|
事件链路割裂(主线 A 修复的根因):
|
||||||
|
- `EventStore.ts:942` 导出模块单例 `eventStore = new EventStore({})`(空 DB handle)
|
||||||
|
- `EventIngestor.getEventStore()` 用这个单例
|
||||||
|
- Scheduler 通过 `import { eventIngestor }` 发事件 → 全部流向这个空单例
|
||||||
|
- RuntimeApp 另建 `new EventStore({db})` 赋给 `this.event_store`,只对它 setTransactionManager
|
||||||
|
- 两个实例割裂,domain 表恒空
|
||||||
|
|
||||||
|
## 6. 参考项目复用映射(关键:reference/ 就是 AirCoding 的设计血统)
|
||||||
|
|
||||||
|
| 参考项目 | 对应 AirCoding | FR |
|
||||||
|
|---|---|---|
|
||||||
|
| opencode-1.15.5 | TUI 结构 + @opentui/solid 用法 | FR-016 |
|
||||||
|
| claude-code-cli | 执行原语、Tool 契约、ToolUse/Result block、FileEditTool readFileState | FR-009 |
|
||||||
|
| openai-codex | 工具广度、shell/patch/test loop | FR-009/010 |
|
||||||
|
| hermes-agent-2026.5.16 | 经验学习闭环(curator/memory/skill/error_classifier/context_compressor) | FR-008/014 |
|
||||||
|
| claude-hud-0.0.12 | HUD 显示(context/tools/agents/todo) | FR-016 |
|
||||||
|
| anthropic-skills | SKILL.md 标准 + Agent Skills spec | FR-012 |
|
||||||
|
| air-suite-20260518 | **AirCoding 插件原型**(airarc/aireng/airdo/airdbg/airxdb/airndb/airsdb) | FR-006/007/008/017 |
|
||||||
|
| atuin-18.16.1 | shell 历史(辅助) | — |
|
||||||
|
| asciinema-3.2.0 | 终端录制(辅助 evidence) | FR-015 |
|
||||||
|
|
||||||
|
复用核心来源(取证确认):
|
||||||
|
- claude-code-cli `tools/FileEditTool/FileEditTool.ts:275-290`:`readFileState.get(path)` 检查 → 未读报错 "File has not been read yet"
|
||||||
|
- claude-code-cli `Tool.ts:1-6`:`import { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk'`
|
||||||
|
- opencode `footer.prompt.tsx`:OpenTUI textarea 输入框 + keymap
|
||||||
|
- opencode opentui 版本:0.3.0(`package.json:41-43`)
|
||||||
|
- hermes `agent/curator.py`、`memory_manager.py`、`error_classifier.py`
|
||||||
|
|
||||||
|
## 7. 第二轮规划文件(全部在 `/home/airlongdian/.claude/plans/`)
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| `round2-MASTER.md` | 总纲:根因 + 永久纪律 N1-N5 + 需求§6 锚定 + 三主线总览 |
|
||||||
|
| `round2-REUSE-MAP.md` | 参考项目→AirCoding 复用映射表(每个 reference 项目对应的模块/文件) |
|
||||||
|
| `round2-A-events.md` | 主线A:事件落库地基(统一 EventStore 单例 + task.created 发出 + 验收:DB 真有数据) |
|
||||||
|
| `round2-B-execution.md` | 主线B:执行体对齐 claude code(content block 类型 + read-before-edit 强 + verification + 结构化 tool_use) |
|
||||||
|
| `round2-C-ui.md` | 主线C:界面对齐 opencode(@opentui/solid 直接移植优先 + 冗余依赖可接受) |
|
||||||
|
| `round2-D-experience.md` | 主线D:经验学习闭环(hermes curator/error_classifier/skill 移植到 ExperienceMiner/Debugger/Compactor) |
|
||||||
|
| `round2-E-gates.md` | 主线E:反作弊门禁(需求§6 13条行为化 + NFR-006 + fail-on-missing) |
|
||||||
|
| `round2-EXECUTOR-PROMPT.md` | **可直接复制给廉价模型的启动指令** |
|
||||||
|
|
||||||
|
### 执行顺序:A → B → (C ∥ D) → E
|
||||||
|
- A 是 C/D 的前提(事件落库才能有真实投影供 TUI 消费)
|
||||||
|
- B 与 A 无强依赖,建议顺序做(改动交叉)
|
||||||
|
- C 和 D 可并行(都依赖 A)
|
||||||
|
- E 贯穿收口
|
||||||
|
|
||||||
|
### 每主线验收硬线
|
||||||
|
- A:session.db 的 tasks 表 count > 0(不达成不准进 C/D)
|
||||||
|
- B:read-before-edit 代码强制(未读先改被拒)+ 结构化 tool_use + executor-role 功能测试
|
||||||
|
- C:TUI 无手写 ANSI、可输入、状态来自真实投影、不依赖 runtime
|
||||||
|
- D:ExperienceMiner/Compactor 非空壳、CapabilityRegistry 加载 skill
|
||||||
|
- E:需求 §6 13 条各有行为 gate 且绿
|
||||||
|
|
||||||
|
## 8. 永久纪律 N1-N5(各 plan 文件中重申)
|
||||||
|
|
||||||
|
- N1 消灭 unknown? 逃生舱(本轮触及的契约字段补具体类型去可选)
|
||||||
|
- N2 验收必须执行被测代码(禁止用 grep 源码字符串当 gate)
|
||||||
|
- N3 completed 由验收命令通过触发(不是"我写完了")
|
||||||
|
- N4 改不动或与描述不符就停下报告,不自由发挥
|
||||||
|
- N5 每阶段贴真实输出(TSC 退出码、测试 pass/fail、DB 行数——原样贴)
|
||||||
|
|
||||||
|
## 9. 五份审计报告(仓库根,已 git 跟踪)
|
||||||
|
|
||||||
|
1. `集成测试阶段MiniMax-M3审查结果.md`
|
||||||
|
2. `集成测试阶段Deepseek审查结果.md`
|
||||||
|
3. `集成测试阶段GLM5.1审查结果.md`
|
||||||
|
4. `集成测试阶段Gpt5.5审查结果.md`
|
||||||
|
5. `集成测试阶段opus审查结果.md`(本轮新增)
|
||||||
|
|
||||||
|
## 10. 回话恢复操作(新会话中执行)
|
||||||
|
|
||||||
|
1. 确认仓库存在:`cd /home/airlongdian/DataDevices/AirWorkSpace/AirCoding && ls`
|
||||||
|
2. 确认分支:`git log --oneline -3`(应在 GLM5-Achieve,最新 ddefcbb)
|
||||||
|
3. 读本文件恢复上下文:`Read /home/airlongdian/DataDevices/AirWorkSpace/AirCoding/状态交接.md`
|
||||||
|
4. 读总纲:`Read /home/airlongdian/.claude/plans/round2-MASTER.md`
|
||||||
|
5. 定位上次进度:如果主线A尚未完成,从 `round2-A-events.md` 继续;如果A已完成,检查 session.db 的 tasks 表,确认后发主线B
|
||||||
|
6. 给廉价模型的指令模板:读 `round2-EXECUTOR-PROMPT.md`,复制「===」之间的内容给廉价模型
|
||||||
|
7. 复审流程:执行模型贴回验收输出 → 主模型读 diff / 查 DB / 跑验收 → 通过放行下一主线
|
||||||
|
|
||||||
|
## 11. 当前剩余核心决策点(可选在新会话中确认)
|
||||||
|
|
||||||
|
- TUI:C0 如果 @opentui/solid 0.3.0 在当前 Bun 版本装不上,是降版本还是换方案?当前用户已确认「冗余依赖可接受、直接移植优先」
|
||||||
|
- air ask 旁路架构:是否要删掉内联循环统一到 Scheduler→Worker(不在本轮A-E,留后续)
|
||||||
|
- 第二轮修复的 git 分支:是继续 GLM5-Achieve 还是新开分支
|
||||||
|
|
||||||
|
## 12. 用户核心价值取向(从会话中提取,新会话中如遇决策歧义以此为锚)
|
||||||
|
|
||||||
|
- 不接受架构降级,但需求 > 冻结(需求冲突时改 UML 服从需求)
|
||||||
|
- 不接受空壳实现(prompt 文字冒充代码强制不可接受)
|
||||||
|
- 复用成熟实现 > 自己写(手写简陋版是之前反复出问题的根源)
|
||||||
|
- 质量 > 速度;真实验收 > 绿灯数字
|
||||||
|
- "小步快走"但每一步交付物真实可验证,不靠标记自报
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*如需在新设备继续,git pull GLM5-Achieve 后按 §10 操作即可。*
|
||||||
|
|||||||
269
集成测试阶段opus审查结果.md
Executable file
269
集成测试阶段opus审查结果.md
Executable file
@@ -0,0 +1,269 @@
|
|||||||
|
# 集成测试阶段 opus 审查结果
|
||||||
|
|
||||||
|
**审计日期**: 2026-06-08
|
||||||
|
**项目**: AirCoding V1.0.0 Alpha
|
||||||
|
**审计模型**: Opus(四子代理并行,各持不同文档)
|
||||||
|
**审计模式**: 四视角交叉审计(系统架构师 / 开发工程师 / 真实用户 / 测试工程师)
|
||||||
|
**审计基线**: 第一轮集成修复后(commit ddefcbb)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 总体结论
|
||||||
|
|
||||||
|
第一轮修复**方向正确、happy-path 可用**:四份历史报告的头号 P0(工具契约 content/output、shell.run AsyncGenerator、Scheduler 假完成、MainAgent 无上下文、确认门断裂)已真实闭合并有功能级门禁守护。但本轮 opus 交叉审查发现**第一轮未触及的更深层架构问题与新缺陷**:
|
||||||
|
|
||||||
|
- **架构师**:事件驱动地基在运行路径上是空的(`setRepositories` 从不调用、`task.created` 从不发出、ProjectionStore 运行时从不被事件驱动、TUI 非 OpenTUI、`air ask` 旁路整个调度架构)。判定**不可发布**。
|
||||||
|
- **工程师**:发现 failed 任务被调度机当成 COMPLETED、Worker exit/result 竞态、fs.edit 参数名不匹配、心跳不刷新。判定**不阻断内测,但对外 Alpha 前必修 P1-1/P1-2/P1-3**。
|
||||||
|
- **用户**:核心闭环真实跑通无幻觉无假成功,评分 8/10,判定**可演示、Alpha 可发布带条件**。
|
||||||
|
- **QA**:5 条新增 gate 中 4 条真实有效,但 P6 门禁引用不存在的测试文件形成假阳性,Worker/C++/Projection 仍无真实集成测试。判定**不建议标记 Release READY**。
|
||||||
|
|
||||||
|
四视角分歧点在于"发布标准":用户/工程师视 happy-path 可用为 Alpha 达标;架构师/QA 视事实源与门禁完整性未达标。**综合判定:可内测演示(限 `air ask`),但不可对外 Alpha 发布,事件地基与失败处理必须先闭合。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 系统架构师审查
|
||||||
|
|
||||||
|
### 架构结论
|
||||||
|
|
||||||
|
**不具备产品演示/Alpha 发布标准。** 第一轮闭合了一批工具契约/Worker 结果/确认门的真实 bug,但**事件驱动这一架构地基在运行路径上仍是空的**:domain 表从不被写入、`task.created` 从不发出、TUI 不是 OpenTUI、ProjectionStore 运行时从不被事件驱动。`air run` 能跑通"创建文件"是因为它绕过事实源,直接用 WorkerResult 内存对象 + 手工 snapshot 显示结果,掩盖了 INV-1/INV-5/FR-004 运行时整体失效。
|
||||||
|
|
||||||
|
存在两条割裂的执行实现:
|
||||||
|
- `air ask`:CLI 进程内自带 LLM→工具循环(`ask.ts:82-215`),**完全不经过 Scheduler/Worker/IPC**。
|
||||||
|
- `air run`:走 MainAgent→Scheduler→WorkerManager→子进程→IPC→ExecutorRole 真实链路。
|
||||||
|
|
||||||
|
状态交接.md 的 UAT 全部用 `air ask` 验证,因此真实执行链路实际未被 UAT 覆盖。
|
||||||
|
|
||||||
|
### P0 问题表(阻断发布)
|
||||||
|
|
||||||
|
| # | 问题 | 证据 | 影响 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ARCH-P0-1 | **durable 事件从不投影到 domain 表**:`EventStore.setRepositories()` 运行路径从未调用,仅测试调用。运行时 `project()` 内所有 repo 为 null,每个 case 静默 no-op | `EventStore.ts:299-312`、`EventStore.ts:492-935`、`RuntimeApp.ts:80-84`(只 setTransactionManager) | INV-1 运行时整体失效;FR-004 不成立;domain 表永远为空,SQLite 不是事实源 |
|
||||||
|
| ARCH-P0-2 | **`task.created` 从不发出**:`Scheduler.create_tasks()` 注释称发出但函数体只加内存 TaskGraph 节点,无 ingest | `Scheduler.ts:65-78`(L76 注释谎称)对比 `Scheduler.ts:149` task.started 确实 ingest | tasks 表无 pending 行;task.started 投影 update 不存在的行;rebuild_from_db 永远查不到任务 |
|
||||||
|
| ARCH-P0-3 | **TUI 非 OpenTUI**,是手写 ANSI 转义渲染器;`@opentui/*` 零依赖零引用 | `TuiApp.tsx:27-310`;`tui/package.json` 无 opentui | 违反 baseline §3/§18 + 需求约束 #3 + FR-016;任务 #158 标记 completed 与事实不符 |
|
||||||
|
| ARCH-P0-4 | **ProjectionStore 运行时从不被事件驱动**:`apply()` 运行路径零调用,run.ts 手工构造假 snapshot | `run.ts:59-72`、`run.ts:170-183`;EventBus→ProjectionStore 无订阅 | 违反 baseline §18 + INV-5 + FR-016;TUI 状态与 DB 可任意不一致 |
|
||||||
|
| ARCH-P0-5 | **`air ask` 旁路整个调度/Worker 架构**,自带内联执行循环 | `ask.ts:71-78`、`ask.ts:82-215` | FR-007/FR-008 在主力 demo 命令上不成立;两套执行语义割裂 |
|
||||||
|
|
||||||
|
### P1 问题表
|
||||||
|
|
||||||
|
| # | 问题 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| ARCH-P1-1 | ArchitectureDesigner 发出事件 `session_id:''` 必抛错被 `.catch(()=>{})` 吞掉 | `ArchitectureDesigner.ts:59-73`、`EventIngestor.ts:204` |
|
||||||
|
| ARCH-P1-2 | MainAgent 15 态多数无真实触发路径(AWAITING/SCHEDULING/SUMMARIZING/ERROR/TERMINATED 无进入点) | `MainAgent.ts:15-30,75-122,278-313` |
|
||||||
|
| ARCH-P1-3 | Scheduler 13 态部分空壳过场(COLLECTING_RESULTS/REVIEWING_WAVE 直接切换);200ms 轮询 | `Scheduler.ts:340-356,329-331` |
|
||||||
|
| ARCH-P1-4 | CapabilityRegistry 接入但运行路径无 discover/load,恒空 | `CapabilityRegistry.ts:48`、`RuntimeApp.ts:71-75` |
|
||||||
|
| ARCH-P1-5 | 真实 C++ 工具链 CppToolRegistrar 未进主链路,live 用 BuiltInToolRegistrar 简化版 | `grep CppToolRegistrar` 无命中;`BuiltInToolRegistrar.ts:288-364` |
|
||||||
|
| ARCH-P1-6 | contracts ToolRegistry 接口签名与实现背离,靠 `as any` 掩盖 | `contracts/src/tool.ts:100-106` vs `ToolRegistry.ts:91,128,60` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 开发工程师审查
|
||||||
|
|
||||||
|
### 工程结论
|
||||||
|
|
||||||
|
第一轮修复方向正确、主路径可用,`tsc --noEmit` 0 错误。但发现第一轮未覆盖的真实缺陷,两项触及"诚实性/正确性"底线:failed 任务被伪装成 COMPLETED、Worker exit/result 竞态。happy-path 能跑通,UAT 结论可信,但"任务失败"会被系统性伪装成成功。
|
||||||
|
|
||||||
|
### P1 问题表(对外发布前必修)
|
||||||
|
|
||||||
|
| # | 问题 | 证据 | 修复方向 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ENG-P1-1 | **failed 任务被调度机当成 COMPLETED**:PLANNING_WAVE 算 `remaining=pending+running`,failed 不计入 → COMPLETED;REPAIRING_OR_CONTINUING 的 `if(failed>0){}` 是空壳,retry_planner 从未调用 | `Scheduler.ts:117-122,358-368`;`grep retry_planner.` 无调用 | PLANNING_WAVE 终态区分 failed → BLOCKED/TERMINATED 或经 RetryPlanner 重试;run_until_idle 终态反映失败 |
|
||||||
|
| ENG-P1-2 | **Worker 退出/结果竞态**:WorkerProcess 用 `'exit'` 非 `'close'`,worker report_result 后立即 process.exit(0),exit 可能先于最后一行 stdout 解析,handle_worker_exit 误判 failed | `WorkerProcess.ts:134-142`、`WorkerManager.ts:339-362`、`main.ts:92-93` | 监听 `'close'`;或 handle_worker_exit 对无结果做微任务让步后复查;worker 端 exit 前 await stdout drain |
|
||||||
|
| ENG-P1-3 | **fs.edit 参数名不匹配,Agent 调用恒失败**:执行器读 `{find,replace}`,ExecutorRole/ask.ts 传 `{old_str,new_str}`,find 恒 undefined → "Exact text not found" | `tools/fs/index.ts:191-197`、`ExecutorRole.ts:292`、`ask.ts:107` | 统一参数名(执行器接受 old_str/new_str 或兼容 find=old_str);补 Agent 路径编辑回归 |
|
||||||
|
| ENG-P1-4 | **Worker 心跳不刷新**:worker.heartbeat 无 WorkerManager 处理器,record_heartbeat 仅派发时调一次,>5min 任务被判 stalled,>10min 被 cancel | `WorkerManager.ts:162-257`、`Scheduler.ts:177,193`、`AgentMonitor.ts:90-104` | WorkerManager 注册 worker.heartbeat/checkpoint → record_heartbeat 刷新 |
|
||||||
|
|
||||||
|
### P2 问题表
|
||||||
|
|
||||||
|
| # | 问题 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| ENG-P2-1 | shell.run 流式块顺序错乱且重复(退出后先聚合 stdout/stderr 再 drain 增量 chunks) | `tools/shell/index.ts:94-135` |
|
||||||
|
| ENG-P2-2 | ServiceRegistry 为分叉死代码,DB 路径与 RuntimeApp 不一致 | `ServiceRegistry.ts:38-49`、`RuntimeApp.ts:64-66` |
|
||||||
|
| ENG-P2-3 | 内建工具成功 envelope 夹带遗留 `call_id/tool_name/type:'text'` 顶层字段,靠 Promise<any> 不报错 | `BuiltInToolRegistrar.ts:180-183` 等 18 处 |
|
||||||
|
| ENG-P2-4 | ProviderManager.complete_text 硬编码 anthropic provider_id/canonical_format | `ProviderManager.ts:110-119`、`ask.ts:13,43` |
|
||||||
|
| ENG-P2-5 | process.kill 工具无权限门(perms 全 false,可 kill 任意 PID) | `BuiltInToolRegistrar.ts:118-120,190-201` |
|
||||||
|
| ENG-P2-6 | 空 catch 吞错 | `ContextAssembler.ts:127`、`run.ts:155,157` |
|
||||||
|
| ENG-P2-7 | ArchitectureDesigner 事件 session_id 为空 | `ArchitectureDesigner.ts:59-73` |
|
||||||
|
|
||||||
|
### 第一轮修复正确性核验表
|
||||||
|
|
||||||
|
| 第一轮声称 | 核验结论 |
|
||||||
|
|---|---|
|
||||||
|
| 内建工具改 canonical {status,output,metadata} | ✅ 部分:status/output 已加,但仍夹带 type:'text'/顶层 call_id |
|
||||||
|
| grep content: 无残留 | ⚠️ 残留多为合法(fs.read 输出、layer.content、IPC payload) |
|
||||||
|
| shell.run 两路径正确 | ✅ 消费正确;⚠️ 流式块顺序/重复有缺陷 |
|
||||||
|
| Scheduler 按 status 终结 | ✅ 已删假完成逻辑;❌ 但 failed 在 PLANNING_WAVE 被当已完成 |
|
||||||
|
| WorkerProcess on_exit 覆盖退出语义 | ⚠️ 映射对,但 exit/result 顺序竞态未解决 |
|
||||||
|
| MainAgent 真用 ContextAssembler | ✅ assemble 注入;⚠️ answer 模式 agent_type 误用 'executor' |
|
||||||
|
| run.ts pendingConfirmation 健壮 | ✅ 空输入/y/n/非y-n 路由成立 |
|
||||||
|
| ExecutorRole 严格 DONE/失败不 completed/保留原文 | ✅ 全部成立 |
|
||||||
|
| CapabilityRegistry/ArchitectureDesigner 接入 | ✅ 实例化绑定;但 ArchDesigner 事件 session_id 空(P1)、Capability 运行时空集 |
|
||||||
|
| ContextAssembler L3/L6/L8 | ✅ project_files/evidence 签名/tool role 均落地 |
|
||||||
|
| release.ts findRepoRoot/findBun | ✅ 成立 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 真实用户 / UAT 审查
|
||||||
|
|
||||||
|
### 用户体验评分:8/10
|
||||||
|
|
||||||
|
核心闭环(创建文件、上下文问答、多文件生成、危险操作确认门、调度执行)全部真实跑通,无幻觉、无假成功。扣分来自 `air run` TUI/readline 交织、新建项目立即 doctor 失败两个体验摩擦点。
|
||||||
|
|
||||||
|
### 测试矩阵
|
||||||
|
|
||||||
|
| # | 场景 | 结果 | 观察 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | air init | PASS | 创建 .air 结构 + project.json,退出码 0 |
|
||||||
|
| 2 | air ask 创建 hello.txt | PASS | fs.write,磁盘内容精确 `HelloWorld`(10B) |
|
||||||
|
| 3 | air ask 项目有哪些文件 | PASS | answer 模式准确列出真实文件,无幻觉,未列 .air |
|
||||||
|
| 4 | air ask C++ + CMakeLists | PASS | main.cpp(97B)+CMakeLists.txt(153B),g++ 实测编译运行输出 Hello World |
|
||||||
|
| 5 | air run 中文删除 + n | PASS | 命中确认门,"Cancelled. No task was created.",文件保留 |
|
||||||
|
| 6 | air run 中文删除 + y | PASS | 确认后派发 worker 走 shell.run,文件被删除 |
|
||||||
|
| 7 | air doctor | PASS(含告警) | 6 项全绿,project_structure 报缺 package.json [fixable] |
|
||||||
|
| 8 | air e2e | PASS | 14/14 gates |
|
||||||
|
| 9 | /help /tools /results | PASS | 清晰可理解 |
|
||||||
|
|
||||||
|
### 最痛问题
|
||||||
|
|
||||||
|
1. `air run` TUI 与 readline 双写终端(中)——全屏 TUI 与行式 `> ` 提示符混在同一 stdout,worker 运行时刷屏交错。
|
||||||
|
2. 新建项目 doctor 立即失败(低-中)——init 不生成 package.json,紧接 doctor 报 FAIL,负面第一印象。
|
||||||
|
3. glm-5.1 reasoning token 消耗(信息项)——低 max_tokens 时正文可能为空。
|
||||||
|
|
||||||
|
### False-positive 风险:低
|
||||||
|
文件产物均落盘后 cat/ls/g++ 实测复核;删除查磁盘确认;cpp.build 失败是真实无 cmake(优雅降级如实说明)。唯一留意:/results 是 run 进程内存态,重启不持久。
|
||||||
|
|
||||||
|
### 是否可演示/可发布
|
||||||
|
- **可演示:是**(建议用 air ask,输出干净)。
|
||||||
|
- **可发布 Alpha:是,带条件**——功能完整、门禁 14/14、确认门中英文生效达 Alpha 线;正式版前收口 run 输入统一、init/doctor 体验、/results 持久化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. QA / 发布门禁审查
|
||||||
|
|
||||||
|
### QA 总结
|
||||||
|
|
||||||
|
第一轮源码修复方向正确,`release-critical-gates.test.ts` 是本项目第一次出现真正执行被测代码的发布级门禁。但门禁整体三个结构性问题未达"可发布":
|
||||||
|
|
||||||
|
1. **P6 门禁形同虚设**——引用的 `projection-store-apply.test.ts` 不存在,bun 静默跳过缺失路径,仅靠 workspace-enum.test.ts 让 gate 变绿,**假阳性**。
|
||||||
|
2. **关键集成路径无真实验证**——28 个测试文件无一真正 spawn worker 子进程、无一真正编译运行 C++。
|
||||||
|
3. **源码字符串断言占比过高**——28 个测试中 18 个(64%)用 readFileSync + toContain,只证明"代码还在"不证明"功能正确"。
|
||||||
|
|
||||||
|
### 测试矩阵(真实运行)
|
||||||
|
|
||||||
|
| 项 | 实测结果 | 备注 |
|
||||||
|
|---|---|---|
|
||||||
|
| tsc | EXIT=0,1.2s | 增量编译(未 clean rebuild)|
|
||||||
|
| air e2e | 14/14 passed,4.9s | 见逐 gate 评估 |
|
||||||
|
| release --dry-run | 3/3 — READY,8.6s | |
|
||||||
|
| P4 Worker IPC | 21 pass/53 expect | 无真实 spawn |
|
||||||
|
| P5 C++ | 5 pass/10 expect | 仅 1 文件纯源码断言,无真实编译 |
|
||||||
|
| P8 全回归 | 142 pass/346 expect/21 files | 体量真实但大量字符串断言 |
|
||||||
|
|
||||||
|
### 新增 gate 有效性评估
|
||||||
|
|
||||||
|
`release-critical-gates.test.ts`(5 条)——质量最高:
|
||||||
|
|
||||||
|
| Gate | 判定 |
|
||||||
|
|---|---|
|
||||||
|
| 1 tool 用 output 非 content | ✅ 真实调用工具断言 output 存在/content undefined |
|
||||||
|
| 2 shell.run final(call+streaming) | ✅ 真跑 printf ok 断言 exit_code/stdout/is_final |
|
||||||
|
| 3 Scheduler 不假完成 | ✅ 命中核心 bug;⚠️ workerManager 是字面量 mock |
|
||||||
|
| 4 MainAgent answer 用 context | ✅ 真实 ContextAssembler + 临时文件 |
|
||||||
|
| 5 危险操作 CONFIRMING→IDLE | ✅ 真实 MainAgent 状态机 |
|
||||||
|
|
||||||
|
5 条中 4 条真实执行被测逻辑——**合格,是门禁里唯一可信功能层**。
|
||||||
|
|
||||||
|
`run-command-regression.test.ts`(3 条)——全部源码字符串断言,不执行 run 命令,仅防回退快照。
|
||||||
|
|
||||||
|
### 仍缺失的关键 gate
|
||||||
|
|
||||||
|
| # | 缺失项 | 风险 |
|
||||||
|
|---|---|---|
|
||||||
|
| G1 | Worker 真实 spawn + IPC round-trip(worker-fixture 自承认 stub) | 最高 |
|
||||||
|
| G2 | 真实 C++ build/run | 高 |
|
||||||
|
| G3 | Worker exit consistency 端到端 | 高 |
|
||||||
|
| G4 | Projection rebuild/replay(门禁引用文件不存在,假绿) | 高 |
|
||||||
|
| G5 | False-positive 成功检测(ExecutorRole 无任何测试) | 高 |
|
||||||
|
| G6 | complex C++ build/run e2e | 中-高 |
|
||||||
|
|
||||||
|
### QA P0/P1/P2
|
||||||
|
|
||||||
|
**P0(阻断发布)**
|
||||||
|
- QA-P0-1 修复 P6 假阳性门禁(补 projection-store-apply.test.ts 或移除路径并 fail-on-missing)
|
||||||
|
- QA-P0-2 e2e runTest 加 fail-on-missing(任一路径不存在直接 fail)
|
||||||
|
- QA-P0-3 Worker 真实 spawn round-trip 接入 P4
|
||||||
|
|
||||||
|
**P1**
|
||||||
|
- QA-P1-1 真实 C++ build/run e2e
|
||||||
|
- QA-P1-2 ExecutorRole DONE/失败不 completed 功能测试
|
||||||
|
- QA-P1-3 Worker exit→result 一致性端到端
|
||||||
|
- QA-P1-4 Projection rebuild/replay 覆盖
|
||||||
|
|
||||||
|
**P2**
|
||||||
|
- QA-P2-1 降低源码字符串断言占比(64%)
|
||||||
|
- QA-P2-2 tsc clean rebuild 验证
|
||||||
|
- QA-P2-3 P7 direct-mode mock 标注边界
|
||||||
|
|
||||||
|
### 发布门禁建议
|
||||||
|
**当前不建议标记 Release READY**,尽管 release --dry-run 3/3。理由:release 的绿建立在 e2e 14/14 之上,而 14/14 里 P6 假阳性、P4/P5 源码断言冒充集成。最低放行:QA-P0-1/2/3 完成 + 手工 UAT 脚本化为可重放 e2e 纳入门禁。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 四视角交叉综合
|
||||||
|
|
||||||
|
### P0 汇总(阻断对外发布)
|
||||||
|
|
||||||
|
| # | 问题 | 来源视角 | 根因 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | EventStore.setRepositories 运行路径从不调用 → domain 表恒空 | 架构师 | INV-1/FR-004 地基失效 |
|
||||||
|
| 2 | task.created 从不发出 | 架构师 | 事件溯源断链 |
|
||||||
|
| 3 | failed 任务被调度机当成 COMPLETED | 工程师 | 失败伪装成功(触碰红线)|
|
||||||
|
| 4 | Worker exit/result 竞态误判 failed | 工程师 | 成功也可能被误判 |
|
||||||
|
| 5 | ProjectionStore 运行时不被事件驱动,run.ts 手工 snapshot | 架构师 | INV-5/FR-016 |
|
||||||
|
| 6 | P6 门禁引用不存在文件,假阳性 | QA | 门禁完整性 |
|
||||||
|
| 7 | air ask 旁路调度/Worker 架构 | 架构师 | FR-007/008 双实现割裂 |
|
||||||
|
| 8 | TUI 非 OpenTUI | 架构师 | FR-016/约束#3 |
|
||||||
|
|
||||||
|
### P1 汇总
|
||||||
|
|
||||||
|
fs.edit 参数不匹配恒失败(工程师)、Worker 心跳不刷新(工程师)、ArchitectureDesigner 事件 session_id 空被吞(架构师+工程师)、MainAgent/Scheduler 状态机空壳(架构师)、CapabilityRegistry 运行时空集(架构师)、真实 C++ 工具链未进主链路(架构师)、Worker/C++/Projection 无真实集成 gate(QA)。
|
||||||
|
|
||||||
|
### 与前几轮对比
|
||||||
|
|
||||||
|
| 维度 | 前几轮 | 第一轮修复后(本轮实测) |
|
||||||
|
|---|---|---|
|
||||||
|
| 工具契约 output/content | 头号 P0 | ✅ 已修 + 真实 gate |
|
||||||
|
| shell.run AsyncGenerator | 阻断 | ✅ 已修 + 真实验证 |
|
||||||
|
| Scheduler 假完成 | 阻断 | ✅ 已删假逻辑;❌ 但 failed→COMPLETED 新问题 |
|
||||||
|
| MainAgent 上下文/确认门 | 阻断 | ✅ 已修 + 真实验证 |
|
||||||
|
| ProjectionStore-only/TUI snapshot | Deepseek/Gpt5.5 P1 | ❌ 未修(更深:本轮查实 setRepositories 从不调用)|
|
||||||
|
| durable task events 主路径 | Gpt5.5 P0 | ❌ 未修(根因:task.created 不发出)|
|
||||||
|
| Worker 真实 spawn | 一直缺失 | ❌ 仍缺失(自承认 stub)|
|
||||||
|
| C++ 真实 build/run | 一直缺失 | ❌ 仍缺失 |
|
||||||
|
|
||||||
|
**本轮新发现**:setRepositories 从不调用(最严重)、task.created 不发出、TUI 非 OpenTUI、failed→COMPLETED、Worker exit/result 竞态、fs.edit 参数不匹配、P6 假阳性门禁、ArchitectureDesigner 事件被吞。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 发布建议与修复优先级
|
||||||
|
|
||||||
|
**综合判定:可内测演示(限 air ask),不可对外 Alpha 发布。**
|
||||||
|
|
||||||
|
14/14 e2e 与 3/3 release 全绿,但这些门禁不触碰本轮 P0 任何一条——绿灯与可用性正交,这正是 MiniMax 已警告、本轮仍重演的盲点。
|
||||||
|
|
||||||
|
按修复优先级(遵守"不接受架构降级"原则,全部为补齐而非删功能):
|
||||||
|
|
||||||
|
1. **闭合事件地基**:RuntimeApp.start 调用 `eventStore.setRepositories({...})` 注入全部 domain repo;create_tasks 真正 ingest task.created。(P0-1/P0-2)
|
||||||
|
2. **修复失败处理**:Scheduler PLANNING_WAVE 终态区分 failed,接线 RetryPlanner;run_until_idle 反映失败。(P0-3)
|
||||||
|
3. **修复 Worker 竞态**:WorkerProcess 监听 'close' 或退出前复查 result。(P0-4)
|
||||||
|
4. **统一执行路径**:air ask 复用 Scheduler→Worker,删除 CLI 内联循环。(P0-7)
|
||||||
|
5. **收口 Projection 事实源**:EventBus→ProjectionStore.apply→TUI,删 run.ts 手工 snapshot。(P0-5)
|
||||||
|
6. **门禁完整性**:e2e runTest fail-on-missing;补 P6 真实测试;补 Worker spawn / C++ build / false-positive / projection rebuild gate。(P0-6 + QA-P0)
|
||||||
|
7. **TUI 技术栈归位**:接 @opentui/*,或走正式架构变更声明(不可默默降级)。(P0-8)
|
||||||
|
8. P1 批量:fs.edit 参数、心跳刷新、ArchDesigner session_id、状态机补齐、CapabilityRegistry 加载、真实 C++ 工具链进主链路。
|
||||||
|
|
||||||
|
**核心教训重申**:代码质量指标(tsc/门禁数)与产品可用性指标正交。第一轮把"被测代码从不执行"推进到"核心修复点被真实执行"是实质进步,但门禁完整性(fail-on-missing)与集成层(真 spawn / 真编译 / 真事件落库)仍是发布前硬缺口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*审计模型: Opus(4 子代理并行)*
|
||||||
|
*审计时间: 2026-06-08*
|
||||||
|
*仓库状态: 源码未改动(只读审计)*
|
||||||
Reference in New Issue
Block a user