Compare commits
5 Commits
ddefcbb2b1
...
8476d5c96f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8476d5c96f | ||
|
|
0de71e7a1c | ||
|
|
5e282a39b4 | ||
|
|
e383d5f6a7 | ||
|
|
bac285d412 |
@@ -57,15 +57,15 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
/* ── Rule 4: runtime may only import from contracts & llm ── */
|
/* ── Rule 4: runtime may only import from contracts, llm & toolchain-cpp ── */
|
||||||
{
|
{
|
||||||
name: "runtime-boundary",
|
name: "runtime-boundary",
|
||||||
comment: "runtime may only depend on contracts and llm (facade)",
|
comment: "runtime may only depend on contracts, llm (facade), and toolchain-cpp (capability registration)",
|
||||||
severity: "error",
|
severity: "error",
|
||||||
from: { path: "^packages/runtime/src/" },
|
from: { path: "^packages/runtime/src/" },
|
||||||
to: {
|
to: {
|
||||||
path: "^packages/(tui|cli|workers|toolchain-cpp)/",
|
path: "^packages/(tui|cli|workers)/",
|
||||||
pathNot: "^packages/(contracts|llm)/",
|
pathNot: "^packages/(contracts|llm|toolchain-cpp)/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
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,22 +72,25 @@ 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)}`
|
||||||
app.scheduler.create_tasks([{
|
await app.scheduler.create_tasks([{
|
||||||
id: taskId,
|
id: taskId,
|
||||||
type: 'execute',
|
type: 'execute',
|
||||||
title: input.slice(0, 80),
|
title: input.slice(0, 80),
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
74
packages/contracts/src/content-block.ts
Executable file
74
packages/contracts/src/content-block.ts
Executable file
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Anthropic canonical content block types
|
||||||
|
* Per constraint #6: Anthropic canonical content blocks
|
||||||
|
*
|
||||||
|
* @module packages/contracts/src/content-block
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text content block
|
||||||
|
*/
|
||||||
|
export interface TextBlock {
|
||||||
|
type: 'text'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thinking/reasoning block (for models that support it)
|
||||||
|
*/
|
||||||
|
export interface ThinkingBlock {
|
||||||
|
type: 'thinking'
|
||||||
|
thinking: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool use block - represents a tool call request
|
||||||
|
*/
|
||||||
|
export interface ToolUseBlock {
|
||||||
|
type: 'tool_use'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
input: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool result block - represents the result of a tool execution
|
||||||
|
*/
|
||||||
|
export interface ToolResultBlock {
|
||||||
|
type: 'tool_result'
|
||||||
|
tool_use_id: string
|
||||||
|
content: string | ContentBlock[]
|
||||||
|
is_error?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Union of all canonical content block types
|
||||||
|
*/
|
||||||
|
export type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical message format using content blocks
|
||||||
|
*/
|
||||||
|
export interface CanonicalMessage {
|
||||||
|
role: 'user' | 'assistant' | 'system'
|
||||||
|
content: string | ContentBlock[]
|
||||||
|
// Optional thinking for assistant messages
|
||||||
|
thinking?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool definition for tool use blocks
|
||||||
|
*/
|
||||||
|
export interface ToolDefinitionBlock {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
input_schema: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool choice specification
|
||||||
|
*/
|
||||||
|
export type ToolChoice =
|
||||||
|
| { type: 'auto' }
|
||||||
|
| { type: 'any' }
|
||||||
|
| { type: 'tool'; name: string }
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
export * from './ids' // §2 Core Primitive Types
|
export * from './ids' // §2 Core Primitive Types
|
||||||
export * from './error' // §3 Error Contracts
|
export * from './error' // §3 Error Contracts
|
||||||
export * from './event' // §5 Runtime Event Contracts
|
export * from './event' // §5 Runtime Event Contracts
|
||||||
|
export * from './content-block' // §6 Anthropic Canonical Content Blocks
|
||||||
export * from './runtime' // §10 Worker/IPC Contracts (runtime context)
|
export * from './runtime' // §10 Worker/IPC Contracts (runtime context)
|
||||||
export * from './ipc' // §10 Worker/IPC Contracts
|
export * from './ipc' // §10 Worker/IPC Contracts
|
||||||
export * from './task' // §9 Task and Scheduler Contracts
|
export * from './task' // §9 Task and Scheduler Contracts
|
||||||
@@ -11,7 +12,7 @@ export * from './worker-result' // §11 WorkerResult Contracts
|
|||||||
export * from './tool' // §12 Tool Contracts + §21 Diagnostic Contracts
|
export * from './tool' // §12 Tool Contracts + §21 Diagnostic Contracts
|
||||||
export * from './permission' // §13 Permission Contracts
|
export * from './permission' // §13 Permission Contracts
|
||||||
export * from './artifact' // §14 Artifact Contracts
|
export * from './artifact' // §14 Artifact Contracts
|
||||||
export * from './evidence' // §14 Evidence Contracts
|
export * from './evidence' // <EFBFBD><EFBFBD>14 Evidence Contracts
|
||||||
export * from './project' // §8 Project and Session Contracts
|
export * from './project' // §8 Project and Session Contracts
|
||||||
|
|
||||||
// Provider exports - re-export with disambiguation for duplicate names
|
// Provider exports - re-export with disambiguation for duplicate names
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import type {
|
|||||||
JsonObject,
|
JsonObject,
|
||||||
} from './ids'
|
} from './ids'
|
||||||
|
|
||||||
|
// Import content block types for canonical message format
|
||||||
|
import type {
|
||||||
|
CanonicalMessage,
|
||||||
|
TextBlock,
|
||||||
|
ToolDefinitionBlock,
|
||||||
|
ToolChoice,
|
||||||
|
} from './content-block'
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// §15 — Provider Contracts
|
// §15 — Provider Contracts
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -180,10 +188,10 @@ export interface ProviderCompletionInput {
|
|||||||
provider_id: ProviderID
|
provider_id: ProviderID
|
||||||
model_id: ModelID
|
model_id: ModelID
|
||||||
canonical_format: 'anthropic'
|
canonical_format: 'anthropic'
|
||||||
messages: unknown[]
|
messages: CanonicalMessage[]
|
||||||
tools?: unknown[]
|
tools?: ToolDefinitionBlock[]
|
||||||
tool_choice?: unknown
|
tool_choice?: ToolChoice
|
||||||
system?: unknown
|
system?: string | TextBlock[]
|
||||||
max_output_tokens?: number
|
max_output_tokens?: number
|
||||||
temperature?: number
|
temperature?: number
|
||||||
metadata?: JsonObject
|
metadata?: JsonObject
|
||||||
|
|||||||
@@ -96,8 +96,8 @@ export class ProviderManager {
|
|||||||
*/
|
*/
|
||||||
async complete_text(
|
async complete_text(
|
||||||
messages: unknown[],
|
messages: unknown[],
|
||||||
options: { model?: string; max_tokens?: number; temperature?: number; system?: string } = {}
|
options: { model?: string; max_tokens?: number; temperature?: number; system?: string; tools?: unknown[] } = {}
|
||||||
): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> {
|
): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
|
||||||
// Auto-initialize if no adapter selected yet (cold start)
|
// Auto-initialize if no adapter selected yet (cold start)
|
||||||
if (!this.current_adapter) {
|
if (!this.current_adapter) {
|
||||||
this.select_model({ model: options.model || 'claude-haiku-4-5-20251001', provider: 'anthropic' })
|
this.select_model({ model: options.model || 'claude-haiku-4-5-20251001', provider: 'anthropic' })
|
||||||
@@ -108,11 +108,13 @@ export class ProviderManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const model_id = options.model || 'claude-haiku-4-5-20251001'
|
const model_id = options.model || 'claude-haiku-4-5-20251001'
|
||||||
|
// N1: Cast to CanonicalMessage[] - adapter handles conversion from unknown[]
|
||||||
const input: ProviderCompletionInput = {
|
const input: ProviderCompletionInput = {
|
||||||
provider_id: 'anthropic',
|
provider_id: 'anthropic',
|
||||||
model_id: model_id as ModelID,
|
model_id: model_id as ModelID,
|
||||||
canonical_format: 'anthropic',
|
canonical_format: 'anthropic',
|
||||||
messages,
|
messages: messages as any,
|
||||||
|
tools: options.tools as any,
|
||||||
max_output_tokens: options.max_tokens || 4096,
|
max_output_tokens: options.max_tokens || 4096,
|
||||||
temperature: options.temperature,
|
temperature: options.temperature,
|
||||||
system: options.system
|
system: options.system
|
||||||
@@ -120,6 +122,7 @@ export class ProviderManager {
|
|||||||
|
|
||||||
let content = ''
|
let content = ''
|
||||||
let usage: { input_tokens: number; output_tokens: number } | undefined
|
let usage: { input_tokens: number; output_tokens: number } | undefined
|
||||||
|
const tool_calls: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> = []
|
||||||
|
|
||||||
for await (const event of adapter.complete(input)) {
|
for await (const event of adapter.complete(input)) {
|
||||||
if (event.type === 'content_delta') {
|
if (event.type === 'content_delta') {
|
||||||
@@ -129,6 +132,11 @@ export class ProviderManager {
|
|||||||
} else if (payload.type === 'thinking_delta') {
|
} else if (payload.type === 'thinking_delta') {
|
||||||
// Accumulate thinking for reference but don't include in content
|
// Accumulate thinking for reference but don't include in content
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'tool_use') {
|
||||||
|
const payload = event.payload as { id?: string; name?: string; input?: Record<string, unknown>; arguments?: Record<string, unknown> }
|
||||||
|
if (payload.name) {
|
||||||
|
tool_calls.push({ id: payload.id, name: payload.name, arguments: payload.input || payload.arguments || {} })
|
||||||
|
}
|
||||||
} else if (event.type === 'message_stop') {
|
} else if (event.type === 'message_stop') {
|
||||||
const payload = event.payload as { usage?: { output_tokens: number } }
|
const payload = event.payload as { usage?: { output_tokens: number } }
|
||||||
if (payload.usage) {
|
if (payload.usage) {
|
||||||
@@ -137,7 +145,7 @@ export class ProviderManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content, usage }
|
return { content, usage, tool_calls: tool_calls.length ? tool_calls : undefined }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,7 +179,7 @@ export class ProviderManager {
|
|||||||
provider_id: assignment.provider as ProviderID || 'anthropic',
|
provider_id: assignment.provider as ProviderID || 'anthropic',
|
||||||
model_id: assignment.model as ModelID,
|
model_id: assignment.model as ModelID,
|
||||||
canonical_format: 'anthropic',
|
canonical_format: 'anthropic',
|
||||||
messages,
|
messages: messages as any,
|
||||||
max_output_tokens: options.max_tokens || 4096,
|
max_output_tokens: options.max_tokens || 4096,
|
||||||
temperature: options.temperature
|
temperature: options.temperature
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface AnthropicApiRequest {
|
|||||||
top_p?: number
|
top_p?: number
|
||||||
system?: string
|
system?: string
|
||||||
stream?: boolean
|
stream?: boolean
|
||||||
|
tools?: Array<{ name: string; description: string; input_schema: Record<string, unknown> }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AnthropicAdapter implements ProviderAdapter {
|
export class AnthropicAdapter implements ProviderAdapter {
|
||||||
@@ -101,6 +102,7 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|||||||
temperature: input.temperature,
|
temperature: input.temperature,
|
||||||
system: input.system as string | undefined,
|
system: input.system as string | undefined,
|
||||||
stream: false,
|
stream: false,
|
||||||
|
tools: this.convert_tools(input.tools),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Yield each content block as an event
|
// Yield each content block as an event
|
||||||
@@ -110,6 +112,8 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|||||||
yield { type: 'content_delta', payload: { type: 'text_delta', text: block.text } }
|
yield { type: 'content_delta', payload: { type: 'text_delta', text: block.text } }
|
||||||
} else if (block.type === 'thinking' && block.thinking) {
|
} else if (block.type === 'thinking' && block.thinking) {
|
||||||
yield { type: 'content_delta', payload: { type: 'thinking_delta', thinking: block.thinking } }
|
yield { type: 'content_delta', payload: { type: 'thinking_delta', thinking: block.thinking } }
|
||||||
|
} else if (block.type === 'tool_use' && block.name) {
|
||||||
|
yield { type: 'tool_use', payload: { id: block.id, name: block.name, input: (block.input as Record<string, unknown>) || {} } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (response.usage) {
|
if (response.usage) {
|
||||||
@@ -126,19 +130,36 @@ export class AnthropicAdapter implements ProviderAdapter {
|
|||||||
* Backward-compat: single-shot complete that returns string content.
|
* Backward-compat: single-shot complete that returns string content.
|
||||||
* Used by MainAgent.classify_via_llm.
|
* Used by MainAgent.classify_via_llm.
|
||||||
*/
|
*/
|
||||||
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> {
|
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number; tools?: unknown[] } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
|
||||||
const response = await this.make_request({
|
const response = await this.make_request({
|
||||||
model: options.model || 'claude-haiku-4-5-20251001',
|
model: options.model || 'claude-haiku-4-5-20251001',
|
||||||
messages: this.convert_raw_messages(messages),
|
messages: this.convert_raw_messages(messages),
|
||||||
max_tokens: options.max_tokens || 1024,
|
max_tokens: options.max_tokens || 1024,
|
||||||
|
tools: this.convert_tools(options.tools),
|
||||||
stream: false,
|
stream: false,
|
||||||
})
|
})
|
||||||
|
const tool_calls = response.content
|
||||||
|
.filter(b => b.type === 'tool_use' && b.name)
|
||||||
|
.map(b => ({ id: b.id, name: b.name!, arguments: (b.input as Record<string, unknown>) || {} }))
|
||||||
return {
|
return {
|
||||||
content: this.extract_content(response),
|
content: this.extract_content(response),
|
||||||
usage: response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined,
|
usage: response.usage ? { input_tokens: response.usage.input_tokens, output_tokens: response.usage.output_tokens } : undefined,
|
||||||
|
tool_calls: tool_calls.length ? tool_calls : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private convert_tools(tools?: unknown[]): Array<{ name: string; description: string; input_schema: Record<string, unknown> }> | undefined {
|
||||||
|
if (!tools?.length) return undefined
|
||||||
|
return tools.map(t => {
|
||||||
|
const tool = t as { name: string; description?: string; input_schema?: Record<string, unknown> }
|
||||||
|
return {
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description || tool.name,
|
||||||
|
input_schema: tool.input_schema || { type: 'object', properties: {} },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private convert_to_anthropic_messages(messages: Array<{ role: string; content: unknown }>): Array<{ role: string; content: Array<Record<string, unknown>> }> {
|
private convert_to_anthropic_messages(messages: Array<{ role: string; content: unknown }>): Array<{ role: string; content: Array<Record<string, unknown>> }> {
|
||||||
return messages.map(m => {
|
return messages.map(m => {
|
||||||
const blocks: Array<Record<string, unknown>> = []
|
const blocks: Array<Record<string, unknown>> = []
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ interface OpenAIApiResponse {
|
|||||||
model: string
|
model: string
|
||||||
choices: Array<{
|
choices: Array<{
|
||||||
index: number
|
index: number
|
||||||
message?: { role: string; content: string; tool_calls?: unknown[] }
|
message?: { role: string; content: string | null; tool_calls?: Array<{ id: string; type: string; function: { name: string; arguments: string } }> }
|
||||||
delta?: { role?: string; content?: string }
|
delta?: { role?: string; content?: string }
|
||||||
finish_reason?: string
|
finish_reason?: string
|
||||||
}>
|
}>
|
||||||
@@ -96,6 +96,7 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|||||||
const response = await this.make_request({
|
const response = await this.make_request({
|
||||||
model: String(input.model_id),
|
model: String(input.model_id),
|
||||||
messages: this.convert_messages(input.messages as { role: string; content: unknown }[]),
|
messages: this.convert_messages(input.messages as { role: string; content: unknown }[]),
|
||||||
|
tools: this.convert_tools(input.tools),
|
||||||
max_tokens: input.max_output_tokens ?? 4096,
|
max_tokens: input.max_output_tokens ?? 4096,
|
||||||
temperature: input.temperature,
|
temperature: input.temperature,
|
||||||
system: input.system as string | undefined,
|
system: input.system as string | undefined,
|
||||||
@@ -108,6 +109,22 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|||||||
if (content) {
|
if (content) {
|
||||||
yield { type: 'content_delta', payload: { type: 'text_delta', text: content, index: choice.index } }
|
yield { type: 'content_delta', payload: { type: 'text_delta', text: content, index: choice.index } }
|
||||||
}
|
}
|
||||||
|
for (const call of choice.message?.tool_calls || []) {
|
||||||
|
let args: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
args = JSON.parse(call.function.arguments || '{}')
|
||||||
|
} catch {
|
||||||
|
args = { raw_arguments: call.function.arguments || '' }
|
||||||
|
}
|
||||||
|
yield {
|
||||||
|
type: 'tool_use',
|
||||||
|
payload: {
|
||||||
|
id: call.id,
|
||||||
|
name: this.from_openai_tool_name(call.function.name),
|
||||||
|
input: args,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (response.usage) {
|
if (response.usage) {
|
||||||
const stop = response.choices[0]?.finish_reason || 'stop'
|
const stop = response.choices[0]?.finish_reason || 'stop'
|
||||||
@@ -121,10 +138,11 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|||||||
* Backward-compat: single-shot complete that returns string content.
|
* Backward-compat: single-shot complete that returns string content.
|
||||||
* Used by callers expecting a Promise<string> result.
|
* Used by callers expecting a Promise<string> result.
|
||||||
*/
|
*/
|
||||||
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number } }> {
|
async complete_text(messages: unknown[], options: { model?: string; max_tokens?: number; tools?: unknown[] } = {}): Promise<{ content: string; usage?: { input_tokens: number; output_tokens: number }; tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> }> {
|
||||||
const response = await this.make_request({
|
const response = await this.make_request({
|
||||||
model: options.model || this.model,
|
model: options.model || this.model,
|
||||||
messages: this.convert_raw_messages(messages),
|
messages: this.convert_raw_messages(messages),
|
||||||
|
tools: this.convert_tools(options.tools),
|
||||||
max_tokens: options.max_tokens || 1024,
|
max_tokens: options.max_tokens || 1024,
|
||||||
stream: false,
|
stream: false,
|
||||||
})
|
})
|
||||||
@@ -133,27 +151,104 @@ export class OpenAICompatibleAdapter implements ProviderAdapter {
|
|||||||
const content = choice?.message?.content
|
const content = choice?.message?.content
|
||||||
|| (choice?.message as any)?.reasoning
|
|| (choice?.message as any)?.reasoning
|
||||||
|| ''
|
|| ''
|
||||||
|
const tool_calls = (choice?.message?.tool_calls || []).map(call => {
|
||||||
|
let args: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
args = JSON.parse(call.function.arguments || '{}')
|
||||||
|
} catch {
|
||||||
|
args = { raw_arguments: call.function.arguments || '' }
|
||||||
|
}
|
||||||
|
return { id: call.id, name: this.from_openai_tool_name(call.function.name), arguments: args }
|
||||||
|
})
|
||||||
return {
|
return {
|
||||||
content,
|
content,
|
||||||
usage: response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined,
|
usage: response.usage ? { input_tokens: response.usage.prompt_tokens, output_tokens: response.usage.completion_tokens } : undefined,
|
||||||
|
tool_calls: tool_calls.length ? tool_calls : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private convert_messages(messages: Array<{ role: string; content: unknown }>): Array<Record<string, unknown>> {
|
private convert_messages(messages: Array<{ role: string; content: unknown }>): Array<Record<string, unknown>> {
|
||||||
return messages.map(m => ({
|
return messages.flatMap(m => this.convert_one_message(m))
|
||||||
role: m.role,
|
}
|
||||||
content: typeof m.content === 'string' ? m.content : String(m.content),
|
|
||||||
}))
|
/**
|
||||||
|
* Convert a single canonical message to OpenAI wire format.
|
||||||
|
* Assistant tool_use blocks → assistant message with tool_calls.
|
||||||
|
* tool_result blocks → one role:tool message per result (OpenAI requires
|
||||||
|
* each tool result to reference its tool_call_id on its own message).
|
||||||
|
*/
|
||||||
|
private convert_one_message(m: { role: string; content: unknown }): Array<Record<string, unknown>> {
|
||||||
|
if (Array.isArray(m.content)) {
|
||||||
|
const toolResults = m.content.filter(c => typeof c === 'object' && c !== null && (c as any).type === 'tool_result') as any[]
|
||||||
|
if (toolResults.length > 0) {
|
||||||
|
return toolResults.map(tr => ({
|
||||||
|
role: 'tool',
|
||||||
|
tool_call_id: tr.tool_use_id,
|
||||||
|
content: typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolUses = m.content.filter(c => typeof c === 'object' && c !== null && (c as any).type === 'tool_use') as any[]
|
||||||
|
if (toolUses.length > 0) {
|
||||||
|
const text = m.content
|
||||||
|
.filter(c => typeof c === 'object' && c !== null && (c as any).type === 'text')
|
||||||
|
.map(c => String((c as any).text || ''))
|
||||||
|
.join('\n')
|
||||||
|
return [{
|
||||||
|
role: m.role,
|
||||||
|
content: text || null,
|
||||||
|
tool_calls: toolUses.map(tu => ({
|
||||||
|
id: tu.id,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: this.to_openai_tool_name(tu.name), arguments: JSON.stringify(tu.input || {}) },
|
||||||
|
})),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [{ role: m.role, content: this.content_to_text(m.content) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
private convert_tools(tools?: unknown[]): Array<Record<string, unknown>> | undefined {
|
||||||
|
if (!tools?.length) return undefined
|
||||||
|
return tools.map(t => {
|
||||||
|
const tool = t as { name: string; description?: string; input_schema?: Record<string, unknown> }
|
||||||
|
return {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: this.to_openai_tool_name(tool.name),
|
||||||
|
description: tool.description || tool.name,
|
||||||
|
parameters: tool.input_schema || { type: 'object', properties: {} },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private to_openai_tool_name(name: string): string {
|
||||||
|
return name.replace(/\./g, '__')
|
||||||
|
}
|
||||||
|
|
||||||
|
private from_openai_tool_name(name: string): string {
|
||||||
|
return name.replace(/__/g, '.')
|
||||||
|
}
|
||||||
|
|
||||||
|
private content_to_text(content: unknown): string {
|
||||||
|
if (typeof content === 'string') return content
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
return content.map(c => {
|
||||||
|
if (typeof c === 'string') return c
|
||||||
|
if (typeof c === 'object' && c !== null) {
|
||||||
|
const obj = c as Record<string, unknown>
|
||||||
|
if (obj.type === 'text') return String(obj.text || '')
|
||||||
|
return JSON.stringify(obj)
|
||||||
|
}
|
||||||
|
return String(c)
|
||||||
|
}).join('\n')
|
||||||
|
}
|
||||||
|
return String(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
private convert_raw_messages(messages: unknown[]): Array<Record<string, unknown>> {
|
private convert_raw_messages(messages: unknown[]): Array<Record<string, unknown>> {
|
||||||
return messages.map(m => {
|
return messages.flatMap(m => this.convert_one_message(m as { role: string; content: unknown }))
|
||||||
const obj = m as { role: string; content: unknown }
|
|
||||||
if (typeof obj.content === 'string') {
|
|
||||||
return { role: obj.role, content: obj.content }
|
|
||||||
}
|
|
||||||
return { role: obj.role, content: String(obj.content) }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private capability_matrix(model_id: string): ProviderCapabilityMatrix {
|
private capability_matrix(model_id: string): ProviderCapabilityMatrix {
|
||||||
|
|||||||
@@ -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,12 +20,23 @@ 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'
|
||||||
|
import { SessionRepository } from '../storage/repositories/SessionRepository.js'
|
||||||
|
import { MessageDraftRepository } from '../storage/repositories/MessageDraftRepository.js'
|
||||||
|
import { TaskAttemptRepository } from '../storage/repositories/TaskAttemptRepository.js'
|
||||||
|
import { TaskDependencyRepository } from '../storage/repositories/TaskDependencyRepository.js'
|
||||||
|
import { AgentRepository } from '../storage/repositories/AgentRepository.js'
|
||||||
|
import { ToolRunRepository } from '../storage/repositories/ToolRunRepository.js'
|
||||||
|
import { CommandRunRepository } from '../storage/repositories/CommandRunRepository.js'
|
||||||
|
import { ArtifactRepository } from '../storage/repositories/ArtifactRepository.js'
|
||||||
|
import { DiagnosticRepository } from '../storage/repositories/DiagnosticRepository.js'
|
||||||
|
import { WorkspaceRepository } from '../storage/repositories/WorkspaceRepository.js'
|
||||||
|
import { SummaryRepository } from '../storage/repositories/SummaryRepository.js'
|
||||||
import { createCapabilityRegistry, type CapabilityRegistry } from '../capabilities/CapabilityRegistry.js'
|
import { createCapabilityRegistry, type CapabilityRegistry } from '../capabilities/CapabilityRegistry.js'
|
||||||
|
|
||||||
export interface RuntimeAppConfig {
|
export interface RuntimeAppConfig {
|
||||||
@@ -37,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
|
||||||
@@ -75,19 +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)
|
||||||
this.event_store = raw_db
|
// Use module singleton eventStore - don't create separate instance
|
||||||
? new EventStore({ id: 'runtime', db: raw_db } as any)
|
this.event_store = eventStore
|
||||||
: new EventStore({ id: 'startup', db: null } as any)
|
this.event_ingestor = eventIngestor
|
||||||
this.event_ingestor = new EventIngestorImpl()
|
|
||||||
|
|
||||||
// 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({
|
||||||
@@ -140,21 +156,55 @@ 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: Recover interrupted tasks (INV-5: rebuild from SQLite)
|
// 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) {
|
||||||
const task_repo = new TaskRepository(raw_db as any)
|
const sessionRepo = new SessionRepository(raw_db as any)
|
||||||
const message_repo = new MessageRepository(raw_db as any)
|
const messageRepo = new MessageRepository(raw_db as any)
|
||||||
const evidence_repo = new EvidenceRepository(raw_db as any)
|
const messageDraftRepo = new MessageDraftRepository(raw_db as any)
|
||||||
this.context_assembler.set_data_sources({ message_repo, evidence_store: evidence_repo })
|
const taskRepo = new TaskRepository(raw_db as any)
|
||||||
this.scheduler.set_task_repo(task_repo)
|
const taskAttemptRepo = new TaskAttemptRepository(raw_db as any)
|
||||||
|
const taskDepRepo = new TaskDependencyRepository(raw_db as any)
|
||||||
|
const agentRepo = new AgentRepository(raw_db as any)
|
||||||
|
const toolRunRepo = new ToolRunRepository(raw_db as any)
|
||||||
|
const commandRunRepo = new CommandRunRepository(raw_db as any)
|
||||||
|
const artifactRepo = new ArtifactRepository(raw_db as any)
|
||||||
|
const diagnosticRepo = new DiagnosticRepository(raw_db as any)
|
||||||
|
const evidenceRepo = new EvidenceRepository(raw_db as any)
|
||||||
|
const workspaceRepo = new WorkspaceRepository(raw_db as any)
|
||||||
|
const summaryRepo = new SummaryRepository(raw_db as any)
|
||||||
|
|
||||||
|
this.event_store.setRepositories({
|
||||||
|
sessionRepo, messageRepo, messageDraftRepo, taskRepo, taskAttemptRepo,
|
||||||
|
taskDepRepo, agentRepo, toolRunRepo, commandRunRepo, artifactRepo,
|
||||||
|
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)
|
||||||
|
this.context_assembler.set_data_sources({ message_repo: messageRepo, evidence_store: evidenceRepo })
|
||||||
|
this.scheduler.set_task_repo(taskRepo)
|
||||||
const rehydrated = await this.scheduler.rebuild_from_db()
|
const rehydrated = await this.scheduler.rebuild_from_db()
|
||||||
this.logger.info('Scheduler recovery complete', { rehydrated })
|
this.logger.info('Scheduler recovery complete', { rehydrated })
|
||||||
}
|
}
|
||||||
@@ -165,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,43 @@ export class Scheduler {
|
|||||||
/**
|
/**
|
||||||
* Create tasks from specifications.
|
* Create tasks from specifications.
|
||||||
*/
|
*/
|
||||||
create_tasks(tasks: Array<{ id: TaskID; type: string; title: string; description?: string; depends_on?: string[] }>): void {
|
// Generate unique event ID with timestamp to avoid collisions on repeated asks
|
||||||
|
private generate_event_id(prefix: string): string {
|
||||||
|
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
await this.event_ingestor.ingest({
|
||||||
|
id: this.generate_event_id(`evt_${task.id}_created`),
|
||||||
|
type: 'task.created',
|
||||||
|
version: 1,
|
||||||
|
session_id: this.context.session_id,
|
||||||
|
project_id: this.context.project_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: { kind: 'scheduler' },
|
||||||
|
route: ['scheduler', 'create'],
|
||||||
|
payload: {
|
||||||
|
task_id: task.id,
|
||||||
|
type: task.type,
|
||||||
|
title: task.title,
|
||||||
|
task_spec_json: task.task_spec || { description: task.description || '' },
|
||||||
|
dependencies: (task.depends_on || []).map(d => ({ depends_on_task_id: d, dependency_type: 'hard', reason: '' })),
|
||||||
|
metadata: {},
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit task.created events (INV-1: via projection, not direct status write)
|
|
||||||
this.state = 'PLANNING_WAVE'
|
this.state = 'PLANNING_WAVE'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,25 +140,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,12 +167,13 @@ export class Scheduler {
|
|||||||
case 'DISPATCHING': {
|
case 'DISPATCHING': {
|
||||||
const runnable = this.graph.get_runnable_tasks()
|
const runnable = this.graph.get_runnable_tasks()
|
||||||
for (const task of runnable) {
|
for (const task of runnable) {
|
||||||
const agent_id = `agent_${task.id}`
|
const agent_id = `agent_${task.id}_${Date.now()}`
|
||||||
|
|
||||||
// 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({
|
const timestamp = Date.now()
|
||||||
id: `evt_${task.id}_started`,
|
await this.event_ingestor.ingest({
|
||||||
|
id: this.generate_event_id(`evt_${task.id}`),
|
||||||
type: 'task.started',
|
type: 'task.started',
|
||||||
version: 1,
|
version: 1,
|
||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
@@ -155,7 +181,7 @@ export class Scheduler {
|
|||||||
timestamp: now,
|
timestamp: now,
|
||||||
source: { kind: 'scheduler' },
|
source: { kind: 'scheduler' },
|
||||||
route: ['scheduler', 'dispatch'],
|
route: ['scheduler', 'dispatch'],
|
||||||
payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_1`, attempt_index: 0, workspace_id: `ws_${task.id}` }
|
payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_${timestamp}`, attempt_index: 0, workspace_id: `ws_${task.id}_${timestamp}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.worker_manager) {
|
if (this.worker_manager) {
|
||||||
@@ -166,7 +192,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 || '',
|
||||||
@@ -175,10 +201,32 @@ export class Scheduler {
|
|||||||
})
|
})
|
||||||
this.graph.update_status(task.id, 'running')
|
this.graph.update_status(task.id, 'running')
|
||||||
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
|
||||||
|
await this.event_ingestor.ingest({
|
||||||
|
id: this.generate_event_id(`evt_${agent_id}`),
|
||||||
|
type: 'agent.started',
|
||||||
|
version: 1,
|
||||||
|
session_id: this.context.session_id,
|
||||||
|
project_id: this.context.project_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: { kind: 'scheduler' },
|
||||||
|
route: ['scheduler', 'dispatch'],
|
||||||
|
payload: {
|
||||||
|
agent_id,
|
||||||
|
agent_type: task.type || 'executor',
|
||||||
|
task_id: task.id,
|
||||||
|
pid: 0,
|
||||||
|
model_provider_id: '',
|
||||||
|
model_id: '',
|
||||||
|
workspace_id: `ws_${task.id}`,
|
||||||
|
metadata: {},
|
||||||
|
}
|
||||||
|
})
|
||||||
} 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: this.generate_event_id(`evt_${task.id}`),
|
||||||
type: 'task.failed',
|
type: 'task.failed',
|
||||||
version: 1,
|
version: 1,
|
||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
@@ -186,7 +234,7 @@ export class Scheduler {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
source: { kind: 'scheduler' },
|
source: { kind: 'scheduler' },
|
||||||
route: ['scheduler', 'dispatch'],
|
route: ['scheduler', 'dispatch'],
|
||||||
payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_1`, error: { message: 'Worker spawn failed' }, evidence_refs: [], metadata: {} }
|
payload: { task_id: task.id, agent_id, attempt_id: `${task.id}_${Date.now()}`, error: { message: 'Worker spawn failed' }, evidence_refs: [], metadata: {} }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -204,8 +252,8 @@ 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: this.generate_event_id(`evt_${hb.task_id}`),
|
||||||
type: 'agent.lost',
|
type: 'agent.lost',
|
||||||
version: 1,
|
version: 1,
|
||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
@@ -228,8 +276,8 @@ 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: this.generate_event_id(`evt_${task_id}`),
|
||||||
type: 'agent.cancelled',
|
type: 'agent.cancelled',
|
||||||
version: 1,
|
version: 1,
|
||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
@@ -256,10 +304,10 @@ export class Scheduler {
|
|||||||
const result = this.worker_manager.get_result_for_task(task.id)
|
const result = this.worker_manager.get_result_for_task(task.id)
|
||||||
if (!handle || !result) continue
|
if (!handle || !result) continue
|
||||||
|
|
||||||
const attempt_id = `${task.id}_1`
|
const attempt_id = `${task.id}_${Date.now()}`
|
||||||
if (result.status === 'completed') {
|
if (result.status === 'completed') {
|
||||||
await eventIngestor.ingest({
|
await this.event_ingestor.ingest({
|
||||||
id: `evt_${task.id}_completed`,
|
id: this.generate_event_id(`evt_${task.id}`),
|
||||||
type: 'task.completed',
|
type: 'task.completed',
|
||||||
version: 1,
|
version: 1,
|
||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
@@ -278,10 +326,22 @@ 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
|
||||||
|
await this.event_ingestor.ingest({
|
||||||
|
id: this.generate_event_id(`evt_${handle.worker_id}`),
|
||||||
|
type: 'agent.completed',
|
||||||
|
version: 1,
|
||||||
|
session_id: this.context.session_id,
|
||||||
|
project_id: this.context.project_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: { kind: 'scheduler' },
|
||||||
|
route: ['scheduler', 'monitoring'],
|
||||||
|
payload: { agent_id: handle.worker_id, task_id: task.id, summary: result.summary, worker_result_ref: attempt_id, metadata: {} }
|
||||||
|
})
|
||||||
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: this.generate_event_id(`evt_${task.id}`),
|
||||||
type: 'task.blocked',
|
type: 'task.blocked',
|
||||||
version: 1,
|
version: 1,
|
||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
@@ -294,8 +354,8 @@ 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: this.generate_event_id(`evt_${task.id}`),
|
||||||
type: 'task.cancelled',
|
type: 'task.cancelled',
|
||||||
version: 1,
|
version: 1,
|
||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
@@ -308,8 +368,8 @@ 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: this.generate_event_id(`evt_${task.id}`),
|
||||||
type: 'task.failed',
|
type: 'task.failed',
|
||||||
version: 1,
|
version: 1,
|
||||||
session_id: this.context.session_id,
|
session_id: this.context.session_id,
|
||||||
@@ -320,6 +380,18 @@ export class Scheduler {
|
|||||||
payload: { task_id: task.id, agent_id: handle.worker_id, attempt_id, error: { message: result.summary }, evidence_refs: result.evidence_refs, metadata: { worker_status: result.status } }
|
payload: { task_id: task.id, agent_id: handle.worker_id, attempt_id, error: { message: result.summary }, evidence_refs: result.evidence_refs, metadata: { worker_status: result.status } }
|
||||||
})
|
})
|
||||||
this.graph.update_status(task.id, 'failed')
|
this.graph.update_status(task.id, 'failed')
|
||||||
|
// INV-1: Emit agent.failed event (durable) for projection
|
||||||
|
await this.event_ingestor.ingest({
|
||||||
|
id: this.generate_event_id(`evt_${handle.worker_id}`),
|
||||||
|
type: 'agent.failed',
|
||||||
|
version: 1,
|
||||||
|
session_id: this.context.session_id,
|
||||||
|
project_id: this.context.project_id,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
source: { kind: 'scheduler' },
|
||||||
|
route: ['scheduler', 'monitoring'],
|
||||||
|
payload: { agent_id: handle.worker_id, task_id: task.id, error: { message: result.summary }, evidence_refs: result.evidence_refs || [], metadata: {} }
|
||||||
|
})
|
||||||
this.agent_monitor.remove(handle.worker_id)
|
this.agent_monitor.remove(handle.worker_id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,6 +447,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 {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export type AgentInsert = Omit<AgentRecord, 'id' | 'status'> & {
|
|||||||
id?: AgentID
|
id?: AgentID
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentUpdate = Partial<Omit<AgentRecord, 'id' | 'session_id' | 'started_at' | 'status'>>
|
export type AgentUpdate = Partial<Omit<AgentRecord, 'id' | 'session_id' | 'started_at'>>
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// AgentRepository
|
// AgentRepository
|
||||||
@@ -105,7 +105,11 @@ export class AgentRepository implements Repository<AgentRecord, AgentInsert, Age
|
|||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
// NOTE: status is NOT updatable here - only EventStore.project() writes status columns
|
// status reaches here only via EventStore.project() (INV-1's authorized writer).
|
||||||
|
if (patch.status !== undefined) {
|
||||||
|
fields.push('status = ?')
|
||||||
|
values.push(patch.status)
|
||||||
|
}
|
||||||
if (patch.pid !== undefined) {
|
if (patch.pid !== undefined) {
|
||||||
fields.push('pid = ?')
|
fields.push('pid = ?')
|
||||||
values.push(patch.pid)
|
values.push(patch.pid)
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export type TaskAttemptInsert = Omit<TaskAttemptRecord, 'id' | 'status'> & {
|
|||||||
id?: UUID
|
id?: UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskAttemptUpdate = Partial<Omit<TaskAttemptRecord, 'id' | 'session_id' | 'task_id' | 'attempt_index' | 'started_at' | 'status'>>
|
export type TaskAttemptUpdate = Partial<Omit<TaskAttemptRecord, 'id' | 'session_id' | 'task_id' | 'attempt_index' | 'started_at'>>
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TaskAttemptRepository
|
// TaskAttemptRepository
|
||||||
@@ -106,7 +106,11 @@ export class TaskAttemptRepository implements Repository<TaskAttemptRecord, Task
|
|||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
// NOTE: status is NOT updatable here - only EventStore.project() writes status columns
|
// status reaches here only via EventStore.project() (INV-1's authorized writer).
|
||||||
|
if (patch.status !== undefined) {
|
||||||
|
fields.push('status = ?')
|
||||||
|
values.push(patch.status)
|
||||||
|
}
|
||||||
if (patch.agent_id !== undefined) {
|
if (patch.agent_id !== undefined) {
|
||||||
fields.push('agent_id = ?')
|
fields.push('agent_id = ?')
|
||||||
values.push(patch.agent_id)
|
values.push(patch.agent_id)
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export type TaskInsert = Omit<TaskRecord, 'id' | 'status'> & {
|
|||||||
heartbeat_at?: ISOTimeString
|
heartbeat_at?: ISOTimeString
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TaskUpdate = Partial<Omit<TaskRecord, 'id' | 'session_id' | 'created_at' | 'status'>>
|
// status IS updatable — but only reachable via EventStore.project() (INV-1).
|
||||||
|
// project() is the sole caller of update(); guarding status here would block
|
||||||
|
// the one authorized writer and freeze every row at its insert-time status.
|
||||||
|
export type TaskUpdate = Partial<Omit<TaskRecord, 'id' | 'session_id' | 'created_at'>>
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TaskRepository
|
// TaskRepository
|
||||||
@@ -118,7 +121,11 @@ export class TaskRepository implements Repository<TaskRecord, TaskInsert, TaskUp
|
|||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
// NOTE: status is NOT updatable here - only EventStore.project() writes status columns
|
// status reaches here only via EventStore.project() (INV-1's authorized writer).
|
||||||
|
if (patch.status !== undefined) {
|
||||||
|
fields.push('status = ?')
|
||||||
|
values.push(patch.status)
|
||||||
|
}
|
||||||
if (patch.title !== undefined) {
|
if (patch.title !== undefined) {
|
||||||
fields.push('title = ?')
|
fields.push('title = ?')
|
||||||
values.push(patch.title)
|
values.push(patch.title)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export type ToolRunInsert = Omit<ToolRunRecord, 'id' | 'status'> & {
|
|||||||
id?: ToolRunID
|
id?: ToolRunID
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolRunUpdate = Partial<Omit<ToolRunRecord, 'id' | 'session_id' | 'tool_name' | 'started_at' | 'status'>>
|
export type ToolRunUpdate = Partial<Omit<ToolRunRecord, 'id' | 'session_id' | 'tool_name' | 'started_at'>>
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// ToolRunRepository
|
// ToolRunRepository
|
||||||
@@ -114,7 +114,11 @@ export class ToolRunRepository implements Repository<ToolRunRecord, ToolRunInser
|
|||||||
const fields: string[] = []
|
const fields: string[] = []
|
||||||
const values: unknown[] = []
|
const values: unknown[] = []
|
||||||
|
|
||||||
// NOTE: status is NOT updatable here - only EventStore.project() writes status columns
|
// status reaches here only via EventStore.project() (INV-1's authorized writer).
|
||||||
|
if (patch.status !== undefined) {
|
||||||
|
fields.push('status = ?')
|
||||||
|
values.push(patch.status)
|
||||||
|
}
|
||||||
if (patch.output_json !== undefined) {
|
if (patch.output_json !== undefined) {
|
||||||
fields.push('output_json = ?')
|
fields.push('output_json = ?')
|
||||||
values.push(patch.output_json)
|
values.push(patch.output_json)
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,9 +8,64 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'fs'
|
import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from 'fs'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
import { join, dirname, basename, extname } from 'path'
|
import { join, dirname, basename, extname } from 'path'
|
||||||
import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from '@aircoding/contracts'
|
import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from '@aircoding/contracts'
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Read File State (for read-before-edit enforcement)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
interface ReadFileState {
|
||||||
|
timestamp: number
|
||||||
|
sha256: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session-scoped read file state - keyed by absolute path
|
||||||
|
const read_file_state = new Map<string, ReadFileState>()
|
||||||
|
|
||||||
|
function compute_sha256(content: string): string {
|
||||||
|
return createHash('sha256').update(content).digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function record_file_read(abs_path: string, content: string): void {
|
||||||
|
read_file_state.set(abs_path, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sha256: compute_sha256(content),
|
||||||
|
size: content.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function check_file_read_state(abs_path: string, current_content: string): { allowed: boolean; error?: string } {
|
||||||
|
const state = read_file_state.get(abs_path)
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
error: 'File has not been read yet. Read it first before editing.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const current_sha = compute_sha256(current_content)
|
||||||
|
if (current_sha !== state.sha256) {
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
error: 'File has been unexpectedly modified. Read it again before editing.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_file_state(abs_path: string, new_content: string): void {
|
||||||
|
read_file_state.set(abs_path, {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
sha256: compute_sha256(new_content),
|
||||||
|
size: new_content.length
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tool Definitions
|
// Tool Definitions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -65,11 +120,13 @@ export const fs_edit: ToolDefinition = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
path: { type: 'string', description: 'File path to edit' },
|
path: { type: 'string', description: 'File path to edit' },
|
||||||
find: { type: 'string', description: 'Exact text to find' },
|
find: { type: 'string', description: 'Exact text to find (alias: old_str)' },
|
||||||
replace: { type: 'string', description: 'Text to replace with' },
|
replace: { type: 'string', description: 'Text to replace with (alias: new_str)' },
|
||||||
global: { type: 'boolean', default: false, description: 'Replace all occurrences' }
|
old_str: { type: 'string', description: 'Alias for find' },
|
||||||
|
new_str: { type: 'string', description: 'Alias for replace' },
|
||||||
|
global: { type: 'boolean', default: false, description: 'Replace all occurrences (alias: replace_all)' }
|
||||||
},
|
},
|
||||||
required: ['path', 'find', 'replace']
|
required: ['path']
|
||||||
},
|
},
|
||||||
permissions: { read_paths: { allow: ["*"] }, write_paths: { allow: ["*"] } },
|
permissions: { read_paths: { allow: ["*"] }, write_paths: { allow: ["*"] } },
|
||||||
streaming: false
|
streaming: false
|
||||||
@@ -153,6 +210,9 @@ export function createFsExecutors(project_root: string) {
|
|||||||
? content.toString('base64')
|
? content.toString('base64')
|
||||||
: content.toString('utf-8')
|
: content.toString('utf-8')
|
||||||
|
|
||||||
|
// Record file read for read-before-edit enforcement
|
||||||
|
record_file_read(full_path, content.toString('utf-8'))
|
||||||
|
|
||||||
return create_result(call.call_id, 'fs.read', 'text', { content: output, size: content.length })
|
return create_result(call.call_id, 'fs.read', 'text', { content: output, size: content.length })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return create_result(call.call_id, 'fs.read', 'error', { message: error instanceof Error ? error.message : String(error) })
|
return create_result(call.call_id, 'fs.read', 'error', { message: error instanceof Error ? error.message : String(error) })
|
||||||
@@ -181,7 +241,16 @@ export function createFsExecutors(project_root: string) {
|
|||||||
? Buffer.from(content, 'base64')
|
? Buffer.from(content, 'base64')
|
||||||
: Buffer.from(content, 'utf-8')
|
: Buffer.from(content, 'utf-8')
|
||||||
|
|
||||||
|
if (existsSync(full_path)) {
|
||||||
|
const original = readFileSync(full_path, 'utf-8')
|
||||||
|
const read_check = check_file_read_state(full_path, original)
|
||||||
|
if (!read_check.allowed) {
|
||||||
|
return create_result(call.call_id, 'fs.write', 'error', { message: read_check.error })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeFileSync(full_path, data)
|
writeFileSync(full_path, data)
|
||||||
|
update_file_state(full_path, data.toString('utf-8'))
|
||||||
return create_result(call.call_id, 'fs.write', 'text', { message: `Written to ${path}`, size: data.length })
|
return create_result(call.call_id, 'fs.write', 'text', { message: `Written to ${path}`, size: data.length })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return create_result(call.call_id, 'fs.write', 'error', { message: error instanceof Error ? error.message : String(error) })
|
return create_result(call.call_id, 'fs.write', 'error', { message: error instanceof Error ? error.message : String(error) })
|
||||||
@@ -189,11 +258,15 @@ export function createFsExecutors(project_root: string) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
'fs.edit': async (call: ToolCall): Promise<ToolResultEnvelope> => {
|
'fs.edit': async (call: ToolCall): Promise<ToolResultEnvelope> => {
|
||||||
const { path, find, replace, global = false } = call.arguments as {
|
// Support both old_str/new_str (ExecutorRole) and find/replace (UI) parameter names
|
||||||
path: string
|
const args = call.arguments as Record<string, unknown>
|
||||||
find: string
|
const path = args.path as string
|
||||||
replace: string
|
const find = (args.find ?? args.old_str ?? '') as string
|
||||||
global?: boolean
|
const replace = (args.replace ?? args.new_str ?? '') as string
|
||||||
|
const global = (args.global ?? args.replace_all ?? false) as boolean
|
||||||
|
|
||||||
|
if (!find) {
|
||||||
|
return create_result(call.call_id, 'fs.edit', 'error', { message: 'Missing find/old_str parameter' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const full_path = resolve_path(path)
|
const full_path = resolve_path(path)
|
||||||
@@ -205,11 +278,25 @@ export function createFsExecutors(project_root: string) {
|
|||||||
try {
|
try {
|
||||||
const original = readFileSync(full_path, 'utf-8')
|
const original = readFileSync(full_path, 'utf-8')
|
||||||
|
|
||||||
// Read-before-edit enforcement (DD §9.4)
|
// Read-before-edit enforcement (DD §9.4) - code layer, not prompt
|
||||||
|
const read_check = check_file_read_state(full_path, original)
|
||||||
|
if (!read_check.allowed) {
|
||||||
|
return create_result(call.call_id, 'fs.edit', 'error', { message: read_check.error })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact edit: old_str must exist uniquely
|
||||||
if (!original.includes(find)) {
|
if (!original.includes(find)) {
|
||||||
return create_result(call.call_id, 'fs.edit', 'error', { message: 'Exact text not found in file' })
|
return create_result(call.call_id, 'fs.edit', 'error', { message: 'Exact text not found in file' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for uniqueness when not global
|
||||||
|
if (!global) {
|
||||||
|
const matches = original.split(find)
|
||||||
|
if (matches.length > 2) {
|
||||||
|
return create_result(call.call_id, 'fs.edit', 'error', { message: 'Text appears multiple times. Use global=true or provide more context to make it unique.' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let edited: string
|
let edited: string
|
||||||
if (global) {
|
if (global) {
|
||||||
edited = original.split(find).join(replace)
|
edited = original.split(find).join(replace)
|
||||||
@@ -219,6 +306,9 @@ export function createFsExecutors(project_root: string) {
|
|||||||
|
|
||||||
writeFileSync(full_path, edited, 'utf-8')
|
writeFileSync(full_path, edited, 'utf-8')
|
||||||
|
|
||||||
|
// Update read state after successful edit
|
||||||
|
update_file_state(full_path, edited)
|
||||||
|
|
||||||
// Emit diff artifact (DD §9.4)
|
// Emit diff artifact (DD §9.4)
|
||||||
return create_result(call.call_id, 'fs.edit', 'text', {
|
return create_result(call.call_id, 'fs.edit', 'text', {
|
||||||
message: `Edited ${path}`,
|
message: `Edited ${path}`,
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -219,13 +221,15 @@ export class WorkerManager {
|
|||||||
const response = await this.provider_manager.complete_text(messages, {
|
const response = await this.provider_manager.complete_text(messages, {
|
||||||
model: msg.payload.model as string,
|
model: msg.payload.model as string,
|
||||||
max_tokens: msg.payload.max_tokens as number,
|
max_tokens: msg.payload.max_tokens as number,
|
||||||
temperature: msg.payload.temperature as number
|
temperature: msg.payload.temperature as number,
|
||||||
|
tools: (msg.payload.tools as unknown[]) || this.tool_registry?.list?.() || []
|
||||||
})
|
})
|
||||||
|
|
||||||
this.send_to_worker(agent_id, 'llm.response', {
|
this.send_to_worker(agent_id, 'llm.response', {
|
||||||
call_id,
|
call_id,
|
||||||
content: response.content,
|
content: response.content,
|
||||||
usage: response.usage
|
usage: response.usage,
|
||||||
|
tool_calls: response.tool_calls
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.send_to_worker(agent_id, 'llm.response', {
|
this.send_to_worker(agent_id, 'llm.response', {
|
||||||
@@ -236,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)
|
||||||
@@ -322,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
|
||||||
@@ -369,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
|
||||||
@@ -379,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)
|
||||||
@@ -24,7 +25,6 @@ describe('Release critical gates', () => {
|
|||||||
for (const [name, args] of [
|
for (const [name, args] of [
|
||||||
['fs.stat', { path: 'sample.txt' }],
|
['fs.stat', { path: 'sample.txt' }],
|
||||||
['project.scan', { root: '.' }],
|
['project.scan', { root: '.' }],
|
||||||
['cpp.detect', { project_root: projectRoot }],
|
|
||||||
['doctor.run', { scope: 'all' }],
|
['doctor.run', { scope: 'all' }],
|
||||||
] as Array<[string, Record<string, unknown>]>) {
|
] as Array<[string, Record<string, unknown>]>) {
|
||||||
const result = await registry.call({ call_id: `call-${name}`, name, arguments: args }, ctx)
|
const result = await registry.call({ call_id: `call-${name}`, name, arguments: args }, ctx)
|
||||||
@@ -59,8 +59,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 +68,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'")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* C7 regression: All 28 MVP tools registered
|
* C7 regression: All MVP tools registered
|
||||||
* Validates that BuiltInToolRegistrar registers all 28 tool-registry-v1 MVP tools
|
* Validates that BuiltInToolRegistrar registers built-in tools (non-cpp)
|
||||||
* plus extra built-in tools, with stub executors for Alpha-scope tools.
|
* and that CppToolRegistrar registers cpp.* tools separately.
|
||||||
*
|
*
|
||||||
* Tests actual ToolRegistry state rather than source text inspection.
|
* Tests actual ToolRegistry state rather than source text inspection.
|
||||||
*/
|
*/
|
||||||
@@ -15,22 +15,26 @@ const registrar = new BuiltInToolRegistrar(registry)
|
|||||||
registrar.register_all('/tmp/test-air')
|
registrar.register_all('/tmp/test-air')
|
||||||
const tools = registry.list()
|
const tools = registry.list()
|
||||||
|
|
||||||
// 28 MVP tools from tool-registry-v1 §11
|
// Built-in tools (non-cpp, registered by BuiltInToolRegistrar)
|
||||||
const MVP_TOOLS = [
|
const BUILTIN_TOOLS = [
|
||||||
'fs.list', 'fs.read', 'fs.write', 'fs.edit', 'fs.patch', 'fs.stat',
|
'fs.list', 'fs.read', 'fs.write', 'fs.edit', 'fs.patch', 'fs.stat',
|
||||||
'shell.run', 'process.kill',
|
'shell.run', 'process.kill',
|
||||||
'git.status', 'git.diff', 'git.worktree.create', 'git.merge_workspace',
|
'git.status', 'git.diff', 'git.worktree.create', 'git.merge_workspace',
|
||||||
'project.scan', 'project.profile.write',
|
'project.scan', 'project.profile.write',
|
||||||
'cpp.detect', 'cpp.cmake.configure', 'cpp.build', 'cpp.test',
|
|
||||||
'cpp.static.cppcheck', 'cpp.clangd.query',
|
|
||||||
'debug.run', 'debug.parse_logs',
|
'debug.run', 'debug.parse_logs',
|
||||||
'gui.screenshot', 'network.capture',
|
'gui.screenshot', 'network.capture',
|
||||||
'artifact.create', 'context.assemble',
|
'artifact.create', 'context.assemble',
|
||||||
'permission.request', 'doctor.run',
|
'permission.request', 'doctor.run',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// cpp tools registered by CppToolRegistrar (tested via RuntimeApp integration)
|
||||||
|
const CPP_TOOLS = [
|
||||||
|
'cpp.detect', 'cpp.configure', 'cpp.build', 'cpp.test',
|
||||||
|
'cpp.cppcheck', 'cpp.clangd',
|
||||||
|
]
|
||||||
|
|
||||||
describe('C7: MVP tool registrations', () => {
|
describe('C7: MVP tool registrations', () => {
|
||||||
for (const tool_name of MVP_TOOLS) {
|
for (const tool_name of BUILTIN_TOOLS) {
|
||||||
it(`registers ${tool_name}`, () => {
|
it(`registers ${tool_name}`, () => {
|
||||||
const found = tools.find(t => t.name === tool_name)
|
const found = tools.find(t => t.name === tool_name)
|
||||||
expect(found).toBeDefined()
|
expect(found).toBeDefined()
|
||||||
@@ -38,19 +42,30 @@ describe('C7: MVP tool registrations', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
it('has at least 28 tools registered', () => {
|
it('has at least 22 built-in tools registered', () => {
|
||||||
expect(tools.length).toBeGreaterThanOrEqual(28)
|
expect(tools.length).toBeGreaterThanOrEqual(22)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('stub tools produce text envelope with alpha_stub metadata', async () => {
|
it('stub tools produce structured envelope', async () => {
|
||||||
// Pick a stub tool and verify its executor returns structured envelope
|
const stub_names = ['process.kill', 'gui.screenshot', 'network.capture']
|
||||||
const stub_names = ['process.kill', 'cpp.clangd.query', 'gui.screenshot', 'network.capture']
|
|
||||||
for (const name of stub_names) {
|
for (const name of stub_names) {
|
||||||
const tool = tools.find(t => t.name === name)
|
const tool = tools.find(t => t.name === name)
|
||||||
expect(tool).toBeDefined()
|
expect(tool).toBeDefined()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('cpp tools registered via CppToolRegistrar (not BuiltInToolRegistrar)', async () => {
|
||||||
|
const { CppToolRegistrar } = await import('@aircoding/toolchain-cpp')
|
||||||
|
const cppRegistry = new ToolRegistry('/tmp/test-air-cpp')
|
||||||
|
const cppRegistrar = new CppToolRegistrar()
|
||||||
|
cppRegistrar.register(cppRegistry, '/tmp/test-air-cpp')
|
||||||
|
const cppTools = cppRegistry.list()
|
||||||
|
for (const name of CPP_TOOLS) {
|
||||||
|
const found = cppTools.find((t: any) => t.name === name)
|
||||||
|
expect(found).toBeDefined()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('create_stub_definitions and create_real_executor exist', () => {
|
it('create_stub_definitions and create_real_executor exist', () => {
|
||||||
expect(typeof (BuiltInToolRegistrar.prototype as any).create_stub_definitions).toBe('function')
|
expect(typeof (BuiltInToolRegistrar.prototype as any).create_stub_definitions).toBe('function')
|
||||||
expect(typeof (BuiltInToolRegistrar.prototype as any).create_real_executor).toBe('function')
|
expect(typeof (BuiltInToolRegistrar.prototype as any).create_real_executor).toBe('function')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export interface LLMRequest {
|
|||||||
export interface LLMResponse {
|
export interface LLMResponse {
|
||||||
content: string
|
content: string
|
||||||
usage?: { input_tokens: number; output_tokens: number }
|
usage?: { input_tokens: number; output_tokens: number }
|
||||||
tool_calls?: Array<{ name: string; arguments: Record<string, unknown> }>
|
tool_calls?: Array<{ id?: string; name: string; arguments: Record<string, unknown> }>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WorkerRuntime {
|
export class WorkerRuntime {
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,11 @@ export interface ExecutorResult {
|
|||||||
evidence_refs?: string[]
|
evidence_refs?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ExecutorAction =
|
||||||
|
| { type: 'text' }
|
||||||
|
| { type: 'code_block'; filename: string; content: string }
|
||||||
|
| { type: 'tool_call'; id: string; name: string; args: Record<string, unknown> }
|
||||||
|
|
||||||
export class ExecutorRole {
|
export class ExecutorRole {
|
||||||
private runtime: WorkerRuntime
|
private runtime: WorkerRuntime
|
||||||
private max_turns: number = 15
|
private max_turns: number = 15
|
||||||
@@ -25,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 || '.'
|
||||||
@@ -36,25 +41,23 @@ 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.
|
||||||
|
|
||||||
You can write files by outputting code blocks with a language tag that includes the filename:
|
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:
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
{"tool":"fs.write","args":{"path":"src/main.cpp","content":"..."}}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
You may write new files by outputting code blocks with a language tag that includes the filename:
|
||||||
\`\`\`cpp:src/main.cpp
|
\`\`\`cpp:src/main.cpp
|
||||||
// C++ code here
|
// C++ code here
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
\`\`\`cmake:CMakeLists.txt
|
After all required files are written and required verification has passed, write a line containing exactly: DONE`
|
||||||
# CMake code here
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Or any language: python, javascript, txt, etc.
|
|
||||||
The filename goes after the language tag, separated by colon.
|
|
||||||
|
|
||||||
You can also call tools directly:
|
|
||||||
fs.read("path/to/file") — read a file
|
|
||||||
fs.write("path/to/file", "content") — write a file
|
|
||||||
shell.run("command") — run a shell command
|
|
||||||
fs.list("dir") — list a directory
|
|
||||||
|
|
||||||
After completing ALL required files, write: DONE`
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -81,8 +84,8 @@ After completing ALL required files, write: DONE`
|
|||||||
.replace(/<\|assistant\|>/g, '')
|
.replace(/<\|assistant\|>/g, '')
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
// Parse ALL actions from the response
|
// Parse structured actions from native tool_calls first, then strict JSON/code-block fallback
|
||||||
const actions = this.parse_actions(text)
|
const actions = this.parse_actions(text, llm_response.tool_calls || [])
|
||||||
|
|
||||||
// Debug
|
// Debug
|
||||||
const actionSummary = actions.map(a => {
|
const actionSummary = actions.map(a => {
|
||||||
@@ -97,7 +100,7 @@ After completing ALL required files, write: DONE`
|
|||||||
let allSucceeded = true
|
let allSucceeded = true
|
||||||
|
|
||||||
if (hadActions) {
|
if (hadActions) {
|
||||||
messages.push({ role: 'assistant', content: text })
|
messages.push({ role: 'assistant', content: this.assistant_content_for_actions(text, actions) })
|
||||||
|
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
if (action.type === 'code_block') {
|
if (action.type === 'code_block') {
|
||||||
@@ -116,7 +119,7 @@ After completing ALL required files, write: DONE`
|
|||||||
messages.push({ role: 'user', content: `Error writing ${filename}: ${e.message}` })
|
messages.push({ role: 'user', content: `Error writing ${filename}: ${e.message}` })
|
||||||
}
|
}
|
||||||
} else if (action.type === 'tool_call') {
|
} else if (action.type === 'tool_call') {
|
||||||
const { name, args } = action as { name: string; args: Record<string, unknown> }
|
const { id, name, args } = action as { id: string; name: string; args: Record<string, unknown> }
|
||||||
try {
|
try {
|
||||||
const result = await this.runtime.call_tool(name, args)
|
const result = await this.runtime.call_tool(name, args)
|
||||||
const output = result.type === 'error'
|
const output = result.type === 'error'
|
||||||
@@ -125,10 +128,16 @@ After completing ALL required files, write: DONE`
|
|||||||
if (name === 'fs.write' && args.path) changes.push({ file: args.path as string, type: 'create' })
|
if (name === 'fs.write' && args.path) changes.push({ file: args.path as string, type: 'create' })
|
||||||
if (name === 'fs.edit' && args.path) changes.push({ file: args.path as string, type: 'edit' })
|
if (name === 'fs.edit' && args.path) changes.push({ file: args.path as string, type: 'edit' })
|
||||||
if (result.type === 'error') allSucceeded = false
|
if (result.type === 'error') allSucceeded = false
|
||||||
messages.push({ role: 'user', content: `Tool ${name}(${args.path || ''}): ${output.slice(0, 500)}` })
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'tool_result', tool_use_id: id, content: output.slice(0, 5000), is_error: result.type === 'error' }]
|
||||||
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
allSucceeded = false
|
allSucceeded = false
|
||||||
messages.push({ role: 'user', content: `Tool ${name} error: ${e.message}` })
|
messages.push({
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'tool_result', tool_use_id: id, content: `Tool ${name} error: ${e.message}`, is_error: true }]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,11 +148,16 @@ After completing ALL required files, write: DONE`
|
|||||||
messages.push({ role: 'user', content: 'You signaled DONE, but one or more tool actions failed. Fix the failed actions before signaling DONE.' })
|
messages.push({ role: 'user', content: 'You signaled DONE, but one or more tool actions failed. Fix the failed actions before signaling DONE.' })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
const verification = await this.verify_before_completion(task_spec, changes)
|
||||||
|
if (!verification.passed) {
|
||||||
|
messages.push({ role: 'user', content: `Verification failed; do not say DONE until fixed.\n${verification.output}` })
|
||||||
|
continue
|
||||||
|
}
|
||||||
await this.runtime.checkpoint('task_completed', { task_id: task_spec.id })
|
await this.runtime.checkpoint('task_completed', { task_id: task_spec.id })
|
||||||
return {
|
return {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
changes,
|
changes,
|
||||||
verification: { passed: true, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` },
|
verification,
|
||||||
evidence_refs: []
|
evidence_refs: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,17 +177,22 @@ After completing ALL required files, write: DONE`
|
|||||||
messages.push({ role: 'user', content: 'You said DONE but no files were created. Please create the required files first.' })
|
messages.push({ role: 'user', content: 'You said DONE but no files were created. Please create the required files first.' })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
const verification = await this.verify_before_completion(task_spec, changes)
|
||||||
|
if (!verification.passed) {
|
||||||
|
messages.push({ role: 'user', content: `Verification failed; do not say DONE until fixed.\n${verification.output}` })
|
||||||
|
continue
|
||||||
|
}
|
||||||
await this.runtime.checkpoint('task_completed', { task_id: task_spec.id })
|
await this.runtime.checkpoint('task_completed', { task_id: task_spec.id })
|
||||||
return {
|
return {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
changes,
|
changes,
|
||||||
verification: { passed: true, output: `${changes.length} files created` },
|
verification,
|
||||||
evidence_refs: []
|
evidence_refs: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.push({ role: 'assistant', content: text })
|
messages.push({ role: 'assistant', content: text })
|
||||||
messages.push({ role: 'user', content: 'Please CREATE the files. Use code blocks with filename tags or fs.write() tool calls. When done creating ALL files, respond DONE.' })
|
messages.push({ role: 'user', content: 'Please CREATE the files. Use native tools, strict JSON tool_call blocks, or code blocks with filename tags. When done creating ALL files and required verification passes, respond DONE.' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,10 +204,6 @@ After completing ALL required files, write: DONE`
|
|||||||
}
|
}
|
||||||
|
|
||||||
} 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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,24 +215,88 @@ After completing ALL required files, write: DONE`
|
|||||||
.some(line => line === 'DONE' || line === 'TASK_COMPLETE')
|
.some(line => line === 'DONE' || line === 'TASK_COMPLETE')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async verify_before_completion(
|
||||||
* Parse ALL actions from LLM response: code blocks and tool calls.
|
task_spec: { acceptance_criteria: string[]; title: string; description: string },
|
||||||
*/
|
changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }>
|
||||||
private parse_actions(text: string): Array<
|
): Promise<{ passed: boolean; output: string }> {
|
||||||
{ type: 'text' } |
|
if (!this.requires_executable_verification(task_spec)) {
|
||||||
{ type: 'code_block'; filename: string; content: string } |
|
return { passed: true, output: `${changes.length} files: ${changes.map(c => c.file).join(', ')}` }
|
||||||
{ type: 'tool_call'; name: string; args: Record<string, unknown> }
|
}
|
||||||
> {
|
|
||||||
const actions: Array<any> = []
|
const command = this.verification_command(task_spec, changes)
|
||||||
|
if (!command) {
|
||||||
|
return { passed: false, output: 'Acceptance criteria require executable verification, but no verification command could be derived.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.runtime.call_tool('shell.run', { command, timeout: 300000 })
|
||||||
|
const payload = result.content as { exit_code?: number; stdout?: string; stderr?: string; message?: string }
|
||||||
|
if (result.type === 'error') {
|
||||||
|
return {
|
||||||
|
passed: false,
|
||||||
|
output: `Verification command failed: ${command}\n${payload.stderr || payload.stdout || payload.message || JSON.stringify(payload)}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
passed: true,
|
||||||
|
output: `Verification command passed: ${command}\n${payload.stdout || ''}`.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private requires_executable_verification(task_spec: { acceptance_criteria: string[]; title: string; description: string }): boolean {
|
||||||
|
const text = `${task_spec.title}\n${task_spec.description}\n${task_spec.acceptance_criteria.join('\n')}`.toLowerCase()
|
||||||
|
return /\b(build|compile|run|test|cmake|make|pytest|npm test|bun test)\b|编译|构建|运行|测试/.test(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private verification_command(
|
||||||
|
task_spec: { title: string; description: string; acceptance_criteria: string[] },
|
||||||
|
changes: Array<{ file: string; type: 'create' | 'edit' | 'delete' }>
|
||||||
|
): string | null {
|
||||||
|
const text = `${task_spec.title}\n${task_spec.description}\n${task_spec.acceptance_criteria.join('\n')}`.toLowerCase()
|
||||||
|
const files = new Set(changes.map(c => c.file))
|
||||||
|
|
||||||
|
if (files.has('CMakeLists.txt') || text.includes('cmake')) {
|
||||||
|
return 'cmake -S . -B build && cmake --build build'
|
||||||
|
}
|
||||||
|
if ([...files].some(f => f.endsWith('.cpp') || f.endsWith('.cc') || f.endsWith('.cxx'))) {
|
||||||
|
const file = [...files].find(f => f.endsWith('.cpp') || f.endsWith('.cc') || f.endsWith('.cxx')) || 'main.cpp'
|
||||||
|
return `c++ ${file} -o /tmp/aircoding-verify && /tmp/aircoding-verify`
|
||||||
|
}
|
||||||
|
if (files.has('package.json') || text.includes('npm test')) return 'npm test'
|
||||||
|
if (text.includes('bun test')) return 'bun test'
|
||||||
|
if ([...files].some(f => f.endsWith('.py')) && text.includes('test')) return 'python3 -m pytest'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse structured actions from native tool calls and strict JSON fallback.
|
||||||
|
*/
|
||||||
|
private parse_actions(
|
||||||
|
text: string,
|
||||||
|
native_tool_calls: Array<{ id?: string; name: string; arguments: Record<string, unknown> }> = []
|
||||||
|
): ExecutorAction[] {
|
||||||
|
const actions: ExecutorAction[] = []
|
||||||
|
|
||||||
|
for (const call of native_tool_calls) {
|
||||||
|
actions.push({
|
||||||
|
type: 'tool_call',
|
||||||
|
id: call.id || crypto.randomUUID(),
|
||||||
|
name: call.name,
|
||||||
|
args: call.arguments || {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pattern 1: Code blocks with filename tags ──
|
|
||||||
// ```cpp:src/main.cpp or ```cpp:main.cpp or ```cpp main.cpp
|
|
||||||
const codeBlockRe = /```(\w+)(?::(\S+)|\s+(\S+))?\s*\n([\s\S]*?)```/g
|
const codeBlockRe = /```(\w+)(?::(\S+)|\s+(\S+))?\s*\n([\s\S]*?)```/g
|
||||||
for (const match of text.matchAll(codeBlockRe)) {
|
for (const match of text.matchAll(codeBlockRe)) {
|
||||||
const lang = match[1]
|
const lang = match[1]
|
||||||
let filename = match[2] || match[3] || ''
|
const inner = match[4].trim()
|
||||||
|
if (lang === 'json' || lang === 'tool' || lang === 'tool_call') {
|
||||||
|
const parsed = this.parse_json_tool_call(inner)
|
||||||
|
if (parsed) actions.push(parsed)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Infer filename from language
|
let filename = match[2] || match[3] || ''
|
||||||
if (!filename || filename.length < 2) {
|
if (!filename || filename.length < 2) {
|
||||||
const extMap: Record<string, string> = {
|
const extMap: Record<string, string> = {
|
||||||
cpp: 'main.cpp', c: 'main.c', h: 'header.h', hpp: 'header.hpp',
|
cpp: 'main.cpp', c: 'main.c', h: 'header.h', hpp: 'header.hpp',
|
||||||
@@ -233,109 +312,51 @@ After completing ALL required files, write: DONE`
|
|||||||
actions.push({ type: 'code_block', filename, content: match[4] })
|
actions.push({ type: 'code_block', filename, content: match[4] })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pattern 2: Explicit tool calls ──
|
const textWithoutBlocks = text.replace(/```(?:\w+)?[\s\S]*?```/g, '')
|
||||||
// Manual parser for tool_name("arg1", "arg2") to handle content with quotes
|
actions.push(...this.extract_json_tool_calls(textWithoutBlocks))
|
||||||
const toolNames = ['fs.write', 'fs.read', 'fs.edit', 'fs.list', 'fs.stat',
|
|
||||||
'shell.run', 'git.status', 'git.diff', 'git.commit', 'git.branch',
|
|
||||||
'project.scan', 'project.context', 'cpp.detect', 'cpp.build', 'cpp.test']
|
|
||||||
|
|
||||||
for (const tname of toolNames) {
|
|
||||||
let searchFrom = 0
|
|
||||||
while (true) {
|
|
||||||
const idx = text.indexOf(`${tname}(`, searchFrom)
|
|
||||||
if (idx < 0) break
|
|
||||||
|
|
||||||
// Find the argument list: count parens and handle quotes
|
|
||||||
const argsStart = idx + tname.length + 1 // skip "("
|
|
||||||
let depth = 1
|
|
||||||
let i = argsStart
|
|
||||||
let inString = false
|
|
||||||
let stringChar = ''
|
|
||||||
|
|
||||||
while (i < text.length && depth > 0) {
|
|
||||||
const ch = text[i]
|
|
||||||
if (inString) {
|
|
||||||
if (ch === '\\') { i += 2; continue }
|
|
||||||
if (ch === stringChar) inString = false
|
|
||||||
} else {
|
|
||||||
if (ch === '"' || ch === "'") { inString = true; stringChar = ch }
|
|
||||||
else if (ch === '(') depth++
|
|
||||||
else if (ch === ')') depth--
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
const argsStr = text.slice(argsStart, i - 1).trim()
|
|
||||||
searchFrom = i
|
|
||||||
|
|
||||||
// Parse arguments: split by top-level commas
|
|
||||||
const args: string[] = []
|
|
||||||
let cur = ''
|
|
||||||
let inStr = false
|
|
||||||
let strCh = ''
|
|
||||||
for (let j = 0; j < argsStr.length; j++) {
|
|
||||||
const ch = argsStr[j]
|
|
||||||
if (inStr) {
|
|
||||||
if (ch === '\\') { cur += ch + (argsStr[j+1] || ''); j++; continue }
|
|
||||||
if (ch === strCh) inStr = false
|
|
||||||
cur += ch
|
|
||||||
} else {
|
|
||||||
if (ch === '"' || ch === "'") { inStr = true; strCh = ch; cur += ch }
|
|
||||||
else if (ch === ',') { args.push(cur.trim()); cur = '' }
|
|
||||||
else cur += ch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cur.trim()) args.push(cur.trim())
|
|
||||||
|
|
||||||
// Map to tool-specific arg names
|
|
||||||
const argMap: Record<string, string[]> = {
|
|
||||||
'fs.write': ['path', 'content'],
|
|
||||||
'fs.read': ['path'],
|
|
||||||
'fs.edit': ['path', 'old_str', 'new_str'],
|
|
||||||
'fs.list': ['path'],
|
|
||||||
'fs.stat': ['path'],
|
|
||||||
'shell.run': ['command'],
|
|
||||||
'project.scan': ['root'],
|
|
||||||
'cpp.detect': ['project_root'],
|
|
||||||
'cpp.build': ['target'],
|
|
||||||
'cpp.test': ['filter'],
|
|
||||||
}
|
|
||||||
const keys = argMap[tname] || args.map((_, k) => `arg${k}`)
|
|
||||||
const toolArgs: Record<string, unknown> = {}
|
|
||||||
args.forEach((v, k) => {
|
|
||||||
// Strip surrounding quotes
|
|
||||||
let clean = v.trim()
|
|
||||||
if ((clean.startsWith('"') && clean.endsWith('"')) ||
|
|
||||||
(clean.startsWith("'") && clean.endsWith("'"))) {
|
|
||||||
clean = clean.slice(1, -1)
|
|
||||||
}
|
|
||||||
toolArgs[keys[k] || `arg${k}`] = clean
|
|
||||||
})
|
|
||||||
|
|
||||||
actions.push({ type: 'tool_call', name: tname, args: toolArgs })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pattern 3: ```tool_call blocks (explicit tool JSON) ──
|
|
||||||
const tcallRe = /```(?:tool_call|tool|json)\s*\n?([\s\S]*?)```/g
|
|
||||||
for (const match of text.matchAll(tcallRe)) {
|
|
||||||
const inner = match[1].trim()
|
|
||||||
// Try JSON
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(inner)
|
|
||||||
if (parsed.tool) actions.push({ type: 'tool_call', name: parsed.tool, args: parsed.args || {} })
|
|
||||||
} catch {
|
|
||||||
// Try function call
|
|
||||||
const subCalls = this.parse_actions(inner)
|
|
||||||
for (const sc of subCalls) {
|
|
||||||
if (sc.type !== 'text') actions.push(sc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If nothing parsed, it's just text
|
|
||||||
if (actions.length === 0) actions.push({ type: 'text' })
|
if (actions.length === 0) actions.push({ type: 'text' })
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parse_json_tool_call(raw: string): Extract<ExecutorAction, { type: 'tool_call' }> | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { id?: string; tool?: string; name?: string; args?: Record<string, unknown>; arguments?: Record<string, unknown> }
|
||||||
|
const name = parsed.tool || parsed.name
|
||||||
|
if (!name) return null
|
||||||
|
return {
|
||||||
|
type: 'tool_call',
|
||||||
|
id: parsed.id || crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
args: parsed.args || parsed.arguments || {},
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extract_json_tool_calls(text: string): ExecutorAction[] {
|
||||||
|
const actions: ExecutorAction[] = []
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) continue
|
||||||
|
const action = this.parse_json_tool_call(trimmed)
|
||||||
|
if (action) actions.push(action)
|
||||||
|
}
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
private assistant_content_for_actions(text: string, actions: ExecutorAction[]): unknown {
|
||||||
|
const toolUses = actions
|
||||||
|
.filter((a): a is Extract<ExecutorAction, { type: 'tool_call' }> => a.type === 'tool_call')
|
||||||
|
.map(a => ({ type: 'tool_use', id: a.id, name: a.name, input: a.args }))
|
||||||
|
|
||||||
|
if (toolUses.length === 0) return text
|
||||||
|
|
||||||
|
const blocks: Array<Record<string, unknown>> = []
|
||||||
|
const cleanText = text.replace(/```(?:tool_call|tool|json)\s*\n?[\s\S]*?```/g, '').trim()
|
||||||
|
if (cleanText) blocks.push({ type: 'text', text: cleanText })
|
||||||
|
blocks.push(...toolUses)
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
70
packages/workers/test/executor-role.test.ts
Executable file
70
packages/workers/test/executor-role.test.ts
Executable file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
import { ExecutorRole } from '../src/roles/ExecutorRole.js'
|
||||||
|
|
||||||
|
class NativeToolRuntime {
|
||||||
|
turns = 0
|
||||||
|
calls: Array<{ name: string; args: Record<string, unknown> }> = []
|
||||||
|
emit() {}
|
||||||
|
heartbeat() {}
|
||||||
|
checkpoint() {}
|
||||||
|
async call_llm() {
|
||||||
|
this.turns++
|
||||||
|
if (this.turns === 1) {
|
||||||
|
return { content: '', tool_calls: [{ id: 'tu_1', name: 'fs.write', arguments: { path: 'a.txt', content: 'X' } }] }
|
||||||
|
}
|
||||||
|
return { content: 'DONE' }
|
||||||
|
}
|
||||||
|
async call_tool(name: string, args: Record<string, unknown>) {
|
||||||
|
this.calls.push({ name, args })
|
||||||
|
return { call_id: 'c', type: 'text' as const, content: { ok: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VerificationFailRuntime {
|
||||||
|
turns = 0
|
||||||
|
checkpointed = false
|
||||||
|
emit() {}
|
||||||
|
heartbeat() {}
|
||||||
|
checkpoint() { this.checkpointed = true }
|
||||||
|
async call_llm() {
|
||||||
|
this.turns++
|
||||||
|
if (this.turns === 1) {
|
||||||
|
return { content: '', tool_calls: [{ id: 'tu_main', name: 'fs.write', arguments: { path: 'main.cpp', content: 'int main(){return 0;}' } }] }
|
||||||
|
}
|
||||||
|
return { content: 'DONE' }
|
||||||
|
}
|
||||||
|
async call_tool(name: string, _args: Record<string, unknown>) {
|
||||||
|
if (name === 'fs.write') return { call_id: 'w', type: 'text' as const, content: { ok: true } }
|
||||||
|
if (name === 'shell.run') return { call_id: 's', type: 'error' as const, content: { exit_code: 1, stderr: 'compile failed' } }
|
||||||
|
return { call_id: 'x', type: 'error' as const, content: { message: 'unexpected tool' } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ExecutorRole structured tool execution', () => {
|
||||||
|
test('executes native tool_calls instead of regex text parsing', async () => {
|
||||||
|
const runtime = new NativeToolRuntime()
|
||||||
|
const result = await new ExecutorRole(runtime as any).run({
|
||||||
|
id: 't1',
|
||||||
|
title: 'create file',
|
||||||
|
description: 'create a file',
|
||||||
|
acceptance_criteria: ['file exists'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.status).toBe('completed')
|
||||||
|
expect(runtime.calls).toEqual([{ name: 'fs.write', args: { path: 'a.txt', content: 'X' } }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not complete executable tasks when verification fails', async () => {
|
||||||
|
const runtime = new VerificationFailRuntime()
|
||||||
|
const result = await new ExecutorRole(runtime as any).run({
|
||||||
|
id: 't2',
|
||||||
|
title: 'compile cpp',
|
||||||
|
description: 'write and compile C++',
|
||||||
|
acceptance_criteria: ['must compile'],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.status).not.toBe('completed')
|
||||||
|
expect(runtime.checkpointed).toBe(false)
|
||||||
|
expect(result.changes).toEqual([{ file: 'main.cpp', type: 'create' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
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