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:
parent
1d8fc2d74d
commit
3457ef35d8
8 changed files with 2198 additions and 1 deletions
432
package-lock.json
generated
432
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
442
src/historicalStore.test.ts
Normal 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
968
src/historicalStore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
src/index.ts
11
src/index.ts
|
|
@ -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';
|
||||
|
|
|
|||
141
src/store.ts
141
src/store.ts
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue