feat(bd-art): SQLite Historical Analytics Storage

Implements persistent storage for historical session analytics:

- Add HistoricalStore class using better-sqlite3
- Schema includes sessions, task_metrics, and error_history tables
- Session management: start/end sessions with worker/task/cost metrics
- Task metrics: record per-bead completion times, costs, tokens
- Error history: track errors with resolution status
- Historical queries: worker comparison across sessions
- Learned recoveries: extract patterns from historical error resolutions
- Integration with EventStore for automatic session persistence
- Historical query methods in WorkerAnalytics
- Enhanced recovery suggestions using historical data in RecoveryManager
- Comprehensive unit tests (20 passing tests)

Database stored at ~/.needle/fabric.db for cross-session persistence.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Worker <noreply@anthropic.com>
This commit is contained in:
jeda 2026-03-07 04:18:45 +00:00
parent 1d8fc2d74d
commit 3457ef35d8
8 changed files with 2198 additions and 1 deletions

432
package-lock.json generated
View file

@ -10,6 +10,7 @@
"license": "ISC",
"dependencies": {
"@types/blessed": "^0.1.27",
"better-sqlite3": "^12.6.2",
"blessed": "^0.1.81",
"chalk": "^4.1.2",
"commander": "^12.0.0",
@ -24,6 +25,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"@types/node": "^20.11.0",
"@types/react": "^19.2.14",
@ -1621,6 +1623,16 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/blessed": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/@types/blessed/-/blessed-0.1.27.tgz",
@ -2026,6 +2038,26 @@
"js-tokens": "^10.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
@ -2039,6 +2071,20 @@
"node": ">=6.0.0"
}
},
"node_modules/better-sqlite3": {
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
"integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
@ -2049,6 +2095,26 @@
"require-from-string": "^2.0.2"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/blessed": {
"version": "0.1.81",
"resolved": "https://registry.npmjs.org/blessed/-/blessed-0.1.81.tgz",
@ -2119,6 +2185,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -2204,6 +2294,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -2370,6 +2466,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -2389,6 +2509,15 @@
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@ -2433,6 +2562,15 @@
"node": ">= 0.8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
@ -2560,6 +2698,15 @@
"node": ">= 0.6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@ -2631,6 +2778,12 @@
}
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@ -2670,6 +2823,12 @@
"node": ">= 0.8"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2741,6 +2900,12 @@
"node": ">= 0.4"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -2870,6 +3035,26 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
@ -2886,6 +3071,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -3142,6 +3333,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@ -3152,6 +3355,21 @@
"node": ">=4"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -3177,6 +3395,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@ -3186,6 +3410,18 @@
"node": ">= 0.6"
}
},
"node_modules/node-abi": {
"version": "3.87.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@ -3372,6 +3608,33 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@ -3415,6 +3678,16 @@
"node": ">= 0.10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -3464,6 +3737,21 @@
"node": ">= 0.10"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@ -3505,6 +3793,20 @@
"node": ">=0.10.0"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -3590,6 +3892,26 @@
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -3620,7 +3942,6 @@
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
@ -3759,6 +4080,51 @@
"dev": true,
"license": "ISC"
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@ -3792,6 +4158,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@ -3805,6 +4180,15 @@
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@ -3824,6 +4208,34 @@
"dev": true,
"license": "MIT"
},
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@ -3923,6 +4335,18 @@
"node": ">=20"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@ -4007,6 +4431,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",

View file

@ -39,6 +39,7 @@
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/better-sqlite3": "^7.6.13",
"@types/express": "^5.0.6",
"@types/node": "^20.11.0",
"@types/react": "^19.2.14",
@ -55,6 +56,7 @@
},
"dependencies": {
"@types/blessed": "^0.1.27",
"better-sqlite3": "^12.6.2",
"blessed": "^0.1.81",
"chalk": "^4.1.2",
"commander": "^12.0.0",

442
src/historicalStore.test.ts Normal file
View file

@ -0,0 +1,442 @@
/**
* Tests for FABRIC Historical Store
*
* Tests SQLite-based persistent storage for historical session analytics.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
HistoricalStore,
SessionRecord,
TaskMetricsRecord,
ErrorHistoryRecord,
} from './historicalStore.js';
// Test database path
const TEST_DB_DIR = path.join(os.tmpdir(), 'fabric-test-' + Date.now());
const TEST_DB_PATH = path.join(TEST_DB_DIR, 'test-fabric.db');
describe('HistoricalStore', () => {
let store: HistoricalStore;
beforeEach(() => {
// Create test directory
if (!fs.existsSync(TEST_DB_DIR)) {
fs.mkdirSync(TEST_DB_DIR, { recursive: true });
}
// Create fresh store instance
store = new HistoricalStore(TEST_DB_PATH);
});
afterEach(() => {
// Close and cleanup
store.close();
if (fs.existsSync(TEST_DB_DIR)) {
fs.rmSync(TEST_DB_DIR, { recursive: true, force: true });
}
});
describe('Session Management', () => {
it('should start a new session', () => {
const sessionId = store.startSession('test-session-1');
expect(sessionId).toBe('test-session-1');
expect(store.getCurrentSessionId()).toBe('test-session-1');
});
it('should generate session ID if not provided', () => {
const sessionId = store.startSession();
expect(sessionId).toMatch(/^session-/);
expect(store.getCurrentSessionId()).toBe(sessionId);
});
it('should end session with metrics', () => {
store.startSession('test-session-2');
store.endSession({
workerCount: 3,
taskCount: 10,
totalCost: 0.5,
totalTokens: 5000,
});
const session = store.getSession('test-session-2');
expect(session).not.toBeNull();
expect(session!.worker_count).toBe(3);
expect(session!.task_count).toBe(10);
expect(session!.total_cost).toBe(0.5);
expect(session!.total_tokens).toBe(5000);
});
it('should retrieve sessions within time range', () => {
const now = Date.now();
// Create multiple sessions
store.startSession('session-1');
store.endSession({ workerCount: 1, taskCount: 1, totalCost: 0.1, totalTokens: 100 });
store.startSession('session-2');
store.endSession({ workerCount: 2, taskCount: 2, totalCost: 0.2, totalTokens: 200 });
const sessions = store.getSessions({ startTime: now - 1000, endTime: now + 10000 });
expect(sessions.length).toBe(2);
expect(sessions[0].id).toBe('session-2'); // Most recent first
});
});
describe('Task Metrics', () => {
beforeEach(() => {
store.startSession('metrics-test-session');
});
it('should record task completion', () => {
const taskId = store.recordTask({
workerId: 'worker-1',
taskType: 'bead',
startedAt: Date.now() - 60000,
endedAt: Date.now(),
cost: 0.05,
tokensIn: 500,
tokensOut: 200,
success: true,
retryCount: 0,
});
expect(taskId).toMatch(/metrics-test-session-task-\d+/);
const tasks = store.getTaskMetrics({ sessionId: 'metrics-test-session' });
expect(tasks.length).toBe(1);
expect(tasks[0].worker_id).toBe('worker-1');
expect(tasks[0].success).toBe(1); // SQLite stores boolean as 0/1
expect(tasks[0].duration_ms).toBe(60000);
});
it('should record failed tasks', () => {
store.recordTask({
workerId: 'worker-2',
taskType: 'bead',
startedAt: Date.now() - 30000,
endedAt: Date.now(),
cost: 0.02,
tokensIn: 200,
tokensOut: 50,
success: false,
retryCount: 3,
});
const tasks = store.getTaskMetrics({ workerId: 'worker-2' });
expect(tasks.length).toBe(1);
expect(tasks[0].success).toBe(0);
expect(tasks[0].retry_count).toBe(3);
});
it('should filter tasks by worker', () => {
store.recordTask({
workerId: 'worker-a',
taskType: 'bead',
startedAt: Date.now(),
endedAt: Date.now() + 1000,
cost: 0,
tokensIn: 0,
tokensOut: 0,
success: true,
retryCount: 0,
});
store.recordTask({
workerId: 'worker-b',
taskType: 'bead',
startedAt: Date.now(),
endedAt: Date.now() + 1000,
cost: 0,
tokensIn: 0,
tokensOut: 0,
success: true,
retryCount: 0,
});
const workerATasks = store.getTaskMetrics({ workerId: 'worker-a' });
expect(workerATasks.length).toBe(1);
expect(workerATasks[0].worker_id).toBe('worker-a');
});
});
describe('Error History', () => {
beforeEach(() => {
store.startSession('error-test-session');
});
it('should record errors', () => {
const errorId = store.recordError({
workerId: 'worker-1',
errorType: 'network',
errorMessage: 'ECONNREFUSED: Connection refused',
filePath: '/src/api.ts',
timestamp: Date.now(),
});
expect(errorId).toBeGreaterThan(0);
const errors = store.getErrorHistory({ sessionId: 'error-test-session' });
expect(errors.length).toBe(1);
expect(errors[0].error_type).toBe('network');
expect(errors[0].file_path).toBe('/src/api.ts');
});
it('should update error resolution', () => {
const errorId = store.recordError({
workerId: 'worker-1',
errorType: 'permission',
errorMessage: 'EACCES: Permission denied',
timestamp: Date.now(),
});
store.updateErrorResolution(errorId, 'Fixed file permissions with chmod', true);
const errors = store.getErrorHistory({ errorType: 'permission' });
expect(errors[0].resolution).toBe('Fixed file permissions with chmod');
expect(errors[0].resolution_successful).toBe(1);
});
it('should filter resolved errors', () => {
const errorId = store.recordError({
workerId: 'worker-1',
errorType: 'timeout',
errorMessage: 'Request timeout',
timestamp: Date.now(),
});
store.updateErrorResolution(errorId, 'Increased timeout', true);
store.recordError({
workerId: 'worker-2',
errorType: 'timeout',
errorMessage: 'Another timeout',
timestamp: Date.now(),
});
const resolvedOnly = store.getErrorHistory({ resolvedOnly: true });
expect(resolvedOnly.length).toBe(1);
expect(resolvedOnly[0].resolution_successful).toBe(1);
});
it('should search for similar errors', () => {
store.recordError({
workerId: 'worker-1',
errorType: 'network',
errorMessage: 'ECONNREFUSED connection to localhost refused',
timestamp: Date.now() - 10000,
});
store.recordError({
workerId: 'worker-2',
errorType: 'network',
errorMessage: 'ETIMEDOUT connection timeout waiting for response',
timestamp: Date.now() - 5000,
});
const similar = store.findSimilarErrors('ECONNREFUSED connection refused', 10);
expect(similar.length).toBeGreaterThan(0);
expect(similar[0].similarity).toBeGreaterThan(0);
});
});
describe('Worker Comparison', () => {
beforeEach(() => {
// Create multiple sessions with tasks for a worker
store.startSession('compare-sess-1');
store.recordTask({
workerId: 'test-worker',
taskType: 'bead',
startedAt: Date.now() - 10000,
endedAt: Date.now() - 5000,
cost: 0.1,
tokensIn: 1000,
tokensOut: 500,
success: true,
retryCount: 0,
});
store.endSession({ workerCount: 1, taskCount: 1, totalCost: 0.1, totalTokens: 1500 });
store.startSession('compare-sess-2');
store.recordTask({
workerId: 'test-worker',
taskType: 'bead',
startedAt: Date.now() - 4000,
endedAt: Date.now() - 2000,
cost: 0.05,
tokensIn: 500,
tokensOut: 250,
success: true,
retryCount: 0,
});
store.recordTask({
workerId: 'test-worker',
taskType: 'bead',
startedAt: Date.now() - 2000,
endedAt: Date.now() - 1000,
cost: 0.08,
tokensIn: 800,
tokensOut: 400,
success: false,
retryCount: 1,
});
store.endSession({ workerCount: 1, taskCount: 2, totalCost: 0.13, totalTokens: 1950 });
});
it('should get worker comparison metrics', () => {
const metrics = store.getWorkerComparisonMetrics('test-worker');
expect(metrics).not.toBeNull();
expect(metrics!.workerId).toBe('test-worker');
expect(metrics!.sessionsCount).toBe(2);
expect(metrics!.totalBeadsCompleted).toBe(2); // 2 successful tasks
expect(metrics!.totalErrors).toBe(1);
expect(metrics!.totalCostUsd).toBeCloseTo(0.23, 2);
});
it('should return null for unknown worker', () => {
const metrics = store.getWorkerComparisonMetrics('unknown-worker');
expect(metrics).toBeNull();
});
});
describe('Learned Recoveries', () => {
beforeEach(() => {
store.startSession('learn-sess');
});
it('should learn from error resolutions', () => {
// Record resolved errors
const error1 = store.recordError({
workerId: 'w1',
errorType: 'network',
errorMessage: 'ECONNREFUSED connection refused',
timestamp: Date.now() - 5000,
});
store.updateErrorResolution(error1, 'Retry with exponential backoff', true);
const error2 = store.recordError({
workerId: 'w2',
errorType: 'network',
errorMessage: 'ECONNREFUSED connection timeout',
timestamp: Date.now(),
});
store.updateErrorResolution(error2, 'Retry with exponential backoff', true);
const learned = store.getLearnedRecoveries();
expect(learned.length).toBeGreaterThan(0);
expect(learned[0].errorType).toBe('network');
expect(learned[0].resolution).toBe('Retry with exponential backoff');
expect(learned[0].occurrenceCount).toBe(2);
expect(learned[0].successRate).toBe(1);
});
});
describe('Aggregated Analytics', () => {
beforeEach(() => {
store.startSession('agg-sess-1');
store.recordTask({
workerId: 'worker-a',
taskType: 'bead',
startedAt: Date.now() - 10000,
endedAt: Date.now() - 5000,
cost: 0.1,
tokensIn: 1000,
tokensOut: 500,
success: true,
retryCount: 0,
});
store.endSession({ workerCount: 1, taskCount: 1, totalCost: 0.1, totalTokens: 1500 });
});
it('should get aggregated analytics', () => {
const analytics = store.getAggregatedAnalytics();
expect(analytics.totalWorkers).toBeGreaterThanOrEqual(1);
expect(analytics.totalBeadsCompleted).toBeGreaterThanOrEqual(1);
expect(analytics.totalCostUsd).toBeGreaterThanOrEqual(0.1);
});
it('should filter by time range', () => {
const now = Date.now();
const analytics = store.getAggregatedAnalytics({
startTime: now + 10000, // Future time - should be empty
endTime: now + 20000,
});
expect(analytics.totalBeadsCompleted).toBe(0);
});
});
describe('Database Statistics', () => {
it('should return database stats', () => {
store.startSession('stats-sess');
store.recordTask({
workerId: 'w1',
taskType: 'bead',
startedAt: Date.now(),
endedAt: Date.now() + 1000,
cost: 0.01,
tokensIn: 100,
tokensOut: 50,
success: true,
retryCount: 0,
});
store.recordError({
workerId: 'w1',
errorType: 'test',
errorMessage: 'Test error',
timestamp: Date.now(),
});
const stats = store.getStats();
expect(stats.sessionsCount).toBeGreaterThanOrEqual(1);
expect(stats.tasksCount).toBeGreaterThanOrEqual(1);
expect(stats.errorsCount).toBeGreaterThanOrEqual(1);
expect(stats.dbSizeBytes).toBeGreaterThan(0);
});
});
describe('Clear and Reset', () => {
it('should clear all data', () => {
store.startSession('clear-sess');
store.recordTask({
workerId: 'w1',
taskType: 'bead',
startedAt: Date.now(),
endedAt: Date.now() + 1000,
cost: 0.01,
tokensIn: 100,
tokensOut: 50,
success: true,
retryCount: 0,
});
store.clear();
const stats = store.getStats();
expect(stats.sessionsCount).toBe(0);
expect(stats.tasksCount).toBe(0);
expect(stats.errorsCount).toBe(0);
});
});
describe('Database Path', () => {
it('should return database path', () => {
const dbPath = store.getDatabasePath();
expect(dbPath).toBe(TEST_DB_PATH);
});
it('should use default path if not specified', () => {
const defaultStore = new HistoricalStore();
const dbPath = defaultStore.getDatabasePath();
expect(dbPath).toContain('.needle');
expect(dbPath).toContain('fabric.db');
defaultStore.close();
});
});
});

968
src/historicalStore.ts Normal file
View file

@ -0,0 +1,968 @@
/**
* FABRIC Historical Analytics Storage
*
* SQLite-based persistent storage for historical session analytics.
* Enables worker comparison across sessions and recovery playbook learning.
*
* Schema matches plan.md lines 1016-1124
*/
import Database from 'better-sqlite3';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import {
WorkerMetrics,
ErrorCategory,
AggregatedAnalytics,
} from './types.js';
// ============================================
// Type Definitions
// ============================================
/**
* Session summary record
*/
export interface SessionRecord {
id: string;
started_at: number;
ended_at: number;
worker_count: number;
task_count: number;
total_cost: number;
total_tokens: number;
}
/**
* Task metrics record
*/
export interface TaskMetricsRecord {
id: string;
session_id: string;
worker_id: string;
task_type: string;
started_at: number;
ended_at: number;
duration_ms: number;
cost: number;
tokens_in: number;
tokens_out: number;
success: boolean;
retry_count: number;
}
/**
* Error history record
*/
export interface ErrorHistoryRecord {
id: number;
session_id: string;
worker_id: string;
error_type: string;
error_message: string;
file_path: string | null;
timestamp: number;
resolution: string | null;
resolution_successful: boolean | null;
}
/**
* Options for querying historical data
*/
export interface HistoricalQueryOptions {
/** Start time (Unix timestamp in ms) */
startTime?: number;
/** End time (Unix timestamp in ms) */
endTime?: number;
/** Limit number of results */
limit?: number;
/** Filter by worker ID */
workerId?: string;
/** Filter by session ID */
sessionId?: string;
/** Filter by error type */
errorType?: string;
/** Filter by error category */
errorCategory?: ErrorCategory;
/** Include only resolved errors */
resolvedOnly?: boolean;
}
/**
* Worker comparison metrics across sessions
*/
export interface WorkerComparisonMetrics {
workerId: string;
sessionsCount: number;
totalBeadsCompleted: number;
avgBeadsPerSession: number;
avgBeadsPerHour: number;
totalErrors: number;
avgErrorRate: number;
totalCostUsd: number;
avgCostPerBead: number;
totalTokens: number;
avgCompletionTimeMs: number;
bestSession: SessionRecord | null;
worstSession: SessionRecord | null;
}
/**
* Recovery playbook learned entry
*/
export interface LearnedRecoveryEntry {
errorType: string;
errorPattern: string;
resolution: string;
successRate: number;
occurrenceCount: number;
avgResolutionTime: number;
lastSeen: number;
}
// ============================================
// Database Schema
// ============================================
const SCHEMA_VERSION = 1;
const CREATE_SESSIONS_TABLE = `
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
worker_count INTEGER NOT NULL DEFAULT 0,
task_count INTEGER NOT NULL DEFAULT 0,
total_cost REAL NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
CREATE INDEX IF NOT EXISTS idx_sessions_ended ON sessions(ended_at);
`;
const CREATE_TASK_METRICS_TABLE = `
CREATE TABLE IF NOT EXISTS task_metrics (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
task_type TEXT NOT NULL,
started_at INTEGER NOT NULL,
ended_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL DEFAULT 0,
cost REAL NOT NULL DEFAULT 0,
tokens_in INTEGER NOT NULL DEFAULT 0,
tokens_out INTEGER NOT NULL DEFAULT 0,
success INTEGER NOT NULL DEFAULT 1,
retry_count INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_task_metrics_session ON task_metrics(session_id);
CREATE INDEX IF NOT EXISTS idx_task_metrics_worker ON task_metrics(worker_id);
CREATE INDEX IF NOT EXISTS idx_task_metrics_started ON task_metrics(started_at);
`;
const CREATE_ERROR_HISTORY_TABLE = `
CREATE TABLE IF NOT EXISTS error_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
worker_id TEXT NOT NULL,
error_type TEXT NOT NULL,
error_message TEXT NOT NULL,
file_path TEXT,
timestamp INTEGER NOT NULL,
resolution TEXT,
resolution_successful INTEGER,
FOREIGN KEY (session_id) REFERENCES sessions(id)
);
CREATE INDEX IF NOT EXISTS idx_error_history_session ON error_history(session_id);
CREATE INDEX IF NOT EXISTS idx_error_history_worker ON error_history(worker_id);
CREATE INDEX IF NOT EXISTS idx_error_history_type ON error_history(error_type);
CREATE INDEX IF NOT EXISTS idx_error_history_timestamp ON error_history(timestamp);
`;
const CREATE_SCHEMA_VERSION_TABLE = `
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY
);
`;
// ============================================
// Historical Store Class
// ============================================
/**
* SQLite-backed historical analytics storage
*/
export class HistoricalStore {
private db: Database.Database;
private dbPath: string;
private currentSessionId: string | null = null;
private sessionStartTime: number = 0;
private taskCounter: number = 0;
private errorCounter: number = 0;
/**
* Create or open the historical store
*/
constructor(dbPath?: string) {
// Default to ~/.needle/fabric.db
this.dbPath = dbPath || path.join(os.homedir(), '.needle', 'fabric.db');
// Ensure directory exists
const dbDir = path.dirname(this.dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// Open database
this.db = new Database(this.dbPath);
// Enable WAL mode for better concurrency
this.db.pragma('journal_mode = WAL');
// Initialize schema
this.initializeSchema();
}
/**
* Initialize database schema
*/
private initializeSchema(): void {
// Create schema_version table first (idempotent)
this.db.exec(CREATE_SCHEMA_VERSION_TABLE);
// Check current schema version
const versionRow = this.db.prepare('SELECT version FROM schema_version').get() as { version: number } | undefined;
const currentVersion = versionRow?.version || 0;
if (currentVersion < SCHEMA_VERSION) {
// Run schema migrations
this.db.exec(CREATE_SESSIONS_TABLE);
this.db.exec(CREATE_TASK_METRICS_TABLE);
this.db.exec(CREATE_ERROR_HISTORY_TABLE);
// Update version
this.db.prepare('INSERT OR REPLACE INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
}
}
/**
* Start a new session
*/
startSession(sessionId?: string): string {
this.currentSessionId = sessionId || this.generateSessionId();
this.sessionStartTime = Date.now();
this.taskCounter = 0;
this.errorCounter = 0;
// Insert session record
this.db.prepare(`
INSERT INTO sessions (id, started_at, ended_at, worker_count, task_count, total_cost, total_tokens)
VALUES (?, ?, ?, 0, 0, 0, 0)
`).run(this.currentSessionId, this.sessionStartTime, this.sessionStartTime);
return this.currentSessionId;
}
/**
* End the current session and write final metrics
*/
endSession(metrics: {
workerCount: number;
taskCount: number;
totalCost: number;
totalTokens: number;
}): void {
if (!this.currentSessionId) return;
const endTime = Date.now();
this.db.prepare(`
UPDATE sessions
SET ended_at = ?, worker_count = ?, task_count = ?, total_cost = ?, total_tokens = ?
WHERE id = ?
`).run(
endTime,
metrics.workerCount,
metrics.taskCount,
metrics.totalCost,
metrics.totalTokens,
this.currentSessionId
);
this.currentSessionId = null;
this.sessionStartTime = 0;
}
/**
* Record a task completion
*/
recordTask(task: {
workerId: string;
taskType: string;
startedAt: number;
endedAt: number;
cost: number;
tokensIn: number;
tokensOut: number;
success: boolean;
retryCount: number;
}): string {
if (!this.currentSessionId) {
this.startSession();
}
const taskId = `${this.currentSessionId}-task-${++this.taskCounter}`;
const durationMs = task.endedAt - task.startedAt;
this.db.prepare(`
INSERT INTO task_metrics (
id, session_id, worker_id, task_type, started_at, ended_at,
duration_ms, cost, tokens_in, tokens_out, success, retry_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
taskId,
this.currentSessionId,
task.workerId,
task.taskType,
task.startedAt,
task.endedAt,
durationMs,
task.cost,
task.tokensIn,
task.tokensOut,
task.success ? 1 : 0,
task.retryCount
);
// Update session task count
this.db.prepare(`
UPDATE sessions SET task_count = task_count + 1 WHERE id = ?
`).run(this.currentSessionId);
return taskId;
}
/**
* Record an error occurrence
*/
recordError(error: {
workerId: string;
errorType: string;
errorMessage: string;
filePath?: string;
timestamp: number;
}): number {
if (!this.currentSessionId) {
this.startSession();
}
const result = this.db.prepare(`
INSERT INTO error_history (
session_id, worker_id, error_type, error_message, file_path, timestamp
) VALUES (?, ?, ?, ?, ?, ?)
`).run(
this.currentSessionId,
error.workerId,
error.errorType,
error.errorMessage,
error.filePath || null,
error.timestamp
);
return result.lastInsertRowid as number;
}
/**
* Update error resolution
*/
updateErrorResolution(
errorId: number,
resolution: string,
successful: boolean
): void {
this.db.prepare(`
UPDATE error_history
SET resolution = ?, resolution_successful = ?
WHERE id = ?
`).run(resolution, successful ? 1 : 0, errorId);
}
// ============================================
// Query Methods
// ============================================
/**
* Get sessions within a time range
*/
getSessions(options: HistoricalQueryOptions = {}): SessionRecord[] {
const { startTime = 0, endTime = Date.now(), limit = 100 } = options;
const rows = this.db.prepare(`
SELECT * FROM sessions
WHERE started_at >= ? AND ended_at <= ?
ORDER BY started_at DESC
LIMIT ?
`).all(startTime, endTime, limit) as SessionRecord[];
return rows;
}
/**
* Get a specific session by ID
*/
getSession(sessionId: string): SessionRecord | null {
const row = this.db.prepare(`
SELECT * FROM sessions WHERE id = ?
`).get(sessionId) as SessionRecord | undefined;
return row || null;
}
/**
* Get task metrics for a session or worker
*/
getTaskMetrics(options: HistoricalQueryOptions = {}): TaskMetricsRecord[] {
const { sessionId, workerId, startTime, endTime, limit = 1000 } = options;
let query = 'SELECT * FROM task_metrics WHERE 1=1';
const params: (string | number)[] = [];
if (sessionId) {
query += ' AND session_id = ?';
params.push(sessionId);
}
if (workerId) {
query += ' AND worker_id = ?';
params.push(workerId);
}
if (startTime) {
query += ' AND started_at >= ?';
params.push(startTime);
}
if (endTime) {
query += ' AND ended_at <= ?';
params.push(endTime);
}
query += ' ORDER BY started_at DESC LIMIT ?';
params.push(limit);
const rows = this.db.prepare(query).all(...params) as TaskMetricsRecord[];
return rows;
}
/**
* Get error history
*/
getErrorHistory(options: HistoricalQueryOptions = {}): ErrorHistoryRecord[] {
const {
sessionId,
workerId,
errorType,
startTime,
endTime,
resolvedOnly,
limit = 1000,
} = options;
let query = 'SELECT * FROM error_history WHERE 1=1';
const params: (string | number | null)[] = [];
if (sessionId) {
query += ' AND session_id = ?';
params.push(sessionId);
}
if (workerId) {
query += ' AND worker_id = ?';
params.push(workerId);
}
if (errorType) {
query += ' AND error_type = ?';
params.push(errorType);
}
if (startTime) {
query += ' AND timestamp >= ?';
params.push(startTime);
}
if (endTime) {
query += ' AND timestamp <= ?';
params.push(endTime);
}
if (resolvedOnly) {
query += ' AND resolution_successful = 1';
}
query += ' ORDER BY timestamp DESC LIMIT ?';
params.push(limit);
const rows = this.db.prepare(query).all(...params) as ErrorHistoryRecord[];
return rows;
}
/**
* Get worker comparison metrics across sessions
*/
getWorkerComparisonMetrics(workerId: string): WorkerComparisonMetrics | null {
// Get all task metrics for this worker
const tasks = this.db.prepare(`
SELECT * FROM task_metrics WHERE worker_id = ?
`).all(workerId) as TaskMetricsRecord[];
if (tasks.length === 0) {
return null;
}
// Get unique sessions
const sessionIds = [...new Set(tasks.map(t => t.session_id))];
const sessions = this.db.prepare(`
SELECT * FROM sessions WHERE id IN (${sessionIds.map(() => '?').join(',')})
`).all(...sessionIds) as SessionRecord[];
// Calculate metrics
const sessionsMap = new Map(sessions.map(s => [s.id, s]));
let totalBeadsCompleted = 0;
let totalErrors = 0;
let totalCostUsd = 0;
let totalTokens = 0;
let totalDurationMs = 0;
let successCount = 0;
for (const task of tasks) {
if (task.success) {
totalBeadsCompleted++;
totalDurationMs += task.duration_ms;
successCount++;
} else {
totalErrors++;
}
totalCostUsd += task.cost;
totalTokens += task.tokens_in + task.tokens_out;
}
// Find best and worst sessions
let bestSession: SessionRecord | null = null;
let worstSession: SessionRecord | null = null;
let bestTaskCount = 0;
let worstTaskCount = Infinity;
for (const session of sessions) {
const sessionTasks = tasks.filter(t => t.session_id === session.id && t.success);
if (sessionTasks.length > bestTaskCount) {
bestTaskCount = sessionTasks.length;
bestSession = session;
}
if (sessionTasks.length < worstTaskCount && sessionTasks.length > 0) {
worstTaskCount = sessionTasks.length;
worstSession = session;
}
}
const avgBeadsPerSession = sessions.length > 0 ? totalBeadsCompleted / sessions.length : 0;
// Calculate average time span per session
let totalTimeHours = 0;
for (const session of sessions) {
totalTimeHours += (session.ended_at - session.started_at) / 3600000;
}
const avgBeadsPerHour = totalTimeHours > 0 ? totalBeadsCompleted / totalTimeHours : 0;
const avgErrorRate = totalBeadsCompleted + totalErrors > 0
? totalErrors / (totalBeadsCompleted + totalErrors)
: 0;
const avgCostPerBead = totalBeadsCompleted > 0 ? totalCostUsd / totalBeadsCompleted : 0;
const avgCompletionTimeMs = successCount > 0 ? totalDurationMs / successCount : 0;
return {
workerId,
sessionsCount: sessions.length,
totalBeadsCompleted,
avgBeadsPerSession,
avgBeadsPerHour,
totalErrors,
avgErrorRate,
totalCostUsd,
avgCostPerBead,
totalTokens,
avgCompletionTimeMs,
bestSession,
worstSession,
};
}
/**
* Get learned recovery patterns from error history
*/
getLearnedRecoveries(): LearnedRecoveryEntry[] {
const rows = this.db.prepare(`
SELECT
error_type,
error_message,
resolution,
resolution_successful,
timestamp
FROM error_history
WHERE resolution IS NOT NULL
ORDER BY timestamp DESC
`).all() as {
error_type: string;
error_message: string;
resolution: string;
resolution_successful: number;
timestamp: number;
}[];
// Group by error type and resolution
const grouped = new Map<string, {
entries: typeof rows;
successCount: number;
totalResolutionTime: number;
}>();
for (const row of rows) {
const key = `${row.error_type}::${row.resolution}`;
const existing = grouped.get(key);
if (existing) {
existing.entries.push(row);
if (row.resolution_successful) {
existing.successCount++;
}
} else {
grouped.set(key, {
entries: [row],
successCount: row.resolution_successful ? 1 : 0,
totalResolutionTime: 0,
});
}
}
// Convert to learned entries
const learned: LearnedRecoveryEntry[] = [];
for (const [key, data] of grouped) {
const [errorType, resolution] = key.split('::');
const successRate = data.entries.length > 0
? data.successCount / data.entries.length
: 0;
// Extract error pattern (simplified - first 50 chars)
const errorPattern = data.entries[0].error_message.slice(0, 50);
learned.push({
errorType,
errorPattern,
resolution,
successRate,
occurrenceCount: data.entries.length,
avgResolutionTime: 0, // Would need additional tracking for this
lastSeen: Math.max(...data.entries.map(e => e.timestamp)),
});
}
// Sort by occurrence count (most common first)
return learned.sort((a, b) => b.occurrenceCount - a.occurrenceCount);
}
/**
* Search for similar errors in history
*/
findSimilarErrors(
errorMessage: string,
limit: number = 10
): (ErrorHistoryRecord & { similarity: number })[] {
// Simple substring matching - could be enhanced with fuzzy matching
const searchTerms = errorMessage.toLowerCase().split(/\s+/).filter(t => t.length > 3);
if (searchTerms.length === 0) {
return [];
}
const rows = this.db.prepare(`
SELECT * FROM error_history
WHERE ${searchTerms.map(() => 'LOWER(error_message) LIKE ?').join(' OR ')}
ORDER BY timestamp DESC
LIMIT ?
`).all(
...searchTerms.map(t => `%${t}%`),
limit
) as ErrorHistoryRecord[];
// Calculate simple similarity score
return rows.map(row => {
const lowerMsg = row.error_message.toLowerCase();
const matchCount = searchTerms.filter(t => lowerMsg.includes(t)).length;
const similarity = matchCount / searchTerms.length;
return { ...row, similarity };
}).sort((a, b) => b.similarity - a.similarity);
}
/**
* Get aggregated analytics for a time period
*/
getAggregatedAnalytics(options: HistoricalQueryOptions = {}): AggregatedAnalytics {
const { startTime = 0, endTime = Date.now() } = options;
// Get sessions in range
const sessions = this.getSingsInRange(startTime, endTime);
// Get task metrics in range
const tasks = this.db.prepare(`
SELECT * FROM task_metrics
WHERE started_at >= ? AND ended_at <= ?
`).all(startTime, endTime) as TaskMetricsRecord[];
// Calculate aggregated metrics
const workerMap = new Map<string, {
tasksCompleted: number;
errors: number;
cost: number;
tokens: number;
completionTimes: number[];
}>();
let totalBeadsCompleted = 0;
let totalErrors = 0;
let totalCostUsd = 0;
let totalTokens = 0;
let totalCompletionTime = 0;
let successCount = 0;
for (const task of tasks) {
let worker = workerMap.get(task.worker_id);
if (!worker) {
worker = {
tasksCompleted: 0,
errors: 0,
cost: 0,
tokens: 0,
completionTimes: [],
};
workerMap.set(task.worker_id, worker);
}
if (task.success) {
totalBeadsCompleted++;
totalCompletionTime += task.duration_ms;
successCount++;
worker.tasksCompleted++;
worker.completionTimes.push(task.duration_ms);
} else {
totalErrors++;
worker.errors++;
}
totalCostUsd += task.cost;
totalTokens += task.tokens_in + task.tokens_out;
worker.cost += task.cost;
worker.tokens += task.tokens_in + task.tokens_out;
}
const totalTimeMs = endTime - startTime;
const totalTimeHours = totalTimeMs / 3600000;
const avgBeadsPerHour = totalTimeHours > 0 ? totalBeadsCompleted / totalTimeHours : 0;
const avgCompletionTimeMs = successCount > 0 ? totalCompletionTime / successCount : 0;
const overallErrorRate = totalBeadsCompleted + totalErrors > 0
? totalErrors / (totalBeadsCompleted + totalErrors)
: 0;
const avgCostPerBead = totalBeadsCompleted > 0 ? totalCostUsd / totalBeadsCompleted : 0;
// Build top performers list
const topPerformers: WorkerMetrics[] = [];
for (const [workerId, data] of workerMap) {
const avgCompletion = data.completionTimes.length > 0
? data.completionTimes.reduce((a, b) => a + b, 0) / data.completionTimes.length
: 0;
topPerformers.push({
workerId,
periodStart: startTime,
periodEnd: endTime,
beadsCompleted: data.tasksCompleted,
beadsPerHour: totalTimeHours > 0 ? data.tasksCompleted / totalTimeHours : 0,
avgCompletionTimeMs: avgCompletion,
errorCount: data.errors,
errorRate: data.tasksCompleted + data.errors > 0
? data.errors / (data.tasksCompleted + data.errors)
: 0,
totalCostUsd: data.cost,
costPerBead: data.tasksCompleted > 0 ? data.cost / data.tasksCompleted : 0,
activeTimeMs: totalTimeMs,
idleTimeMs: 0,
idlePercentage: 0,
totalEvents: data.tasksCompleted + data.errors,
totalTokens: data.tokens,
tokensPerBead: data.tasksCompleted > 0 ? data.tokens / data.tasksCompleted : 0,
efficiencyScore: data.tasksCompleted > 0 ? 1 : 0,
});
}
// Sort by beads completed
topPerformers.sort((a, b) => b.beadsCompleted - a.beadsCompleted);
return {
periodStart: startTime,
periodEnd: endTime,
totalWorkers: workerMap.size,
totalBeadsCompleted,
avgBeadsPerHour,
avgCompletionTimeMs,
totalErrors,
overallErrorRate,
totalCostUsd,
avgCostPerBead,
topPerformers: topPerformers.slice(0, 10),
highErrorRateWorkers: topPerformers.filter(w => w.errorRate > 0.2).slice(0, 10),
costEfficientWorkers: [...topPerformers]
.sort((a, b) => a.costPerBead - b.costPerBead)
.slice(0, 10),
activeWorkerCount: workerMap.size,
totalTokens,
avgEfficiency: topPerformers.length > 0
? topPerformers.reduce((sum, w) => sum + w.efficiencyScore, 0) / topPerformers.length
: 0,
underperformers: [],
};
}
/**
* Helper to get sessions in a time range
*/
private getSingsInRange(startTime: number, endTime: number): SessionRecord[] {
return this.db.prepare(`
SELECT * FROM sessions
WHERE started_at >= ? AND ended_at <= ?
ORDER BY started_at ASC
`).all(startTime, endTime) as SessionRecord[];
}
// ============================================
// Utility Methods
// ============================================
/**
* Generate a unique session ID
*/
private generateSessionId(): string {
return `session-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
}
/**
* Get the current session ID
*/
getCurrentSessionId(): string | null {
return this.currentSessionId;
}
/**
* Get database path
*/
getDatabasePath(): string {
return this.dbPath;
}
/**
* Close the database connection
*/
close(): void {
if (this.currentSessionId) {
// Auto-end session if still open
this.endSession({
workerCount: 0,
taskCount: this.taskCounter,
totalCost: 0,
totalTokens: 0,
});
}
this.db.close();
}
/**
* Clear all historical data
*/
clear(): void {
this.db.exec('DELETE FROM error_history');
this.db.exec('DELETE FROM task_metrics');
this.db.exec('DELETE FROM sessions');
}
/**
* Get database statistics
*/
getStats(): {
sessionsCount: number;
tasksCount: number;
errorsCount: number;
dbSizeBytes: number;
oldestSession: number | null;
newestSession: number | null;
} {
const sessionsCount = (this.db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }).count;
const tasksCount = (this.db.prepare('SELECT COUNT(*) as count FROM task_metrics').get() as { count: number }).count;
const errorsCount = (this.db.prepare('SELECT COUNT(*) as count FROM error_history').get() as { count: number }).count;
const oldestRow = this.db.prepare('SELECT MIN(started_at) as oldest FROM sessions').get() as { oldest: number | null };
const newestRow = this.db.prepare('SELECT MAX(started_at) as newest FROM sessions').get() as { newest: number | null };
// Get file size
let dbSizeBytes = 0;
try {
const stats = fs.statSync(this.dbPath);
dbSizeBytes = stats.size;
} catch {
// Ignore
}
return {
sessionsCount,
tasksCount,
errorsCount,
dbSizeBytes,
oldestSession: oldestRow.oldest,
newestSession: newestRow.newest,
};
}
}
// ============================================
// Singleton Instance
// ============================================
let globalHistoricalStore: HistoricalStore | undefined;
/**
* Get the global historical store instance
*/
export function getHistoricalStore(): HistoricalStore {
if (!globalHistoricalStore) {
globalHistoricalStore = new HistoricalStore();
}
return globalHistoricalStore;
}
/**
* Reset the global historical store
*/
export function resetHistoricalStore(): void {
if (globalHistoricalStore) {
globalHistoricalStore.close();
globalHistoricalStore = undefined;
}
}

View file

@ -26,3 +26,14 @@ export * from './types.js';
export { SessionDigestGenerator, formatDigestAsMarkdown } from './sessionDigest.js';
export { WorkerAnalytics, getWorkerAnalytics, resetWorkerAnalytics } from './workerAnalytics.js';
export { SemanticNarrativeGenerator, getSemanticNarrativeManager } from './semanticNarrative.js';
export {
HistoricalStore,
getHistoricalStore,
resetHistoricalStore,
SessionRecord,
TaskMetricsRecord,
ErrorHistoryRecord,
HistoricalQueryOptions,
WorkerComparisonMetrics,
LearnedRecoveryEntry,
} from './historicalStore.js';

View file

@ -41,6 +41,7 @@ import { RecoveryManager, getRecoveryManager } from './tui/utils/recoveryPlayboo
import { CrossReferenceManager, getCrossReferenceManager } from './crossReferenceManager.js';
import { WorkerAnalytics, getWorkerAnalytics } from './workerAnalytics.js';
import { SemanticNarrativeGenerator, getSemanticNarrativeManager } from './semanticNarrative.js';
import { HistoricalStore, getHistoricalStore } from './historicalStore.js';
/** Time window (in ms) to consider events as concurrent */
const COLLISION_WINDOW_MS = 5000;
@ -86,10 +87,13 @@ export class InMemoryEventStore implements EventStore {
private crossReferenceManager: CrossReferenceManager;
private workerAnalytics: WorkerAnalytics;
private semanticNarrativeManager: SemanticNarrativeGenerator;
private historicalStore: HistoricalStore;
private maxEvents: number;
private alertCounter = 0;
private batchBuffer: LogEvent[] = [];
private batchTimeout: NodeJS.Timeout | null = null;
private sessionStartTime: number = 0;
private taskStartTimes: Map<string, number> = new Map(); // beadId -> startTime
constructor(maxEvents: number = 10000) {
this.maxEvents = maxEvents;
@ -98,6 +102,9 @@ export class InMemoryEventStore implements EventStore {
this.crossReferenceManager = getCrossReferenceManager();
this.workerAnalytics = getWorkerAnalytics();
this.semanticNarrativeManager = getSemanticNarrativeManager();
this.historicalStore = getHistoricalStore();
this.sessionStartTime = Date.now();
this.historicalStore.startSession();
}
/**
@ -111,6 +118,11 @@ export class InMemoryEventStore implements EventStore {
this.detectTaskCollision(event);
this.trackFileModification(event);
// Track task starts and completions for historical storage
if (event.bead) {
this.trackTaskForHistory(event);
}
// Track errors in error groups
if (event.level === 'error') {
this.errorGroupManager.addError(event);
@ -205,6 +217,9 @@ export class InMemoryEventStore implements EventStore {
* Clear all events
*/
clear(): void {
// Persist session data before clearing
this.persistSession();
this.events = [];
this.workers.clear();
this.collisions.clear();
@ -214,12 +229,90 @@ export class InMemoryEventStore implements EventStore {
this.errorGroupManager.clear();
this.crossReferenceManager.clear();
this.batchBuffer = [];
this.taskStartTimes.clear();
if (this.batchTimeout) {
clearTimeout(this.batchTimeout);
this.batchTimeout = null;
}
}
/**
* Persist current session to historical store
*/
private persistSession(): void {
if (this.events.length === 0) return;
// Calculate session metrics
const analytics = this.workerAnalytics.getAggregatedAnalytics({ timeWindow: 'all' });
// End the historical session
this.historicalStore.endSession({
workerCount: this.workers.size,
taskCount: analytics.totalBeadsCompleted,
totalCost: analytics.totalCostUsd,
totalTokens: analytics.totalTokens,
});
// Record any completed tasks that haven't been recorded yet
for (const [beadId, startTime] of this.taskStartTimes) {
// Find the completion event for this bead
const completionEvent = this.events.find(e =>
e.bead === beadId &&
(e.msg.toLowerCase().includes('completed') ||
e.msg.toLowerCase().includes('finished') ||
e.msg.toLowerCase().includes('closed'))
);
if (completionEvent) {
// Find which worker worked on this bead
const workerEvents = this.events.filter(e => e.bead === beadId);
const workerId = workerEvents[0]?.worker || 'unknown';
// Get cost info for this task
const costSummary = this.workerAnalytics.getAllWorkerMetrics({ workerIds: [workerId] });
const workerCost = costSummary[0]?.totalCostUsd || 0;
const workerTokens = costSummary[0]?.totalTokens || 0;
this.historicalStore.recordTask({
workerId,
taskType: 'bead',
startedAt: startTime,
endedAt: completionEvent.ts,
cost: workerCost,
tokensIn: Math.floor(workerTokens * 0.7), // Estimate
tokensOut: Math.floor(workerTokens * 0.3), // Estimate
success: completionEvent.level !== 'error',
retryCount: 0,
});
}
}
// Record errors from error groups
const errorGroups = this.errorGroupManager.getGroups();
for (const group of errorGroups) {
for (const event of group.events) {
this.historicalStore.recordError({
workerId: event.worker,
errorType: group.fingerprint.category,
errorMessage: group.fingerprint.sampleMessage,
filePath: event.path,
timestamp: event.ts,
});
}
}
// Start a new session
this.sessionStartTime = Date.now();
this.historicalStore.startSession();
}
/**
* Get historical store for queries
*/
getHistoricalStore(): HistoricalStore {
return this.historicalStore;
}
/**
* Get all error groups
*/
@ -347,6 +440,54 @@ export class InMemoryEventStore implements EventStore {
return FILE_MODIFICATION_TOOLS.includes(event.tool);
}
/**
* Track task events for historical storage
*/
private trackTaskForHistory(event: LogEvent): void {
const beadId = event.bead!;
// Track task start
if (!this.taskStartTimes.has(beadId)) {
this.taskStartTimes.set(beadId, event.ts);
}
// Check for task completion
const msg = event.msg?.toLowerCase() || '';
if (
msg.includes('completed') ||
msg.includes('finished') ||
msg.includes('done') ||
msg.includes('success') ||
msg.includes('closed')
) {
const startTime = this.taskStartTimes.get(beadId);
if (startTime) {
const durationMs = event.ts - startTime;
// Get cost info for this worker
const workerMetrics = this.workerAnalytics.getWorkerMetrics(event.worker);
const cost = workerMetrics?.costPerBead || 0;
const tokensIn = Math.floor((workerMetrics?.totalTokens || 0) * 0.7);
const tokensOut = Math.floor((workerMetrics?.totalTokens || 0) * 0.3);
this.historicalStore.recordTask({
workerId: event.worker,
taskType: 'bead',
startedAt: startTime,
endedAt: event.ts,
cost,
tokensIn,
tokensOut,
success: event.level !== 'error',
retryCount: 0,
});
// Clean up
this.taskStartTimes.delete(beadId);
}
}
}
/**
* Detect collision when a file modification event occurs
*/

View file

@ -3,6 +3,7 @@
*
* Maps error patterns to actionable recovery steps.
* Provides suggestions when workers encounter errors.
* Learns from historical error resolutions.
*/
import {
@ -16,6 +17,7 @@ import {
RecoveryStats,
RecoveryPriority,
} from '../../types.js';
import { getHistoricalStore, HistoricalStore, LearnedRecoveryEntry } from '../../historicalStore.js';
// ============================================
// Predefined Recovery Actions
@ -889,6 +891,134 @@ export class RecoveryManager {
};
}
// ============================================
// Historical Error Methods
// ============================================
/**
* Search for similar historical errors and their resolutions
*/
searchHistoricalErrors(errorMessage: string, limit: number = 5): Array<{
error: {
id: number;
workerId: string;
errorType: string;
errorMessage: string;
filePath: string | null;
timestamp: number;
resolution: string | null;
resolutionSuccessful: boolean | null;
};
similarity: number;
}> {
const store = getHistoricalStore();
const similar = store.findSimilarErrors(errorMessage, limit);
return similar.map(e => ({
error: {
id: e.id,
workerId: e.worker_id,
errorType: e.error_type,
errorMessage: e.error_message,
filePath: e.file_path,
timestamp: e.timestamp,
resolution: e.resolution,
resolutionSuccessful: e.resolution_successful !== null
? Boolean(e.resolution_successful)
: null,
},
similarity: e.similarity,
}));
}
/**
* Get learned recovery patterns from historical data
*/
getLearnedRecoveries(): LearnedRecoveryEntry[] {
const store = getHistoricalStore();
return store.getLearnedRecoveries();
}
/**
* Generate recovery suggestion enhanced with historical data
*/
generateEnhancedSuggestion(
errorGroup: ErrorGroup,
options: RecoveryOptions = {}
): RecoverySuggestion | null {
// First get the standard suggestion
const standardSuggestion = this.generateSuggestion(errorGroup, options);
if (!standardSuggestion) return null;
// Search for similar historical errors
const historicalMatches = this.searchHistoricalErrors(
errorGroup.fingerprint.sampleMessage,
3
);
// Get learned recoveries for this error type
const learnedRecoveries = this.getLearnedRecoveries()
.filter(lr => lr.errorType === errorGroup.fingerprint.category)
.slice(0, 3);
// Add historical context to actions
if (historicalMatches.length > 0 || learnedRecoveries.length > 0) {
// Add learned actions from history
const historicalActions: RecoveryAction[] = learnedRecoveries
.filter(lr => lr.successRate > 0.5)
.map(lr => ({
id: `action-learned-${Date.now().toString(36)}`,
type: 'fix_config' as RecoveryActionType,
title: `Learned: ${lr.resolution.slice(0, 50)}...`,
description: `Previously resolved ${lr.occurrenceCount} times with ${(lr.successRate * 100).toFixed(0)}% success rate`,
priority: 'high' as RecoveryPriority,
automated: false,
expectedOutcome: lr.resolution,
riskLevel: 'safe' as const,
estimatedTime: 5,
}));
// Combine standard actions with learned actions
standardSuggestion.actions = [
...standardSuggestion.actions.slice(0, 2),
...historicalActions,
...standardSuggestion.actions.slice(2),
].slice(0, 6);
// Boost confidence based on historical data
if (historicalMatches.some(m => m.error.resolutionSuccessful)) {
standardSuggestion.confidence = Math.min(standardSuggestion.confidence + 0.2, 1.0);
}
}
return standardSuggestion;
}
/**
* Get recovery statistics including historical data
*/
getEnhancedStats(): RecoveryStats & {
historicalErrorsCount: number;
learnedRecoveriesCount: number;
avgHistoricalSuccessRate: number;
} {
const baseStats = this.getStats();
const store = getHistoricalStore();
const dbStats = store.getStats();
const learned = store.getLearnedRecoveries();
const avgSuccessRate = learned.length > 0
? learned.reduce((sum, lr) => sum + lr.successRate, 0) / learned.length
: 0;
return {
...baseStats,
historicalErrorsCount: dbStats.errorsCount,
learnedRecoveriesCount: learned.length,
avgHistoricalSuccessRate: avgSuccessRate,
};
}
/**
* Clear all suggestions
*/

View file

@ -21,6 +21,7 @@ import {
TimeWindow,
} from './types.js';
import { CostTracker } from './tui/utils/costTracking.js';
import { getHistoricalStore, HistoricalStore, WorkerComparisonMetrics } from './historicalStore.js';
const DEFAULT_OPTIONS: Required<WorkerAnalyticsOptions> = {
timeWindow: 'all',
@ -627,6 +628,78 @@ export class WorkerAnalytics implements WorkerAnalyticsStore {
underperformers: [],
};
}
// ============================================
// Historical Data Methods
// ============================================
/**
* Get historical store instance
*/
getHistoricalStore(): HistoricalStore {
return getHistoricalStore();
}
/**
* Get worker comparison metrics across sessions
*/
getHistoricalWorkerMetrics(workerId: string): WorkerComparisonMetrics | null {
return getHistoricalStore().getWorkerComparisonMetrics(workerId);
}
/**
* Get historical aggregated analytics
*/
getHistoricalAnalytics(options: { startTime?: number; endTime?: number } = {}): AggregatedAnalytics {
return getHistoricalStore().getAggregatedAnalytics(options);
}
/**
* Compare current worker performance with historical averages
*/
compareWithHistory(workerId: string): {
current: WorkerMetrics | null;
historical: WorkerComparisonMetrics | null;
comparison: {
beadsPerHourChange: number;
errorRateChange: number;
costPerBeadChange: number;
efficiencyChange: number;
} | null;
} {
const current = this.getWorkerMetrics(workerId) || null;
const historical = getHistoricalStore().getWorkerComparisonMetrics(workerId);
let comparison = null;
if (current && historical) {
comparison = {
beadsPerHourChange: historical.avgBeadsPerHour > 0
? ((current.beadsPerHour - historical.avgBeadsPerHour) / historical.avgBeadsPerHour) * 100
: 0,
errorRateChange: ((current.errorRate - historical.avgErrorRate) / (historical.avgErrorRate || 0.01)) * 100,
costPerBeadChange: historical.avgCostPerBead > 0
? ((current.costPerBead - historical.avgCostPerBead) / historical.avgCostPerBead) * 100
: 0,
efficiencyChange: ((current.efficiencyScore - (historical.totalBeadsCompleted > 0 ? 1 : 0)) * 100),
};
}
return { current, historical, comparison };
}
/**
* Get historical database statistics
*/
getHistoricalStats(): {
sessionsCount: number;
tasksCount: number;
errorsCount: number;
dbSizeBytes: number;
oldestSession: number | null;
newestSession: number | null;
} {
return getHistoricalStore().getStats();
}
}
/**