Add sssf skill, installable via the skills CLI
Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be distributed and installed with the skills CLI (skills add INDigitalStudio/skills --skill sssf). - Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the visualizer app source) into sssf/. - Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/, Python bytecode, and the machine-specific repos.json. - Make the skill location-independent: install.py now stamps the skill's real path into the stamped justfile's skill_dir (replacing the hardcoded ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI installed the skill. - Update cookbooks to use <skill>/scripts/... instead of the hardcoded path, and document the skills CLI install command. - Update the repo README with install instructions.
This commit is contained in:
parent
a42608f602
commit
2cc766aabe
98 changed files with 12508 additions and 0 deletions
18
sssf/apps/visualizer/.oxlintrc.json
Normal file
18
sssf/apps/visualizer/.oxlintrc.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2024": true
|
||||
},
|
||||
"rules": {
|
||||
"no-console": "off",
|
||||
"typescript/no-explicit-any": "warn"
|
||||
},
|
||||
"ignorePatterns": ["dist/**", "node_modules/**"]
|
||||
}
|
||||
263
sssf/apps/visualizer/bun.lock
Normal file
263
sssf/apps/visualizer/bun.lock
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sssf-visualizer",
|
||||
"dependencies": {
|
||||
"@fontsource/play": "^5.3.0",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"vue": "^3.5.13",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.14",
|
||||
"@vitejs/plugin-vue": "^6",
|
||||
"oxlint": "^1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7",
|
||||
"vue-tsc": "^3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@fontsource/play": ["@fontsource/play@5.3.0", "", {}, "sha512-VpI6fd/A3jT1J3bOuyHn01CioAqwGWwNReTGycrFF2D57An2EJhDAuG030up3Ho7jSy1sSGW85gtB+l/v3gXOw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.76.0", "", { "os": "android", "cpu": "arm" }, "sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w=="],
|
||||
|
||||
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.76.0", "", { "os": "android", "cpu": "arm64" }, "sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA=="],
|
||||
|
||||
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.76.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q=="],
|
||||
|
||||
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.76.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw=="],
|
||||
|
||||
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.76.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ=="],
|
||||
|
||||
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.76.0", "", { "os": "linux", "cpu": "arm" }, "sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw=="],
|
||||
|
||||
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.76.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw=="],
|
||||
|
||||
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.76.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA=="],
|
||||
|
||||
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.76.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ=="],
|
||||
|
||||
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.76.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg=="],
|
||||
|
||||
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.76.0", "", { "os": "linux", "cpu": "x64" }, "sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A=="],
|
||||
|
||||
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.76.0", "", { "os": "none", "cpu": "arm64" }, "sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ=="],
|
||||
|
||||
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.76.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw=="],
|
||||
|
||||
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.76.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ=="],
|
||||
|
||||
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.3", "", { "os": "android", "cpu": "arm" }, "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.3", "", { "os": "android", "cpu": "arm64" }, "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.3", "", { "os": "linux", "cpu": "arm" }, "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.3", "", { "os": "linux", "cpu": "arm" }, "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.3", "", { "os": "linux", "cpu": "none" }, "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.3", "", { "os": "linux", "cpu": "x64" }, "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.3", "", { "os": "none", "cpu": "arm64" }, "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.3", "", { "os": "win32", "cpu": "x64" }, "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/node": ["@types/node@26.1.2", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.8", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="],
|
||||
|
||||
"@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.40", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.40", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.40", "", { "dependencies": { "@vue/compiler-core": "3.5.40", "@vue/shared": "3.5.40" } }, "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.40", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.40", "@vue/compiler-dom": "3.5.40", "@vue/compiler-ssr": "3.5.40", "@vue/shared": "3.5.40", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.40", "", { "dependencies": { "@vue/compiler-dom": "3.5.40", "@vue/shared": "3.5.40" } }, "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg=="],
|
||||
|
||||
"@vue/language-core": ["@vue/language-core@3.3.8", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.2.1", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.4" } }, "sha512-ieGT8jJdhhy0mGzStZhsg/qPw5bQZJg5yF+3+XU6saf4sM7yo9ZXy3h+nCwrm2+b4qS/SypkNdR2jAF3uei9tA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.40", "", { "dependencies": { "@vue/shared": "3.5.40" } }, "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.40", "", { "dependencies": { "@vue/reactivity": "3.5.40", "@vue/shared": "3.5.40" } }, "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.40", "", { "dependencies": { "@vue/reactivity": "3.5.40", "@vue/runtime-core": "3.5.40", "@vue/shared": "3.5.40", "csstype": "^3.2.3" } }, "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.40", "", { "dependencies": { "@vue/compiler-ssr": "3.5.40", "@vue/runtime-dom": "3.5.40", "@vue/shared": "3.5.40" } }, "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.40", "", {}, "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg=="],
|
||||
|
||||
"alien-signals": ["alien-signals@3.2.1", "", {}, "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"lucide-vue-next": ["lucide-vue-next@1.0.0", "", { "peerDependencies": { "vue": ">=3.0.1" } }, "sha512-V6SPvx1IHTj/UY+FrIYWV5faISsPSb8BnWSFDxAtezWKvWc9ZZ40PDrdu1/Qb5vg4lHWr1hs1BAMGVGm6V1Xdg=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="],
|
||||
|
||||
"oxlint": ["oxlint@1.76.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.76.0", "@oxlint/binding-android-arm64": "1.76.0", "@oxlint/binding-darwin-arm64": "1.76.0", "@oxlint/binding-darwin-x64": "1.76.0", "@oxlint/binding-freebsd-x64": "1.76.0", "@oxlint/binding-linux-arm-gnueabihf": "1.76.0", "@oxlint/binding-linux-arm-musleabihf": "1.76.0", "@oxlint/binding-linux-arm64-gnu": "1.76.0", "@oxlint/binding-linux-arm64-musl": "1.76.0", "@oxlint/binding-linux-ppc64-gnu": "1.76.0", "@oxlint/binding-linux-riscv64-gnu": "1.76.0", "@oxlint/binding-linux-riscv64-musl": "1.76.0", "@oxlint/binding-linux-s390x-gnu": "1.76.0", "@oxlint/binding-linux-x64-gnu": "1.76.0", "@oxlint/binding-linux-x64-musl": "1.76.0", "@oxlint/binding-openharmony-arm64": "1.76.0", "@oxlint/binding-win32-arm64-msvc": "1.76.0", "@oxlint/binding-win32-ia32-msvc": "1.76.0", "@oxlint/binding-win32-x64-msvc": "1.76.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.24", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw=="],
|
||||
|
||||
"rollup": ["rollup@4.62.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.3", "@rollup/rollup-android-arm64": "4.62.3", "@rollup/rollup-darwin-arm64": "4.62.3", "@rollup/rollup-darwin-x64": "4.62.3", "@rollup/rollup-freebsd-arm64": "4.62.3", "@rollup/rollup-freebsd-x64": "4.62.3", "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", "@rollup/rollup-linux-arm-musleabihf": "4.62.3", "@rollup/rollup-linux-arm64-gnu": "4.62.3", "@rollup/rollup-linux-arm64-musl": "4.62.3", "@rollup/rollup-linux-loong64-gnu": "4.62.3", "@rollup/rollup-linux-loong64-musl": "4.62.3", "@rollup/rollup-linux-ppc64-gnu": "4.62.3", "@rollup/rollup-linux-ppc64-musl": "4.62.3", "@rollup/rollup-linux-riscv64-gnu": "4.62.3", "@rollup/rollup-linux-riscv64-musl": "4.62.3", "@rollup/rollup-linux-s390x-gnu": "4.62.3", "@rollup/rollup-linux-x64-gnu": "4.62.3", "@rollup/rollup-linux-x64-musl": "4.62.3", "@rollup/rollup-openbsd-x64": "4.62.3", "@rollup/rollup-openharmony-arm64": "4.62.3", "@rollup/rollup-win32-arm64-msvc": "4.62.3", "@rollup/rollup-win32-ia32-msvc": "4.62.3", "@rollup/rollup-win32-x64-gnu": "4.62.3", "@rollup/rollup-win32-x64-msvc": "4.62.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
|
||||
|
||||
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"vue": ["vue@3.5.40", "", { "dependencies": { "@vue/compiler-dom": "3.5.40", "@vue/compiler-sfc": "3.5.40", "@vue/runtime-dom": "3.5.40", "@vue/server-renderer": "3.5.40", "@vue/shared": "3.5.40" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@3.3.8", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.3.8" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-xXmYlVQpcwJDWyGlqbHrGVOl1h3UOsASymRibrHc+iy9j/UNnOrOn4u+fntHz4D6Cs74RtapeqVV6CzJeg+UlA=="],
|
||||
}
|
||||
}
|
||||
13
sssf/apps/visualizer/index.html
Normal file
13
sssf/apps/visualizer/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<title>Super Simple Software Factory</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
sssf/apps/visualizer/package.json
Normal file
29
sssf/apps/visualizer/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "sssf-visualizer",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Read-only observability UI for the Super Simple Software Factory — polls sssf.db in a target repo.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"server": "bun run server/index.ts",
|
||||
"dev:all": "bun run server/index.ts & vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "bun run server/index.ts",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "oxlint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/play": "^5.3.0",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.1.14",
|
||||
"@vitejs/plugin-vue": "^6",
|
||||
"oxlint": "^1",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7",
|
||||
"vue-tsc": "^3"
|
||||
}
|
||||
}
|
||||
6
sssf/apps/visualizer/public/logo.svg
Normal file
6
sssf/apps/visualizer/public/logo.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<!-- SSSF mark: three swim lanes. -->
|
||||
<rect x="4" y="6" width="17" height="5" rx="2.5" fill="#e8b64a"/>
|
||||
<rect x="8" y="13.5" width="20" height="5" rx="2.5" fill="#c89bff"/>
|
||||
<rect x="4" y="21" width="13" height="5" rx="2.5" fill="#5ad2dd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 316 B |
BIN
sssf/apps/visualizer/public/models/claude.png
Normal file
BIN
sssf/apps/visualizer/public/models/claude.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
sssf/apps/visualizer/public/models/gemini.png
Normal file
BIN
sssf/apps/visualizer/public/models/gemini.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
sssf/apps/visualizer/public/models/kimi.png
Normal file
BIN
sssf/apps/visualizer/public/models/kimi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 96 KiB |
BIN
sssf/apps/visualizer/public/models/openai.png
Normal file
BIN
sssf/apps/visualizer/public/models/openai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
sssf/apps/visualizer/public/models/zai.png
Normal file
BIN
sssf/apps/visualizer/public/models/zai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
418
sssf/apps/visualizer/server/db.ts
Normal file
418
sssf/apps/visualizer/server/db.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
/**
|
||||
* SQLite reader over a target repo's sssf.db.
|
||||
*
|
||||
* The read connection is opened readonly and every query on it is a SELECT —
|
||||
* the writers are the tracers of running ADW processes, and WAL lets us read
|
||||
* straight through their inserts.
|
||||
*
|
||||
* ONE exception, opened lazily on its own connection: `setArchived`. Archiving
|
||||
* is review triage — "I have looked at this run" — which has to outlive a
|
||||
* browser, so it lives on the session row rather than in localStorage. It is
|
||||
* the only write this process can make, it touches exactly one column, and it
|
||||
* never runs unless a human clicks the button.
|
||||
*/
|
||||
import { Database } from "bun:sqlite";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, isAbsolute, resolve } from "node:path";
|
||||
import type {
|
||||
AgentSession,
|
||||
AgentStartPayload,
|
||||
Envelope,
|
||||
Event,
|
||||
EventsPage,
|
||||
GateResult,
|
||||
Phase,
|
||||
Session,
|
||||
SessionDetail,
|
||||
SessionSummary,
|
||||
SessionUsage,
|
||||
} from "../shared/types.ts";
|
||||
|
||||
const DEFAULT_DB_RELATIVE = "adws/adw_data/sssf.db";
|
||||
const MAX_LIMIT = 1000;
|
||||
const DEFAULT_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* Resolve the db path: --db arg wins, then SSSF_DB, then <cwd>/adws/adw_data/sssf.db.
|
||||
* The db lives in the TARGET repo, so cwd is the repo the visualizer is pointed at.
|
||||
*/
|
||||
export function resolveDbPath(argv: string[] = Bun.argv): string {
|
||||
const flagIndex = argv.indexOf("--db");
|
||||
const inline = argv.find((a) => a.startsWith("--db="));
|
||||
const raw =
|
||||
(flagIndex !== -1 ? argv[flagIndex + 1] : undefined) ??
|
||||
inline?.slice("--db=".length) ??
|
||||
process.env.SSSF_DB ??
|
||||
DEFAULT_DB_RELATIVE;
|
||||
|
||||
return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
|
||||
}
|
||||
|
||||
export class SssfDb {
|
||||
readonly path: string;
|
||||
/**
|
||||
* Where the ADW session dirs live: `{data_dir}/sessions/{adw_id}/{agent}/`.
|
||||
* The db sits in the same data_dir (config's `observability.db` defaults to
|
||||
* `adws/adw_data/sssf.db`), so deriving it as a sibling of the db file keeps
|
||||
* working when the whole data_dir is relocated.
|
||||
*/
|
||||
readonly sessionsDir: string;
|
||||
readonly journalMode: string;
|
||||
private readonly db: Database;
|
||||
/** Opened on first archive and kept; null until then. */
|
||||
private writer: Database | null = null;
|
||||
/** Cache for optionalColumn(), keyed "table.column". Only ever false → true. */
|
||||
private readonly columnCache = new Map<string, boolean>();
|
||||
|
||||
constructor(path: string) {
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(
|
||||
`sssf.db not found at ${path}\n` +
|
||||
`Point the visualizer at a target repo: --db <path> or SSSF_DB=<path>, ` +
|
||||
`or run it from a repo root containing ${DEFAULT_DB_RELATIVE}`,
|
||||
);
|
||||
}
|
||||
this.path = path;
|
||||
this.sessionsDir = resolve(dirname(path), "sessions");
|
||||
this.db = new Database(path, { readonly: true });
|
||||
|
||||
// WAL is set by the tracer when it creates the db; a readonly connection
|
||||
// cannot change it, so we assert rather than set, and always take the
|
||||
// busy_timeout so a concurrent writer never turns into a failed request.
|
||||
this.db.exec("PRAGMA busy_timeout = 5000");
|
||||
this.db.exec("PRAGMA synchronous = NORMAL");
|
||||
const mode = this.db
|
||||
.query<{ journal_mode: string }, []>("PRAGMA journal_mode")
|
||||
.get();
|
||||
this.journalMode = mode?.journal_mode ?? "unknown";
|
||||
if (this.journalMode.toLowerCase() !== "wal") {
|
||||
console.warn(
|
||||
`[db] journal_mode is "${this.journalMode}", expected "wal" — ` +
|
||||
`live reads during agent writes may block`,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* A SELECT fragment for a column the tracer adds by migration.
|
||||
*
|
||||
* We open readonly and cannot run those ALTERs ourselves, so selecting one
|
||||
* blindly would throw "no such column" on every request against a db an older
|
||||
* tracer wrote. Instead we probe and substitute NULL, which reads downstream
|
||||
* as "this db predates the column" — the same thing the UI shows for a row
|
||||
* the migration didn't backfill.
|
||||
*
|
||||
* The probe re-runs while the column is missing, because the tracer's ALTER
|
||||
* can land while we're serving: a startup-only check would keep returning
|
||||
* NULL for the rest of the process even after the data arrived. Once seen,
|
||||
* a column never goes away, so it latches.
|
||||
*/
|
||||
private hasColumn(table: string, column: string): boolean {
|
||||
const key = `${table}.${column}`;
|
||||
if (!this.columnCache.get(key)) {
|
||||
const cols = this.db
|
||||
.query<{ name: string }, []>(`PRAGMA table_info(${table})`)
|
||||
.all();
|
||||
this.columnCache.set(key, cols.some((c) => c.name === column));
|
||||
}
|
||||
return this.columnCache.get(key) ?? false;
|
||||
}
|
||||
|
||||
private optionalColumn(table: string, column: string): string {
|
||||
return this.hasColumn(table, column) ? column : `NULL AS ${column}`;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.writer?.close();
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive or restore a session — the only write in this process.
|
||||
*
|
||||
* busy_timeout matters: a run may be mid-insert on the same WAL db, and a
|
||||
* click should wait its turn rather than fail. Returns false when the id
|
||||
* does not exist, so the route can 404 instead of silently succeeding.
|
||||
*/
|
||||
setArchived(adwId: string, archived: boolean): boolean {
|
||||
if (!this.hasColumn("sessions", "archived")) {
|
||||
throw new Error("this db predates the archived column — run any ADW once to migrate it");
|
||||
}
|
||||
if (!this.writer) {
|
||||
this.writer = new Database(this.path);
|
||||
this.writer.exec("PRAGMA busy_timeout=5000;");
|
||||
}
|
||||
this.writer
|
||||
.query("UPDATE sessions SET archived = ? WHERE adw_id = ?")
|
||||
.run(archived ? 1 : 0, adwId);
|
||||
return this.session(adwId) !== null;
|
||||
}
|
||||
|
||||
/** Sessions, most recent first, each with its phase statuses for the progress dots. */
|
||||
sessions(limit = 200): SessionSummary[] {
|
||||
const rows = this.db
|
||||
.query<Session, [number]>(
|
||||
`SELECT adw_id, ${this.optionalColumn("sessions", "adw_name")}, request,
|
||||
status, engineer, started_at, ended_at,
|
||||
total_tokens, total_cost,
|
||||
${this.optionalColumn("sessions", "archived")}
|
||||
FROM sessions
|
||||
WHERE COALESCE(${this.hasColumn("sessions", "archived") ? "archived" : "0"}, 0) = 0
|
||||
ORDER BY started_at DESC, rowid DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(clamp(limit, 1, MAX_LIMIT));
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
// Embed each session's phases so the L1 progress dots cost no extra request.
|
||||
const ids = rows.map((row) => row.adw_id);
|
||||
const placeholders = ids.map(() => "?").join(", ");
|
||||
const phaseRows = this.db
|
||||
.query<Phase, string[]>(
|
||||
`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
|
||||
attempt, retries, error, started_at, ended_at
|
||||
FROM phases WHERE adw_id IN (${placeholders}) ORDER BY seq, rowid`,
|
||||
)
|
||||
.all(...ids);
|
||||
|
||||
const byAdw = new Map<string, Phase[]>();
|
||||
for (const phase of phaseRows) {
|
||||
const list = byAdw.get(phase.adw_id);
|
||||
if (list) list.push(phase);
|
||||
else byAdw.set(phase.adw_id, [phase]);
|
||||
}
|
||||
|
||||
// Agents come along too: an L1 card draws a per-agent dot timeline, and its
|
||||
// dots are colored per agent — without this it would be one request per card.
|
||||
const agentsByAdw = this.agentsFor(ids);
|
||||
|
||||
const summaries: SessionSummary[] = [];
|
||||
for (const session of rows) {
|
||||
const phases = byAdw.get(session.adw_id) ?? [];
|
||||
summaries.push(
|
||||
Object.assign(session, {
|
||||
phases,
|
||||
phase_count: phases.length,
|
||||
agents: agentsByAdw.get(session.adw_id) ?? [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
session(adwId: string): Session | null {
|
||||
return (
|
||||
this.db
|
||||
.query<Session, [string]>(
|
||||
`SELECT adw_id, ${this.optionalColumn("sessions", "adw_name")}, request,
|
||||
status, engineer, started_at, ended_at,
|
||||
total_tokens, total_cost
|
||||
FROM sessions WHERE adw_id = ?`,
|
||||
)
|
||||
.get(adwId) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
phases(adwId: string): Phase[] {
|
||||
return this.db
|
||||
.query<Phase, [string]>(
|
||||
`SELECT phase_id, adw_id, seq, name, kind, owner, description, status,
|
||||
attempt, retries, error, started_at, ended_at
|
||||
FROM phases WHERE adw_id = ? ORDER BY seq, rowid`,
|
||||
)
|
||||
.all(adwId);
|
||||
}
|
||||
|
||||
agentSessions(adwId: string): AgentSession[] {
|
||||
return this.agentsFor([adwId]).get(adwId) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Agents per session, for a set of ids at once: the agent_sessions rows plus
|
||||
* anything that has started but not finished.
|
||||
*
|
||||
* agents.py writes the agent_sessions row only after the envelope persists, so
|
||||
* a running agent has no row there — precisely the case the live view exists
|
||||
* for. Its model, color and session_id are already on the agent_start event,
|
||||
* so a lane is labelled and colored from the moment the agent spawns.
|
||||
*/
|
||||
private agentsFor(adwIds: string[]): Map<string, AgentSession[]> {
|
||||
const byAdw = new Map<string, AgentSession[]>();
|
||||
if (adwIds.length === 0) return byAdw;
|
||||
const placeholders = adwIds.map(() => "?").join(", ");
|
||||
|
||||
const append = (adwId: string, agent: AgentSession) => {
|
||||
const list = byAdw.get(adwId);
|
||||
if (list) list.push(agent);
|
||||
else byAdw.set(adwId, [agent]);
|
||||
};
|
||||
|
||||
const color = this.optionalColumn("agent_sessions", "color");
|
||||
const ctxUsed = this.optionalColumn("agent_sessions", "context_tokens");
|
||||
const ctxWindow = this.optionalColumn("agent_sessions", "context_window");
|
||||
|
||||
const completed = this.db
|
||||
.query<AgentSession, string[]>(
|
||||
`SELECT adw_id, agent, coding_agent, model, session_id, ${color},
|
||||
${ctxUsed}, ${ctxWindow}, created_at, last_used_at
|
||||
FROM agent_sessions WHERE adw_id IN (${placeholders})
|
||||
ORDER BY created_at, agent`,
|
||||
)
|
||||
.all(...adwIds);
|
||||
for (const row of completed) append(row.adw_id, row);
|
||||
|
||||
const started = this.db
|
||||
.query<
|
||||
{
|
||||
adw_id: string;
|
||||
agent: string | null;
|
||||
payload_json: string | null;
|
||||
started_at: string | null;
|
||||
},
|
||||
string[]
|
||||
>(
|
||||
`SELECT e.adw_id, p.owner AS agent, e.payload_json, e.started_at
|
||||
FROM events e JOIN phases p ON p.phase_id = e.phase_id
|
||||
WHERE e.adw_id IN (${placeholders}) AND e.type = 'agent_start'
|
||||
ORDER BY e.rowid`,
|
||||
)
|
||||
.all(...adwIds);
|
||||
|
||||
for (const row of started) {
|
||||
if (!row.agent) continue;
|
||||
// A finished row is authoritative; only fill genuine gaps.
|
||||
if (byAdw.get(row.adw_id)?.some((a) => a.agent === row.agent)) continue;
|
||||
let payload: AgentStartPayload = {};
|
||||
try {
|
||||
payload = JSON.parse(row.payload_json ?? "{}") as AgentStartPayload;
|
||||
} catch {
|
||||
// A malformed payload just means no label — never a failed request.
|
||||
}
|
||||
append(row.adw_id, {
|
||||
adw_id: row.adw_id,
|
||||
agent: row.agent,
|
||||
coding_agent: null,
|
||||
model: payload.model ?? null,
|
||||
session_id: payload.session_id ?? null,
|
||||
color: payload.color ?? null,
|
||||
// Occupancy is only known once the agent's turn closes.
|
||||
context_tokens: null,
|
||||
context_window: null,
|
||||
created_at: row.started_at,
|
||||
last_used_at: row.started_at,
|
||||
});
|
||||
}
|
||||
return byAdw;
|
||||
}
|
||||
|
||||
/** Session + phases + agents in one shot — L2 needs all three to draw lanes. */
|
||||
sessionDetail(adwId: string): SessionDetail | null {
|
||||
const session = this.session(adwId);
|
||||
if (!session) return null;
|
||||
|
||||
return {
|
||||
session,
|
||||
usage: this.usage(adwId),
|
||||
phases: this.phases(adwId),
|
||||
agents: this.agentSessions(adwId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw tokens read and written, beside the billed headline.
|
||||
*
|
||||
* Derived from the `agent_end` payloads rather than stored, so every run
|
||||
* already in the db gets the split without a migration or a re-run.
|
||||
*
|
||||
* `total_tokens` is a SPEND number: every turn re-sends the whole
|
||||
* conversation, so an 86k conversation over 49 turns bills millions. These
|
||||
* two say what actually moved — material read for the first time, and
|
||||
* material generated. The gap between them and the headline is cached
|
||||
* re-reads, which is usually most of it.
|
||||
*/
|
||||
usage(adwId: string): SessionUsage {
|
||||
const rows = this.db
|
||||
.query<{ payload_json: string | null }, [string]>(
|
||||
"SELECT payload_json FROM events WHERE adw_id = ? AND type = 'agent_end'",
|
||||
)
|
||||
.all(adwId);
|
||||
|
||||
let read = 0;
|
||||
let written = 0;
|
||||
for (const row of rows) {
|
||||
if (!row.payload_json) continue;
|
||||
try {
|
||||
const u = (JSON.parse(row.payload_json) as { usage?: Record<string, number> }).usage;
|
||||
if (!u) continue;
|
||||
// RAW reads only: material entering the context for the first time,
|
||||
// billed either as uncached input or as a cache write. Cache reads are
|
||||
// the same tokens served again on later turns — counting them here
|
||||
// would rebuild the very inflation this split exists to expose.
|
||||
read += (u.input_tokens ?? 0) + (u.cache_write_tokens ?? 0);
|
||||
written += u.output_tokens ?? 0;
|
||||
} catch {
|
||||
/* a payload written by an older tracer simply contributes nothing */
|
||||
}
|
||||
}
|
||||
return { read, written };
|
||||
}
|
||||
|
||||
/**
|
||||
* The polling query. Rowid cursor, insertion order, bounded page — the same
|
||||
* mechanism serves the live tail and lazy-paged history.
|
||||
*/
|
||||
events(adwId: string, after = 0, limit = DEFAULT_LIMIT): EventsPage {
|
||||
const cappedLimit = clamp(limit, 1, MAX_LIMIT);
|
||||
const events = this.db
|
||||
.query<Event, [string, number, number]>(
|
||||
`SELECT rowid, event_id, adw_id, phase_id, parent_id, type, name,
|
||||
payload_json, tokens, started_at, ended_at
|
||||
FROM events
|
||||
WHERE adw_id = ? AND rowid > ?
|
||||
ORDER BY rowid
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(adwId, Math.max(0, after), cappedLimit);
|
||||
|
||||
return {
|
||||
events,
|
||||
cursor: events.length > 0 ? events[events.length - 1]!.rowid : Math.max(0, after),
|
||||
has_more: events.length === cappedLimit,
|
||||
};
|
||||
}
|
||||
|
||||
envelopes(adwId: string): Envelope[] {
|
||||
return this.db
|
||||
.query<Envelope, [string]>(
|
||||
`SELECT envelope_id, adw_id, phase_id, agent, output_type, payload_json,
|
||||
valid, attempt, created_at
|
||||
FROM envelopes WHERE adw_id = ? ORDER BY created_at, rowid`,
|
||||
)
|
||||
.all(adwId);
|
||||
}
|
||||
|
||||
gates(adwId: string): GateResult[] {
|
||||
const checks = this.optionalColumn("gate_results", "checks_json");
|
||||
return this.db
|
||||
.query<GateResult, [string]>(
|
||||
`SELECT id, adw_id, phase_id, attempt, gate, passed, violations_json,
|
||||
${checks}, created_at
|
||||
FROM gate_results WHERE adw_id = ? ORDER BY id`,
|
||||
)
|
||||
.all(adwId);
|
||||
}
|
||||
|
||||
sessionCount(): number {
|
||||
const row = this.db
|
||||
.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM sessions")
|
||||
.get();
|
||||
return row?.n ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||||
}
|
||||
261
sssf/apps/visualizer/server/index.ts
Normal file
261
sssf/apps/visualizer/server/index.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* SSSF visualizer server — JSON API over one or more repos' sssf.db, plus the
|
||||
* built UI when ./dist exists. Reads are read-only; the single write is
|
||||
* POST /api/sessions/:adw_id/archive, which sets one review flag on a row.
|
||||
*
|
||||
* There is no ingest endpoint and no websocket. The data path is
|
||||
* agents → sqlite → web ui, and the UI gets there by polling.
|
||||
*
|
||||
* Single repo (legacy):
|
||||
* bun run server/index.ts
|
||||
* bun run server/index.ts --db /path/to/repo/adws/adw_data/sssf.db
|
||||
* SSSF_DB=/path/to/sssf.db PORT=4600 bun run server/index.ts
|
||||
*
|
||||
* Multi repo:
|
||||
* SSSF_REPOS=/path/to/repos.json PORT=4600 bun run server/index.ts
|
||||
*
|
||||
* In multi-repo mode every route is prefixed with the repo slug:
|
||||
* /api/repos
|
||||
* /api/:repo/sessions
|
||||
* /api/:repo/sessions/:adw_id
|
||||
* ...
|
||||
* The legacy unprefixed routes (/api/sessions, ...) are kept and resolve to
|
||||
* the first repo, so a single-repo deployment and the SPA's default view
|
||||
* keep working unchanged.
|
||||
*/
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { join, resolve, sep } from "node:path";
|
||||
import { buildRepos, type RepoRuntime } from "./repos.ts";
|
||||
import type { AgentPrompts, ApiError, HealthResponse } from "../shared/types.ts";
|
||||
import type { Server } from "bun";
|
||||
const PORT = Number(process.env.PORT ?? 4600);
|
||||
const DIST_DIR = resolve(import.meta.dir, "..", "dist");
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function notFound(message: string): Response {
|
||||
return json({ error: message } satisfies ApiError, 404);
|
||||
}
|
||||
|
||||
/** Guard every handler so a malformed query can't take the server down mid-run. */
|
||||
function safely(
|
||||
handler: (req: Request) => Response | Promise<Response>,
|
||||
): (req: Request) => Promise<Response> {
|
||||
return async (req) => {
|
||||
try {
|
||||
return await handler(req);
|
||||
} catch (error) {
|
||||
console.error(`[sssf] ${req.method} ${new URL(req.url).pathname}:`, error);
|
||||
return json({ error: (error as Error).message } satisfies ApiError, 500);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* adw_ids and agent names are path segments on disk, so anything that isn't a
|
||||
* plain identifier is rejected outright rather than sanitized into something
|
||||
* that might still escape the sessions directory.
|
||||
*/
|
||||
const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function isSafeSegment(value: string): boolean {
|
||||
return SAFE_SEGMENT.test(value) && value !== "." && value !== "..";
|
||||
}
|
||||
|
||||
function param(req: Request, key: string): string {
|
||||
return decodeURIComponent(
|
||||
(req as Request & { params: Record<string, string> }).params[key] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
function intQuery(req: Request, key: string, fallback: number): number {
|
||||
const raw = new URL(req.url).searchParams.get(key);
|
||||
if (raw === null || raw.trim() === "") return fallback;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
/** Serve the built SPA if it has been built; otherwise point at the dev server. */
|
||||
async function serveStatic(req: Request): Promise<Response> {
|
||||
const { pathname } = new URL(req.url);
|
||||
|
||||
if (!existsSync(DIST_DIR)) {
|
||||
return new Response(
|
||||
`SSSF visualizer API is running on :${PORT}.\n\n` +
|
||||
`No ./dist build found. Run "bun run dev" for the Vite dev server ` +
|
||||
`(it proxies /api here), or "bun run build" to serve the UI from this process.\n`,
|
||||
{ status: 200, headers: { "content-type": "text/plain; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
// Reject traversal before touching the filesystem.
|
||||
const candidate = resolve(join(DIST_DIR, pathname));
|
||||
if (candidate === DIST_DIR || candidate.startsWith(DIST_DIR + "/")) {
|
||||
if (existsSync(candidate) && statSync(candidate).isFile()) {
|
||||
return new Response(Bun.file(candidate));
|
||||
}
|
||||
}
|
||||
|
||||
// SPA fallback: breadcrumb routes are client-side.
|
||||
const indexHtml = join(DIST_DIR, "index.html");
|
||||
if (existsSync(indexHtml)) {
|
||||
return new Response(Bun.file(indexHtml), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
return notFound("not found");
|
||||
}
|
||||
|
||||
/** Build the route table for one repo, mounted at /api/:repo and /api (default). */
|
||||
function repoRoutes(repo: RepoRuntime, prefix: string) {
|
||||
const db = repo.db;
|
||||
const p = (path: string) => `${prefix}${path}`;
|
||||
return {
|
||||
[p("/health")]: safely(
|
||||
() =>
|
||||
json({
|
||||
ok: true,
|
||||
repo: repo.slug,
|
||||
db: db.path,
|
||||
journal_mode: db.journalMode,
|
||||
sessions: db.sessionCount(),
|
||||
} satisfies HealthResponse),
|
||||
),
|
||||
|
||||
[p("/sessions")]: safely((req) => json(db.sessions(intQuery(req, "limit", 200)))),
|
||||
|
||||
[p("/sessions/:adw_id")]: safely((req) => {
|
||||
const detail = db.sessionDetail(param(req, "adw_id"));
|
||||
return detail ? json(detail) : notFound(`no session ${param(req, "adw_id")}`);
|
||||
}),
|
||||
|
||||
// The one write. Archiving is review triage — it belongs to the reader, not
|
||||
// to the run — so it never touches anything a tracer wrote.
|
||||
[p("/sessions/:adw_id/archive")]: {
|
||||
POST: safely(async (req) => {
|
||||
const adwId = param(req, "adw_id");
|
||||
if (!isSafeSegment(adwId)) {
|
||||
return json({ error: "invalid adw_id" } satisfies ApiError, 400);
|
||||
}
|
||||
const body = (await req.json().catch(() => ({}))) as { archived?: unknown };
|
||||
const archived = body.archived === undefined ? true : Boolean(body.archived);
|
||||
return db.setArchived(adwId, archived)
|
||||
? json({ adw_id: adwId, archived })
|
||||
: notFound(`no session ${adwId}`);
|
||||
}),
|
||||
},
|
||||
|
||||
[p("/sessions/:adw_id/events")]: safely((req) =>
|
||||
json(
|
||||
db.events(
|
||||
param(req, "adw_id"),
|
||||
intQuery(req, "after", 0),
|
||||
intQuery(req, "limit", 500),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
[p("/sessions/:adw_id/envelopes")]: safely((req) =>
|
||||
json(db.envelopes(param(req, "adw_id"))),
|
||||
),
|
||||
|
||||
[p("/sessions/:adw_id/gates")]: safely((req) => json(db.gates(param(req, "adw_id")))),
|
||||
|
||||
// The exact prompts an agent was sent, read from the session dir. Files are
|
||||
// the raw record; the db has no copy of them.
|
||||
[p("/sessions/:adw_id/agents/:agent/prompts")]: safely(async (req) => {
|
||||
const adwId = param(req, "adw_id");
|
||||
const agent = param(req, "agent");
|
||||
if (!isSafeSegment(adwId) || !isSafeSegment(agent)) {
|
||||
return json({ error: "invalid adw_id or agent" } satisfies ApiError, 400);
|
||||
}
|
||||
if (!db.session(adwId)) return notFound(`no session ${adwId}`);
|
||||
|
||||
const dir = resolve(db.sessionsDir, adwId, agent, "prompts");
|
||||
// Defense in depth: the segment check already forbids traversal.
|
||||
if (dir !== db.sessionsDir && !dir.startsWith(db.sessionsDir + sep)) {
|
||||
return json({ error: "invalid path" } satisfies ApiError, 400);
|
||||
}
|
||||
|
||||
// A prompt file is absent whenever the agent never ran in this session —
|
||||
// a normal state, so it reads as null rather than an error.
|
||||
const read = async (name: string): Promise<string | null> => {
|
||||
const file = Bun.file(join(dir, `${name}.md`));
|
||||
return (await file.exists()) ? await file.text() : null;
|
||||
};
|
||||
return json({
|
||||
system: await read("system"),
|
||||
user: await read("user"),
|
||||
} satisfies AgentPrompts);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// The registry is mutable: POST /api/reload re-reads repos.json and swaps the
|
||||
// route table in place (Bun's server.reload), so new repos appear without a
|
||||
// process restart. `server` is assigned below; the reload handler only runs on
|
||||
// a request, by which point it is set.
|
||||
let repos: RepoRuntime[] = (await buildRepos()).repos;
|
||||
let server: Server<undefined>;
|
||||
|
||||
/** Build the route table for the current registry. */
|
||||
function buildRoutes(repos: RepoRuntime[]): Record<string, unknown> {
|
||||
const routes: Record<string, unknown> = {
|
||||
"/api/repos": safely(() =>
|
||||
json(repos.map((r) => ({ slug: r.slug, name: r.name, db: r.db.path }))),
|
||||
),
|
||||
// Re-read repos.json and swap the registry + routes in place. The UI calls
|
||||
// this after a repo is added/removed, so it shows up without a restart.
|
||||
"/api/reload": {
|
||||
POST: safely(async () => {
|
||||
const next = (await buildRepos()).repos;
|
||||
repos = next;
|
||||
server.reload({ routes: buildRoutes(next) as never });
|
||||
return json({
|
||||
ok: true,
|
||||
repos: next.map((r) => ({ slug: r.slug, name: r.name, db: r.db.path })),
|
||||
});
|
||||
}),
|
||||
},
|
||||
};
|
||||
for (const repo of repos) {
|
||||
Object.assign(routes, repoRoutes(repo, `/api/${repo.slug}`));
|
||||
}
|
||||
Object.assign(routes, repoRoutes(repos[0], "/api"));
|
||||
return routes;
|
||||
}
|
||||
|
||||
const routes = buildRoutes(repos);
|
||||
|
||||
server = Bun.serve({
|
||||
port: PORT,
|
||||
routes: routes as never,
|
||||
fetch(req) {
|
||||
const { pathname } = new URL(req.url);
|
||||
if (pathname.startsWith("/api/")) return notFound(`no route ${pathname}`);
|
||||
return serveStatic(req);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`[sssf] visualizer api http://localhost:${server.port}`);
|
||||
for (const repo of repos) {
|
||||
console.log(`[sssf] repo ${repo.slug} ${repo.db.path} [journal_mode=${repo.db.journalMode}]`);
|
||||
}
|
||||
console.log(
|
||||
existsSync(DIST_DIR)
|
||||
? `[sssf] serving ui from ${DIST_DIR}`
|
||||
: `[sssf] no ./dist — use "bun run dev" for the Vite dev server on :4601`,
|
||||
);
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
146
sssf/apps/visualizer/server/repos.ts
Normal file
146
sssf/apps/visualizer/server/repos.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* Repo registry for the multi-repo visualizer.
|
||||
*
|
||||
* The visualizer can serve several repos' trace DBs from one process. A repo
|
||||
* is a slug → sssf.db mapping, declared in a JSON file:
|
||||
*
|
||||
* [
|
||||
* { "slug": "tailsandstays", "name": "Tails & Stays",
|
||||
* "db": "/home/ima/projects/tailsandstays/adws/adw_data/sssf.db" },
|
||||
* { "slug": "mempalace", "name": "MemPalace",
|
||||
* "db": "/home/ima/projects/mempalace/adws/adw_data/sssf.db" }
|
||||
* ]
|
||||
*
|
||||
* Point the server at it with SSSF_REPOS=/path/to/repos.json. When SSSF_REPOS
|
||||
* is unset the server auto-discovers repos under a projects root (default
|
||||
* ~/projects, override with SSSF_PROJECTS_ROOT) by scanning for a trace db at
|
||||
* <root>/<dir>/adws/adw_data/sssf.db. If nothing is discovered it falls back to
|
||||
* the single-repo behaviour (--db / SSSF_DB / <cwd>/adws/adw_data/sssf.db)
|
||||
* under a slug derived from the repo dir name, so the original single-repo
|
||||
* deployment keeps working.
|
||||
*/
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import { SssfDb, resolveDbPath } from "./db.ts";
|
||||
|
||||
export interface RepoEntry {
|
||||
slug: string;
|
||||
name: string;
|
||||
db: string;
|
||||
}
|
||||
|
||||
export interface RepoRuntime {
|
||||
slug: string;
|
||||
name: string;
|
||||
db: SssfDb;
|
||||
}
|
||||
|
||||
const SAFE_SLUG = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function isSafeSlug(slug: string): boolean {
|
||||
return SAFE_SLUG.test(slug) && slug !== "." && slug !== "..";
|
||||
}
|
||||
|
||||
/** Derive a display slug from a db path: <repo>/adws/adw_data/sssf.db → repo dir name. */
|
||||
function slugFromDb(dbPath: string): string {
|
||||
// adw_data → adws → repo root
|
||||
const repoDir = dirname(dirname(dirname(dbPath)));
|
||||
const name = basename(repoDir);
|
||||
return name && isSafeSlug(name) ? name : "default";
|
||||
}
|
||||
|
||||
async function loadReposFile(path: string): Promise<RepoEntry[]> {
|
||||
const raw = Bun.file(path);
|
||||
if (!existsSync(path)) {
|
||||
throw new Error(`SSSF_REPOS file not found: ${path}`);
|
||||
}
|
||||
const data = JSON.parse(await raw.text()) as unknown;
|
||||
if (!Array.isArray(data)) {
|
||||
throw new Error(`SSSF_REPOS file must be a JSON array of { slug, name, db }`);
|
||||
}
|
||||
const entries: RepoEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of data) {
|
||||
const entry = item as Partial<RepoEntry>;
|
||||
if (typeof entry.slug !== "string" || !isSafeSlug(entry.slug)) {
|
||||
throw new Error(`invalid repo slug: ${String(entry.slug)}`);
|
||||
}
|
||||
if (typeof entry.db !== "string") {
|
||||
throw new Error(`repo "${entry.slug}" is missing a db path`);
|
||||
}
|
||||
if (seen.has(entry.slug)) {
|
||||
throw new Error(`duplicate repo slug: ${entry.slug}`);
|
||||
}
|
||||
seen.add(entry.slug);
|
||||
const db = isAbsolute(entry.db) ? entry.db : resolve(dirname(path), entry.db);
|
||||
entries.push({
|
||||
slug: entry.slug,
|
||||
name: typeof entry.name === "string" && entry.name ? entry.name : entry.slug,
|
||||
db,
|
||||
});
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
throw new Error("SSSF_REPOS file declares no repos");
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-discover repos under a projects root by scanning for a trace db at
|
||||
* <root>/<dir>/adws/adw_data/sssf.db. Used when SSSF_REPOS is unset so new
|
||||
* repos appear without editing a registry file. The root defaults to
|
||||
* ~/projects and can be overridden with SSSF_PROJECTS_ROOT.
|
||||
*/
|
||||
function discoverRepos(root: string): RepoEntry[] {
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
const entries: RepoEntry[] = [];
|
||||
for (const name of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!name.isDirectory() || name.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
const db = join(root, name.name, "adws", "adw_data", "sssf.db");
|
||||
if (existsSync(db) && isSafeSlug(name.name)) {
|
||||
entries.push({ slug: name.name, name: name.name, db });
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the repo registry. The first repo is served under the legacy
|
||||
* unprefixed /api routes (the single repo in single-repo mode, or the first
|
||||
* repo in multi-repo mode).
|
||||
*/
|
||||
export async function buildRepos(): Promise<{ repos: RepoRuntime[] }> {
|
||||
const reposFile = process.env.SSSF_REPOS;
|
||||
const entries: RepoEntry[] = reposFile
|
||||
? await loadReposFile(reposFile)
|
||||
: discoverRepos(process.env.SSSF_PROJECTS_ROOT ?? join(homedir(), "projects"));
|
||||
if (entries.length === 0) {
|
||||
// No discovered repos — fall back to the single-repo behaviour so the
|
||||
// original deployment (--db / SSSF_DB / <cwd>/adws/adw_data/sssf.db) works.
|
||||
entries.push({ slug: slugFromDb(resolveDbPath()), name: "", db: resolveDbPath() });
|
||||
}
|
||||
|
||||
const repos: RepoRuntime[] = [];
|
||||
for (const entry of entries) {
|
||||
let db: SssfDb;
|
||||
try {
|
||||
db = new SssfDb(entry.db);
|
||||
} catch (error) {
|
||||
console.error(`[sssf] repo "${entry.slug}": ${(error as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
repos.push({ slug: entry.slug, name: entry.name, db });
|
||||
}
|
||||
|
||||
if (repos.length === 0) {
|
||||
console.error("[sssf] no repos could be opened — exiting");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { repos };
|
||||
}
|
||||
314
sssf/apps/visualizer/shared/types.ts
Normal file
314
sssf/apps/visualizer/shared/types.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* Types shared by the read-only server and the Vue client.
|
||||
*
|
||||
* Every interface mirrors a table in sssf.db one-for-one (see
|
||||
* references/observability.md). Nothing here is derived state: phase durations,
|
||||
* session progress and lane layout are computed in the UI, never stored.
|
||||
*/
|
||||
|
||||
/** sessions.status — a run is running until it earns success. */
|
||||
export type SessionStatus = "running" | "success" | "fail";
|
||||
|
||||
/** phases.status — queued only for manifest-declared phases not yet entered. */
|
||||
export type PhaseStatus = "queued" | "running" | "success" | "fail";
|
||||
|
||||
/** phases.kind — decides which lane a block renders in. */
|
||||
export type PhaseKind = "engineer" | "code" | "agent";
|
||||
|
||||
/** events.type — the ten types tracer.py emits. */
|
||||
export type EventType =
|
||||
| "phase_start"
|
||||
| "phase_end"
|
||||
| "agent_start"
|
||||
| "agent_end"
|
||||
| "tool_call"
|
||||
| "handoff"
|
||||
| "gate_pass"
|
||||
| "gate_fail"
|
||||
| "log"
|
||||
| "error";
|
||||
|
||||
export interface Session {
|
||||
adw_id: string;
|
||||
/** ADW script(s) that ran this session, e.g. "adw_plan + adw_build_test". */
|
||||
adw_name: string | null;
|
||||
request: string | null;
|
||||
status: SessionStatus | null;
|
||||
engineer: string | null;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
total_tokens: number | null;
|
||||
total_cost: number | null;
|
||||
/** 1 once archived out of the review list. Review state, not run state. */
|
||||
archived: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A session row with its phases embedded, so the L1 table draws the
|
||||
* mini-progress dots without a second request per row.
|
||||
*/
|
||||
export interface SessionSummary extends Session {
|
||||
/** Full phase rows, ordered by seq — one dot each. */
|
||||
phases: Phase[];
|
||||
phase_count: number;
|
||||
/**
|
||||
* The session's agents, same shape and merge rules as SessionDetail.agents —
|
||||
* so an L1 card can color its per-agent dots without a request per card.
|
||||
*/
|
||||
agents: AgentSession[];
|
||||
}
|
||||
|
||||
export interface Phase {
|
||||
phase_id: string;
|
||||
adw_id: string;
|
||||
seq: number | null;
|
||||
name: string | null;
|
||||
kind: PhaseKind | null;
|
||||
owner: string | null;
|
||||
description: string | null;
|
||||
status: PhaseStatus | null;
|
||||
attempt: number | null;
|
||||
retries: number | null;
|
||||
error: string | null;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
}
|
||||
|
||||
export interface Event {
|
||||
/** SQLite rowid — the polling cursor. Monotonic, insertion-ordered. */
|
||||
rowid: number;
|
||||
event_id: string;
|
||||
adw_id: string;
|
||||
phase_id: string | null;
|
||||
/** Span nesting: an agent phase expands into its tool-call children. */
|
||||
parent_id: string | null;
|
||||
type: EventType | null;
|
||||
name: string | null;
|
||||
/** Raw JSON string as written by the tracer; parse at the point of display. */
|
||||
payload_json: string | null;
|
||||
tokens: number | null;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
}
|
||||
|
||||
export interface Envelope {
|
||||
envelope_id: string;
|
||||
adw_id: string;
|
||||
phase_id: string | null;
|
||||
agent: string | null;
|
||||
/** Name of the data_types model the response was parsed against. */
|
||||
output_type: string | null;
|
||||
payload_json: string | null;
|
||||
/** SQLite integer boolean. */
|
||||
valid: number | null;
|
||||
attempt: number | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface GateResult {
|
||||
id: number;
|
||||
adw_id: string;
|
||||
phase_id: string | null;
|
||||
attempt: number | null;
|
||||
gate: string | null;
|
||||
/** SQLite integer boolean. */
|
||||
passed: number | null;
|
||||
/** JSON array of violation strings; "[]" on a pass. */
|
||||
violations_json: string | null;
|
||||
/**
|
||||
* JSON array of GateCheck — the per-item evidence behind the verdict, so a
|
||||
* green gate can say WHAT it verified rather than only that it passed.
|
||||
* Null on rows written before the tracer recorded checks; those are not
|
||||
* backfilled, so fall back to the verdict alone.
|
||||
*/
|
||||
checks_json: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** One item a gate inspected — the parsed element of `checks_json`. */
|
||||
export interface GateCheck {
|
||||
item: string;
|
||||
ok: boolean;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** agent_sessions — the queryable mirror of agent_map.json. Supplies lane labels (`name · model`). */
|
||||
export interface AgentSession {
|
||||
adw_id: string;
|
||||
agent: string;
|
||||
coding_agent: string | null;
|
||||
model: string | null;
|
||||
session_id: string | null;
|
||||
/**
|
||||
* The agent's lane color from sssf.config.yaml, e.g. "#a78bfa". Null on dbs
|
||||
* written by a tracer predating the column, and on agents with no configured
|
||||
* color — fall back to the UI's own palette.
|
||||
*/
|
||||
color: string | null;
|
||||
/**
|
||||
* How full the agent's context window was after its last turn, and the
|
||||
* model's ceiling. Null on dbs predating the columns and on an agent still
|
||||
* running — the lane draws no bar rather than a misleading empty one.
|
||||
*/
|
||||
context_tokens: number | null;
|
||||
context_window: number | null;
|
||||
created_at: string | null;
|
||||
last_used_at: string | null;
|
||||
}
|
||||
|
||||
// ── payload_json shapes ──────────────────────────────────────────────────────
|
||||
// events.payload_json is stored as a string. These are the parsed shapes for
|
||||
// the two payloads the UI renders; every field is optional because the tracer
|
||||
// writes what the coding agent reported, which varies by agent and by version.
|
||||
|
||||
/** Parsed `agent_start` payload — the live source of a lane's label and color. */
|
||||
export interface AgentStartPayload {
|
||||
model?: string;
|
||||
thinking?: string;
|
||||
session_id?: string;
|
||||
color?: string;
|
||||
coding_agent?: string;
|
||||
purpose?: string;
|
||||
/** Tool allowlist; null means all tools. Absent on pre-config-payload rows. */
|
||||
tools?: string[] | null;
|
||||
harness_engineering?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokens and dollars per component for one agent phase, summed across every
|
||||
* send it made (a retried phase paid more than once). Mirrors pi's `usage`:
|
||||
* `input_tokens` EXCLUDES cache reads, which bill at their own rate.
|
||||
*/
|
||||
export interface UsageBreakdown {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
/**
|
||||
* Thinking tokens — the reasoning SHARE of `output_tokens`, not a fifth
|
||||
* component. Billed at the output rate; adding it to the others would
|
||||
* double-count. Absent (undefined) on runs predating the field.
|
||||
*/
|
||||
reasoning_tokens?: number;
|
||||
total_tokens: number;
|
||||
input_cost: number;
|
||||
output_cost: number;
|
||||
cache_read_cost: number;
|
||||
cache_write_cost: number;
|
||||
total_cost: number;
|
||||
}
|
||||
|
||||
/** Parsed `agent_end` payload — closes out a call with its cost and context use. */
|
||||
export interface AgentEndPayload {
|
||||
cost?: number;
|
||||
/** Absent on runs predating the breakdown; `cost` alone survives there. */
|
||||
usage?: UsageBreakdown;
|
||||
/** Window occupancy after the final turn, and the model's ceiling. */
|
||||
context_tokens?: number;
|
||||
context_window?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed `tool_call` payload — one event per real tool call, emitted when the
|
||||
* tool returns. `result_snippet` and `duration_ms` are absent when the coding
|
||||
* agent never reported a result.
|
||||
*/
|
||||
export interface ToolCallPayload {
|
||||
tool?: string;
|
||||
tool_call_id?: string;
|
||||
args?: Record<string, unknown>;
|
||||
result_snippet?: string;
|
||||
ok?: boolean;
|
||||
duration_ms?: number;
|
||||
agent?: string;
|
||||
}
|
||||
|
||||
// ── API responses ────────────────────────────────────────────────────────────
|
||||
|
||||
/** GET /api/sessions */
|
||||
export type SessionsResponse = SessionSummary[];
|
||||
|
||||
/** GET /api/sessions/:adw_id */
|
||||
/**
|
||||
* What actually moved through a session, summed across every agent.
|
||||
*
|
||||
* Deliberately NOT the billed total: `sessions.total_tokens` also counts every
|
||||
* cached re-read, which is the same context charged again on each turn.
|
||||
*/
|
||||
export interface SessionUsage {
|
||||
/** Raw prompt tokens read for the first time: new input + cache writes. */
|
||||
read: number;
|
||||
/** Tokens generated. Each produced exactly once, so this needs no adjusting. */
|
||||
written: number;
|
||||
}
|
||||
|
||||
export interface SessionDetail {
|
||||
session: Session;
|
||||
/** Derived from agent_end payloads, so historical runs have it too. */
|
||||
usage: SessionUsage;
|
||||
/** Ordered by seq. */
|
||||
phases: Phase[];
|
||||
/**
|
||||
* One entry per agent that has run OR is running under this adw_id — lane
|
||||
* labels come from here. Finished agents come from the agent_sessions table;
|
||||
* an agent still in flight has no row there yet, so its entry is built from
|
||||
* its agent_start event (coding_agent is null until it finishes).
|
||||
*/
|
||||
agents: AgentSession[];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sessions/:adw_id/events?after=<rowid>&limit=500
|
||||
*
|
||||
* Poll with `after` = the cursor from the previous response. `cursor` is the
|
||||
* highest rowid in this page (or the `after` you sent, when the page is empty),
|
||||
* so it can be fed straight back in. `has_more` means the page hit the limit.
|
||||
*/
|
||||
export interface EventsPage {
|
||||
events: Event[];
|
||||
cursor: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sessions/:adw_id/agents/:agent/prompts
|
||||
*
|
||||
* The exact compiled prompts sent to an agent, read from
|
||||
* `{data_dir}/sessions/{adw_id}/{agent}/prompts/`. These live only as files —
|
||||
* the db has no copy. Either field is null when that file isn't on disk, which
|
||||
* is the normal state for an agent that never ran in this session, so a 200
|
||||
* with two nulls is a valid answer rather than an error.
|
||||
*/
|
||||
export interface AgentPrompts {
|
||||
system: string | null;
|
||||
user: string | null;
|
||||
}
|
||||
|
||||
/** Alias matching the naming of the other endpoint payloads. */
|
||||
export type PromptsResponse = AgentPrompts;
|
||||
|
||||
/** GET /api/sessions/:adw_id/envelopes */
|
||||
export type EnvelopesResponse = Envelope[];
|
||||
|
||||
/** GET /api/sessions/:adw_id/gates */
|
||||
export type GatesResponse = GateResult[];
|
||||
|
||||
/** GET /api/repos */
|
||||
export interface RepoInfo {
|
||||
slug: string;
|
||||
name: string;
|
||||
db: string;
|
||||
}
|
||||
|
||||
/** GET /api/health */
|
||||
export interface HealthResponse {
|
||||
ok: boolean;
|
||||
repo: string;
|
||||
db: string;
|
||||
journal_mode: string;
|
||||
sessions: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
}
|
||||
264
sssf/apps/visualizer/src/App.vue
Normal file
264
sssf/apps/visualizer/src/App.vue
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRoute, hrefFor, phaseCrumb, navigate } from './lib/router'
|
||||
import type { RepoInfo } from './lib/types'
|
||||
import { fetchRepos, reloadRepos } from './lib/api'
|
||||
import SessionsList from './components/SessionsList.vue'
|
||||
import SessionTrace from './components/SessionTrace.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const repos = ref<RepoInfo[]>([])
|
||||
const repoName = ref<string | null>(null)
|
||||
const reloading = ref(false)
|
||||
const reloadError = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
repos.value = await fetchRepos()
|
||||
// The URL must always carry the repo slug — parse() reads parts[0] as the
|
||||
// repo, so a bare #/<adw_id> would be misread as repo=adwId. If we loaded
|
||||
// with no repo segment, redirect to the first repo.
|
||||
if (!route.value.repo && repos.value.length > 0) {
|
||||
navigate(repos.value[0].slug)
|
||||
return
|
||||
}
|
||||
const current = repos.value.find((r) => r.slug === route.value.repo)
|
||||
repoName.value = current?.name ?? current?.slug ?? null
|
||||
} catch {
|
||||
// Repo list is a nicety; the default repo still works without it.
|
||||
}
|
||||
})
|
||||
|
||||
function onRepoChange(event: Event) {
|
||||
const slug = (event.target as HTMLSelectElement).value
|
||||
navigate(slug || null)
|
||||
}
|
||||
|
||||
/** Re-read the server's repos.json so newly added repos show up without a restart. */
|
||||
async function reload() {
|
||||
reloading.value = true
|
||||
reloadError.value = null
|
||||
try {
|
||||
repos.value = await reloadRepos()
|
||||
// If the current repo was removed, fall back to the first one.
|
||||
if (route.value.repo && !repos.value.some((r) => r.slug === route.value.repo)) {
|
||||
navigate(repos.value[0]?.slug ?? null)
|
||||
}
|
||||
const current = repos.value.find((r) => r.slug === route.value.repo)
|
||||
repoName.value = current?.name ?? current?.slug ?? null
|
||||
} catch (error) {
|
||||
reloadError.value = (error as Error).message
|
||||
} finally {
|
||||
reloading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="topbar">
|
||||
<nav class="crumbs">
|
||||
<!-- Inline copy of public/logo.svg (the favicon) so the mark renders
|
||||
crisply with no fetch; keep the two in sync. -->
|
||||
<svg class="logo" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<rect x="4" y="6" width="17" height="5" rx="2.5" fill="#e8b64a" />
|
||||
<rect x="8" y="13.5" width="20" height="5" rx="2.5" fill="#c89bff" />
|
||||
<rect x="4" y="21" width="13" height="5" rx="2.5" fill="#5ad2dd" />
|
||||
</svg>
|
||||
<span class="brand">Super Simple Software Factory</span>
|
||||
<span class="sep">›</span>
|
||||
<a :href="hrefFor(route.repo)" :class="{ current: !route.adwId }">sessions</a>
|
||||
<template v-if="route.adwId">
|
||||
<span class="sep">›</span>
|
||||
<a :href="hrefFor(route.repo, route.adwId)" :class="{ current: !route.phaseId }">{{
|
||||
route.adwId
|
||||
}}</a>
|
||||
</template>
|
||||
<template v-if="route.adwId && route.phaseId">
|
||||
<span class="sep">›</span>
|
||||
<span class="current">{{ phaseCrumb ?? route.phaseId }}</span>
|
||||
</template>
|
||||
</nav>
|
||||
<div class="topbar-right">
|
||||
<select
|
||||
v-if="repos.length > 1"
|
||||
class="repo-switcher"
|
||||
:value="route.repo ?? repos[0]?.slug ?? ''"
|
||||
@change="onRepoChange"
|
||||
:title="repoName ?? undefined"
|
||||
>
|
||||
<option v-for="r in repos" :key="r.slug" :value="r.slug">{{ r.name || r.slug }}</option>
|
||||
</select>
|
||||
<button
|
||||
class="reload-btn"
|
||||
type="button"
|
||||
:disabled="reloading"
|
||||
:title="reloadError ?? 'Re-read repos.json to pick up newly added repos'"
|
||||
@click="reload"
|
||||
>
|
||||
{{ reloading ? '…' : '↻' }}
|
||||
</button>
|
||||
<span class="live-hint"><span class="live-dot" /> live</span>
|
||||
</div>
|
||||
<div v-if="reloadError" class="reload-error">{{ reloadError }}</div>
|
||||
</header>
|
||||
<main>
|
||||
<SessionsList v-if="!route.adwId" />
|
||||
<SessionTrace v-else :key="route.adwId" :adw-id="route.adwId" :phase-id="route.phaseId" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 15px 28px;
|
||||
background: rgba(11, 15, 24, 0.72);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Gradient hairline instead of a hard border — the brand colors, whispered. */
|
||||
.topbar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(200, 155, 255, 0.45),
|
||||
rgba(90, 210, 221, 0.35) 40%,
|
||||
rgba(90, 210, 221, 0.06)
|
||||
);
|
||||
}
|
||||
|
||||
.crumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 17px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: none;
|
||||
filter: drop-shadow(0 0 8px rgba(200, 155, 255, 0.35));
|
||||
}
|
||||
|
||||
.brand {
|
||||
background: linear-gradient(90deg, var(--purple), var(--cyan));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.crumbs a {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.crumbs a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.crumbs .current {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.live-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--dim);
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.repo-switcher {
|
||||
background: rgba(11, 15, 24, 0.72);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(200, 155, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.repo-switcher:hover {
|
||||
border-color: rgba(200, 155, 255, 0.5);
|
||||
}
|
||||
|
||||
.repo-switcher option {
|
||||
background: #0b0f18;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.reload-btn {
|
||||
background: rgba(11, 15, 24, 0.72);
|
||||
color: var(--dim);
|
||||
border: 1px solid rgba(200, 155, 255, 0.25);
|
||||
border-radius: 8px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reload-btn:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: rgba(200, 155, 255, 0.5);
|
||||
}
|
||||
|
||||
.reload-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.reload-error {
|
||||
position: absolute;
|
||||
right: 28px;
|
||||
top: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
color: #ff8a8a;
|
||||
background: rgba(11, 15, 24, 0.9);
|
||||
border: 1px solid rgba(255, 138, 138, 0.4);
|
||||
border-radius: 8px;
|
||||
z-index: 11;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 10px rgba(74, 222, 128, 0.7);
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
76
sssf/apps/visualizer/src/components/DetailSection.vue
Normal file
76
sssf/apps/visualizer/src/components/DetailSection.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
/** Lucide icon component rendered before the title. */
|
||||
icon?: Component
|
||||
/** Shown after the title; omit for sections without a natural count. */
|
||||
count?: number | null
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ toggle: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dsec">
|
||||
<button class="dsec-head" @click="$emit('toggle')">
|
||||
<span class="chev">{{ open ? '▾' : '▸' }}</span>
|
||||
<component :is="icon" v-if="icon" class="dsec-icon" :size="19" :stroke-width="2" />
|
||||
<span class="dsec-title">{{ title }}</span>
|
||||
<span v-if="count != null" class="dsec-count dim">({{ count }})</span>
|
||||
</button>
|
||||
<div v-if="open" class="dsec-body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dsec {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dsec-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: var(--dim);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: lowercase;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dsec-icon {
|
||||
flex: none;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.dsec-head:hover {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.chev {
|
||||
color: var(--faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dsec-count {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dsec-body {
|
||||
padding-top: 10px;
|
||||
}
|
||||
</style>
|
||||
1267
sssf/apps/visualizer/src/components/PhaseDetail.vue
Normal file
1267
sssf/apps/visualizer/src/components/PhaseDetail.vue
Normal file
File diff suppressed because it is too large
Load diff
55
sssf/apps/visualizer/src/components/PhaseDots.vue
Normal file
55
sssf/apps/visualizer/src/components/PhaseDots.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Phase } from '../lib/types'
|
||||
|
||||
const props = defineProps<{ phases: Phase[] }>()
|
||||
|
||||
const ordered = computed(() => props.phases.toSorted((a, b) => (a.seq ?? 0) - (b.seq ?? 0)))
|
||||
|
||||
const glyph: Record<string, string> = {
|
||||
success: '●',
|
||||
running: '◐',
|
||||
queued: '○',
|
||||
fail: '✗',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="dots">
|
||||
<span
|
||||
v-for="p in ordered"
|
||||
:key="p.phase_id"
|
||||
class="d"
|
||||
:class="p.status"
|
||||
:title="`${p.name} — ${p.status}`"
|
||||
>{{ glyph[p.status ?? ''] ?? '○' }}</span
|
||||
>
|
||||
<span v-if="!ordered.length" class="faint">—</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: 5px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.d.success {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.d.fail {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.d.running {
|
||||
color: var(--blue);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.d.queued {
|
||||
color: var(--faint);
|
||||
}
|
||||
</style>
|
||||
473
sssf/apps/visualizer/src/components/SessionCard.vue
Normal file
473
sssf/apps/visualizer/src/components/SessionCard.vue
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue'
|
||||
import type { EventRow, SessionSummary } from '../lib/types'
|
||||
import { archiveSession, fetchEvents } from '../lib/api'
|
||||
import { axisTicks, fmtDate, fmtOffset, ts } from '../lib/format'
|
||||
import { agentColor, dotColor, eventLabel } from '../lib/events'
|
||||
import { hrefFor, useRoute } from '../lib/router'
|
||||
import StatusChip from './StatusChip.vue'
|
||||
import StatChip from './StatChip.vue'
|
||||
import PhaseDots from './PhaseDots.vue'
|
||||
|
||||
const props = defineProps<{ session: SessionSummary; nowMs: number }>()
|
||||
const emit = defineEmits<{ archived: [adwId: string] }>()
|
||||
const route = useRoute()
|
||||
|
||||
// The card is an <a>; the button lives inside it, so the click must not
|
||||
// navigate. Told the parent optimistically — the poll would take up to half a
|
||||
// second to drop the card, and a triage click should feel instant.
|
||||
async function archive(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
emit('archived', props.session.adw_id)
|
||||
try {
|
||||
await archiveSession(route.value.repo, props.session.adw_id)
|
||||
} catch {
|
||||
emit('archived', '') // signals the parent to re-sync from the server
|
||||
}
|
||||
}
|
||||
|
||||
// Each card tails its own event stream: one full fetch on mount, then the
|
||||
// same rowid-cursor poll as the trace view — but only while the run is live.
|
||||
const events = shallowRef<EventRow[]>([])
|
||||
let cursor = 0
|
||||
let inflight = false
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function stopPolling() {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
|
||||
async function pull() {
|
||||
if (inflight) return
|
||||
inflight = true
|
||||
try {
|
||||
const fresh: EventRow[] = []
|
||||
let page
|
||||
do {
|
||||
// Cursor pagination is inherently sequential: each request needs the previous cursor.
|
||||
// oxlint-disable-next-line no-await-in-loop
|
||||
page = await fetchEvents(route.value.repo, props.session.adw_id, cursor, 1000)
|
||||
cursor = Math.max(cursor, page.cursor)
|
||||
fresh.push(...page.events)
|
||||
} while (page.has_more)
|
||||
if (fresh.length) events.value = [...events.value, ...fresh]
|
||||
if (props.session.status !== 'running') stopPolling()
|
||||
} catch {
|
||||
/* the list view surfaces api errors; a card just retries next poll */
|
||||
} finally {
|
||||
inflight = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void pull()
|
||||
if (props.session.status === 'running') timer = setInterval(() => void pull(), 500)
|
||||
})
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
watch(
|
||||
() => props.session.status,
|
||||
(status) => {
|
||||
if (status === 'running' && !timer) timer = setInterval(() => void pull(), 500)
|
||||
// On the transition out of running, one last pull drains the tail and stops the timer.
|
||||
else if (status !== 'running') void pull()
|
||||
},
|
||||
)
|
||||
|
||||
const running = computed(() => props.session.status === 'running')
|
||||
|
||||
const range = computed(() => {
|
||||
const s = props.session
|
||||
let t0 = ts(s.started_at)
|
||||
if (!Number.isFinite(t0)) {
|
||||
t0 = Math.min(...events.value.map((e) => ts(e.started_at)).filter(Number.isFinite))
|
||||
}
|
||||
if (!Number.isFinite(t0)) t0 = props.nowMs
|
||||
let t1 = running.value ? props.nowMs : ts(s.ended_at)
|
||||
if (!Number.isFinite(t1)) {
|
||||
t1 = Math.max(...events.value.map((e) => ts(e.started_at)).filter(Number.isFinite))
|
||||
}
|
||||
if (!Number.isFinite(t1)) t1 = t0 + 1000
|
||||
return { t0, span: Math.max(t1 - t0, 1000) }
|
||||
})
|
||||
|
||||
const ticks = computed(() => axisTicks(range.value.span, 5))
|
||||
|
||||
interface TimelineDot {
|
||||
id: string
|
||||
xPct: number
|
||||
color: string
|
||||
title: string
|
||||
latest: boolean
|
||||
}
|
||||
|
||||
interface TimelineRow {
|
||||
owner: string
|
||||
color: string
|
||||
title: string
|
||||
dots: TimelineDot[]
|
||||
}
|
||||
|
||||
// Per-agent rows: events attribute to an agent through their phase's owner.
|
||||
const rows = computed<TimelineRow[]>(() => {
|
||||
const owners: string[] = []
|
||||
const ownerByPhase = new Map<string, string>()
|
||||
for (const p of props.session.phases ?? []) {
|
||||
if (p.kind !== 'agent' || !p.owner) continue
|
||||
ownerByPhase.set(p.phase_id, p.owner)
|
||||
if (!owners.includes(p.owner)) owners.push(p.owner)
|
||||
}
|
||||
if (!owners.length) return []
|
||||
|
||||
const { t0, span } = range.value
|
||||
const byOwner = new Map<string, TimelineDot[]>(owners.map((o) => [o, []]))
|
||||
let latest: TimelineDot | null = null
|
||||
let latestT = -Infinity
|
||||
|
||||
for (const e of events.value) {
|
||||
const owner = e.phase_id ? ownerByPhase.get(e.phase_id) : undefined
|
||||
const color = dotColor(e.type)
|
||||
if (!owner || !color) continue
|
||||
const t = ts(e.started_at)
|
||||
if (!Number.isFinite(t)) continue
|
||||
const dot: TimelineDot = {
|
||||
id: e.event_id,
|
||||
xPct: Math.min(Math.max(((t - t0) / span) * 100, 0), 100),
|
||||
color,
|
||||
title: `${e.type} ${eventLabel(e)} at ${fmtOffset(t - t0)}`,
|
||||
latest: false,
|
||||
}
|
||||
byOwner.get(owner)?.push(dot)
|
||||
if (t >= latestT) {
|
||||
latestT = t
|
||||
latest = dot
|
||||
}
|
||||
}
|
||||
if (running.value && latest) latest.latest = true
|
||||
|
||||
// /api/sessions embeds agents so the labels can use config colors with no
|
||||
// extra request; historical sessions return color null → fallback palette.
|
||||
return owners.map((owner, i) => {
|
||||
const info = (props.session.agents ?? []).find((a) => a.agent === owner)
|
||||
return {
|
||||
owner,
|
||||
color: agentColor(info?.color, null, i),
|
||||
title: info?.model ? `${owner} ${info.model}` : owner,
|
||||
dots: byOwner.get(owner) ?? [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const durationMs = computed(() => {
|
||||
const s = props.session
|
||||
const start = ts(s.started_at)
|
||||
if (!Number.isFinite(start)) return NaN
|
||||
const end = running.value ? props.nowMs : ts(s.ended_at)
|
||||
return (Number.isFinite(end) ? end : props.nowMs) - start
|
||||
})
|
||||
|
||||
// Cards are a fixed size, so the timeline region fits exactly MAX_VISIBLE_ROWS
|
||||
// row slots. A roster that overflows spends one slot on the "+N more" line and
|
||||
// shows MIN_VISIBLE_ROWS agents in the rest — never fewer than three, so a
|
||||
// five-agent chain still reads as a chain rather than as a pair and a count.
|
||||
const MAX_VISIBLE_ROWS = 4
|
||||
const MIN_VISIBLE_ROWS = 3
|
||||
|
||||
const overflowing = computed(() => rows.value.length > MAX_VISIBLE_ROWS)
|
||||
|
||||
const visibleRows = computed(() =>
|
||||
overflowing.value ? rows.value.slice(0, MIN_VISIBLE_ROWS) : rows.value,
|
||||
)
|
||||
|
||||
const hiddenRowCount = computed(() =>
|
||||
overflowing.value ? rows.value.length - MIN_VISIBLE_ROWS : 0,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a class="card" :class="session.status" :href="hrefFor(route.repo, session.adw_id)">
|
||||
<button
|
||||
class="card-archive"
|
||||
type="button"
|
||||
title="Archive — remove this run from review"
|
||||
aria-label="Archive run"
|
||||
@click="archive"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<span class="card-id">{{ session.adw_id }}</span>
|
||||
<span class="card-adw" :title="session.adw_name ?? ''">{{ session.adw_name ?? '—' }}</span>
|
||||
<span class="card-req" :title="session.request ?? ''">{{ session.request }}</span>
|
||||
|
||||
<div v-if="rows.length" class="tl">
|
||||
<div class="tl-axis">
|
||||
<span class="tl-gutter" />
|
||||
<span class="tl-scale">
|
||||
<span
|
||||
v-for="(t, i) in ticks"
|
||||
:key="i"
|
||||
class="tl-tick"
|
||||
:class="{ edge: t.pct === 0 }"
|
||||
:style="{ left: `${t.pct}%` }"
|
||||
>{{ t.label }}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div v-for="row in visibleRows" :key="row.owner" class="tl-row">
|
||||
<span class="tl-agent" :style="{ color: row.color }" :title="row.title">{{
|
||||
row.owner
|
||||
}}</span>
|
||||
<span class="tl-track">
|
||||
<span
|
||||
v-for="dot in row.dots"
|
||||
:key="dot.id"
|
||||
class="tl-dot"
|
||||
:class="{ latest: dot.latest }"
|
||||
:style="{ left: `${dot.xPct}%`, background: dot.color }"
|
||||
:title="dot.title"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="hiddenRowCount" class="tl-more dim">+{{ hiddenRowCount }} more agents</div>
|
||||
</div>
|
||||
<div v-else class="tl tl-empty faint">no agent activity yet</div>
|
||||
|
||||
<div class="card-foot">
|
||||
<span class="foot-status">
|
||||
<StatusChip :status="session.status ?? 'fail'" />
|
||||
<PhaseDots :phases="session.phases ?? []" />
|
||||
</span>
|
||||
<span class="dim">{{ fmtDate(session.started_at) }}</span>
|
||||
</div>
|
||||
<div class="card-stats">
|
||||
<StatChip kind="cost" :value="session.total_cost" />
|
||||
<StatChip kind="runtime" :value="durationMs" />
|
||||
<StatChip kind="tokens" :value="session.total_tokens" />
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
/* Uniform size: the grid fixes the width, this fixes the height — content
|
||||
clamps and truncates rather than resizing the card. Grew by one 40px row
|
||||
slot when the timeline went from three to four. */
|
||||
height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 22px;
|
||||
position: relative; /* anchors the archive button */
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 0.18s ease,
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.card-archive {
|
||||
/* Top-right of the card, out of the text flow so nothing reflows around it. */
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 12px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dim);
|
||||
font-family: inherit;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
/* Hidden until the card is hovered — 50 cards should read as runs, not as a
|
||||
wall of close buttons. Focus reveals it too, so keyboards are not excluded. */
|
||||
.card:hover .card-archive,
|
||||
.card-archive:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-archive:hover {
|
||||
background: rgba(255, 111, 103, 0.16);
|
||||
color: #ff6f67;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: rgba(148, 163, 255, 0.45);
|
||||
box-shadow: 0 10px 34px rgba(148, 163, 255, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card.running {
|
||||
border-color: rgba(108, 182, 255, 0.6);
|
||||
box-shadow: 0 0 22px rgba(108, 182, 255, 0.16);
|
||||
}
|
||||
|
||||
.card.fail {
|
||||
border-color: rgba(255, 111, 103, 0.6);
|
||||
}
|
||||
|
||||
/* Text rows must never absorb flex shrink — the fixed-height card squeezes
|
||||
overflow into .tl (which clips), not into the text. */
|
||||
.card-id {
|
||||
flex: none;
|
||||
font-family: var(--mono);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.card-adw {
|
||||
flex: none;
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
color: var(--cyan);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-req {
|
||||
flex: none;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 4px;
|
||||
/* Fixed region: axis (28 + 6) + four 40px row slots, roster size or not.
|
||||
Four slots is what lets three agents show alongside a "+N more" line. */
|
||||
height: 194px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tl-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
padding-left: 96px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tl-axis {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 28px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tl-gutter,
|
||||
.tl-agent {
|
||||
flex: none;
|
||||
/* Wide enough for full agent names (planner, builder, documenter). */
|
||||
width: 96px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.tl-scale {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tl-tick {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
color: var(--faint);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tl-tick.edge {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.tl-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.tl-agent {
|
||||
font-size: 16px;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.tl-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.tl-dot {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.tl-dot.latest {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
box-shadow: 0 0 10px currentColor;
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.tl-empty {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: auto;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.foot-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
862
sssf/apps/visualizer/src/components/SessionTrace.vue
Normal file
862
sssf/apps/visualizer/src/components/SessionTrace.vue
Normal file
|
|
@ -0,0 +1,862 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
|
||||
import type {
|
||||
AgentSession,
|
||||
AgentStartPayload,
|
||||
Envelope,
|
||||
EventRow,
|
||||
GateResult,
|
||||
Phase,
|
||||
PhaseKind,
|
||||
Session,
|
||||
SessionUsage,
|
||||
} from '../lib/types'
|
||||
import { Bot, SquareTerminal, UserRound } from 'lucide-vue-next'
|
||||
import { fetchEnvelopes, fetchEvents, fetchGates, fetchSession } from '../lib/api'
|
||||
import { axisTicks, fmtDate, payloadOk, ts } from '../lib/format'
|
||||
import { modelIcon, modelName } from '../lib/models'
|
||||
import { agentColor, hexAlpha, parseAgentStart } from '../lib/events'
|
||||
import { navigate, phaseCrumb, useRoute } from '../lib/router'
|
||||
import StatusChip from './StatusChip.vue'
|
||||
import StatChip from './StatChip.vue'
|
||||
import PhaseDetail from './PhaseDetail.vue'
|
||||
|
||||
const props = defineProps<{ adwId: string; phaseId: string | null }>()
|
||||
const route = useRoute()
|
||||
|
||||
const session = ref<Session | null>(null)
|
||||
const phases = ref<Phase[]>([])
|
||||
const agents = ref<AgentSession[]>([])
|
||||
const usage = ref<SessionUsage>({ read: 0, written: 0 })
|
||||
const events = ref<EventRow[]>([])
|
||||
const envelopes = ref<Envelope[]>([])
|
||||
const gates = ref<GateResult[]>([])
|
||||
const apiError = ref<string | null>(null)
|
||||
const loaded = ref(false)
|
||||
const nowMs = ref(Date.now())
|
||||
|
||||
let cursor = 0
|
||||
let inflight = false
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const SIDE_TABLE_TYPES = new Set(['gate_pass', 'gate_fail', 'handoff', 'agent_end', 'phase_end', 'error'])
|
||||
|
||||
async function tick() {
|
||||
if (inflight) return
|
||||
inflight = true
|
||||
try {
|
||||
const detail = await fetchSession(route.value.repo, props.adwId)
|
||||
session.value = detail.session
|
||||
phases.value = detail.phases.toSorted((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
||||
agents.value = detail.agents
|
||||
usage.value = detail.usage
|
||||
|
||||
const fresh: EventRow[] = []
|
||||
let page
|
||||
do {
|
||||
// Cursor pagination is inherently sequential: each request needs the previous cursor.
|
||||
// oxlint-disable-next-line no-await-in-loop
|
||||
page = await fetchEvents(route.value.repo, props.adwId, cursor, 1000)
|
||||
cursor = Math.max(cursor, page.cursor)
|
||||
fresh.push(...page.events)
|
||||
} while (page.has_more)
|
||||
if (fresh.length) events.value = [...events.value, ...fresh]
|
||||
|
||||
// Envelopes and gates only gain rows around phase/agent boundaries — refetch
|
||||
// on those events instead of every tick.
|
||||
if (!loaded.value || fresh.some((e) => e.type !== null && SIDE_TABLE_TYPES.has(e.type))) {
|
||||
const [env, g] = await Promise.all([
|
||||
fetchEnvelopes(route.value.repo, props.adwId),
|
||||
fetchGates(route.value.repo, props.adwId),
|
||||
])
|
||||
envelopes.value = env
|
||||
gates.value = g
|
||||
}
|
||||
|
||||
nowMs.value = Date.now()
|
||||
apiError.value = null
|
||||
loaded.value = true
|
||||
} catch (err) {
|
||||
apiError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
inflight = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void tick()
|
||||
timer = setInterval(() => void tick(), 500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
phaseCrumb.value = null
|
||||
})
|
||||
|
||||
const selectedPhase = computed(
|
||||
() => phases.value.find((p) => p.phase_id === props.phaseId) ?? null,
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
phaseCrumb.value = selectedPhase.value?.name ?? null
|
||||
})
|
||||
|
||||
// ── Lanes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const ENGINEER_COLOR = '#e8b64a'
|
||||
const CODE_COLOR = '#5ad2dd'
|
||||
|
||||
const KIND_ICONS = { engineer: UserRound, code: SquareTerminal, agent: Bot }
|
||||
|
||||
interface Lane {
|
||||
id: string
|
||||
label: string
|
||||
/** Model driving this lane's agent — rendered with its provider icon. */
|
||||
model: string | null
|
||||
/** Context-window occupancy, or null while unknown (running / old db). */
|
||||
context: LaneContext | null
|
||||
metaLines: string[]
|
||||
color: string
|
||||
kind: PhaseKind
|
||||
phases: Phase[]
|
||||
}
|
||||
|
||||
interface LaneContext {
|
||||
used: number
|
||||
window: number
|
||||
/** 0–100, uncapped by the floor applied to the bar's width. */
|
||||
pct: number
|
||||
}
|
||||
|
||||
/** Occupancy for an agent lane. Null unless BOTH numbers are real — a bar
|
||||
* against an unknown ceiling would be decoration, not data. */
|
||||
function laneContext(info: AgentSession | undefined): LaneContext | null {
|
||||
const used = info?.context_tokens ?? 0
|
||||
const window = info?.context_window ?? 0
|
||||
if (!used || !window) return null
|
||||
return { used, window, pct: Math.min(100, (used / window) * 100) }
|
||||
}
|
||||
|
||||
/** Sub-1% occupancy is common and real; round it away and the bar reads empty. */
|
||||
function contextLabel(ctx: LaneContext): string {
|
||||
return ctx.pct < 1 ? `${ctx.pct.toFixed(1)}%` : `${Math.round(ctx.pct)}%`
|
||||
}
|
||||
|
||||
/** Keep a non-zero fill visible — the exact numbers ride in the label and title. */
|
||||
function contextFill(ctx: LaneContext): string {
|
||||
return `${Math.max(ctx.pct, 2)}%`
|
||||
}
|
||||
|
||||
const NUM = new Intl.NumberFormat('en-US')
|
||||
|
||||
// A live agent's model/thinking/color arrive on its agent_start event before
|
||||
// any agent_sessions row exists; attribute each start to its phase's owner.
|
||||
const ownerStart = computed<Record<string, AgentStartPayload>>(() => {
|
||||
const ownerByPhase = new Map<string, string | null>(
|
||||
phases.value.map((p) => [p.phase_id, p.owner]),
|
||||
)
|
||||
const meta: Record<string, AgentStartPayload> = {}
|
||||
for (const e of events.value) {
|
||||
if (e.type !== 'agent_start') continue
|
||||
const owner = (e.phase_id ? ownerByPhase.get(e.phase_id) : null) ?? e.name
|
||||
if (!owner || meta[owner]) continue
|
||||
const payload = parseAgentStart(e)
|
||||
if (payload) meta[owner] = payload
|
||||
}
|
||||
return meta
|
||||
})
|
||||
|
||||
const lanes = computed<Lane[]>(() => {
|
||||
const ph = phases.value
|
||||
const agentOwners: string[] = []
|
||||
for (const p of ph) {
|
||||
if (p.kind === 'agent' && p.owner && !agentOwners.includes(p.owner)) agentOwners.push(p.owner)
|
||||
}
|
||||
const codePhases = ph.filter((p) => p.kind === 'code')
|
||||
const out: Lane[] = [
|
||||
{
|
||||
id: 'engineer',
|
||||
label: session.value?.engineer ?? 'engineer',
|
||||
model: null,
|
||||
context: null,
|
||||
metaLines: ['engineer'],
|
||||
color: ENGINEER_COLOR,
|
||||
kind: 'engineer' as const,
|
||||
phases: ph.filter((p) => p.kind === 'engineer'),
|
||||
},
|
||||
]
|
||||
if (codePhases.length) {
|
||||
out.push({
|
||||
id: 'code',
|
||||
label: 'code',
|
||||
model: null,
|
||||
context: null,
|
||||
metaLines: ['workspace'],
|
||||
color: CODE_COLOR,
|
||||
kind: 'code' as const,
|
||||
phases: codePhases,
|
||||
})
|
||||
}
|
||||
for (const [i, owner] of agentOwners.entries()) {
|
||||
const info = agents.value.find((a) => a.agent === owner)
|
||||
const start = ownerStart.value[owner]
|
||||
out.push({
|
||||
id: `agent:${owner}`,
|
||||
label: owner,
|
||||
// The model is the lane's whole story; thinking level lives in the
|
||||
// phase detail's agent config section.
|
||||
model: info?.model ?? start?.model ?? null,
|
||||
context: laneContext(info),
|
||||
metaLines: [],
|
||||
color: agentColor(info?.color, start?.color, i),
|
||||
kind: 'agent' as const,
|
||||
phases: ph.filter((p) => p.kind === 'agent' && p.owner === owner),
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// ── Timeline geometry ────────────────────────────────────────────────────────
|
||||
|
||||
const range = computed(() => {
|
||||
let t0 = Infinity
|
||||
let t1 = -Infinity
|
||||
const s = session.value
|
||||
const sStart = ts(s?.started_at)
|
||||
const sEnd = ts(s?.ended_at)
|
||||
if (Number.isFinite(sStart)) t0 = Math.min(t0, sStart)
|
||||
if (Number.isFinite(sEnd)) t1 = Math.max(t1, sEnd)
|
||||
for (const p of phases.value) {
|
||||
const a = ts(p.started_at)
|
||||
const b = ts(p.ended_at)
|
||||
if (Number.isFinite(a)) {
|
||||
t0 = Math.min(t0, a)
|
||||
t1 = Math.max(t1, a)
|
||||
}
|
||||
if (Number.isFinite(b)) t1 = Math.max(t1, b)
|
||||
}
|
||||
if (s?.status === 'running') t1 = Math.max(t1, nowMs.value)
|
||||
if (!Number.isFinite(t0)) {
|
||||
t0 = nowMs.value
|
||||
t1 = t0 + 1000
|
||||
}
|
||||
if (t1 - t0 < 1000) t1 = t0 + 1000
|
||||
return { t0, t1, span: t1 - t0 }
|
||||
})
|
||||
|
||||
// The engineer's request opens the run and owns the start of the timeline: it
|
||||
// gets an exclusive leading zone, and every later phase maps into the rest —
|
||||
// nothing can render on top of it.
|
||||
const REQ_ZONE_PCT = 16
|
||||
|
||||
const requestPhase = computed(
|
||||
() => phases.value.find((p) => p.kind === 'engineer' && p.started_at) ?? null,
|
||||
)
|
||||
|
||||
const zonePct = computed(() => (requestPhase.value ? REQ_ZONE_PCT : 0))
|
||||
|
||||
/**
|
||||
* Where the post-request timeline begins, in ms.
|
||||
*
|
||||
* The earliest non-engineer phase start, not the request phase's end: a later
|
||||
* ADW joining the session pushes the request row's ended_at forward, which
|
||||
* would otherwise throw every already-finished phase behind the origin.
|
||||
*/
|
||||
const originMs = computed(() => {
|
||||
const { t0 } = range.value
|
||||
const req = requestPhase.value
|
||||
if (!req) return t0
|
||||
let earliest = Infinity
|
||||
for (const p of phases.value) {
|
||||
if (p.kind === 'engineer') continue
|
||||
const s = ts(p.started_at)
|
||||
if (Number.isFinite(s)) earliest = Math.min(earliest, s)
|
||||
}
|
||||
if (Number.isFinite(earliest)) return Math.max(earliest, t0)
|
||||
const end = ts(req.ended_at ?? req.started_at)
|
||||
return Number.isFinite(end) ? Math.max(end, t0) : t0
|
||||
})
|
||||
|
||||
const postSpan = computed(() => Math.max(range.value.t1 - originMs.value, 1000))
|
||||
|
||||
const ticks = computed(() => {
|
||||
const zone = zonePct.value
|
||||
return axisTicks(postSpan.value, 7).map((t) => ({
|
||||
pct: zone + (t.pct * (100 - zone)) / 100,
|
||||
label: t.label,
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* Adjusted layout for every timed phase, in track-%.
|
||||
*
|
||||
* Phases are sequential by doctrine, and the render must say so: when a
|
||||
* near-zero phase (a git commit) is widened to a readable floor, every later
|
||||
* block shifts right by the same amount instead of being overlapped, and the
|
||||
* whole layout is normalized back into the track. Blocks may squeeze a hair;
|
||||
* they never stack.
|
||||
*/
|
||||
const MIN_BLOCK_PCT = 3.5
|
||||
|
||||
const blockLayout = computed<Record<string, { left: number; width: number }>>(() => {
|
||||
const zone = zonePct.value
|
||||
const avail = 100 - zone - 0.4 // hair of right margin
|
||||
const t0 = originMs.value
|
||||
const span = postSpan.value
|
||||
const reqId = requestPhase.value?.phase_id
|
||||
|
||||
const timed = phases.value
|
||||
.filter((p) => p.phase_id !== reqId && Number.isFinite(ts(p.started_at)))
|
||||
.map((p) => {
|
||||
const start = ts(p.started_at)
|
||||
let end = ts(p.ended_at)
|
||||
if (!Number.isFinite(end)) end = p.status === 'running' ? nowMs.value : start
|
||||
return {
|
||||
id: p.phase_id,
|
||||
start,
|
||||
left: ((start - t0) / span) * avail,
|
||||
width: ((Math.max(end, start) - start) / span) * avail,
|
||||
}
|
||||
})
|
||||
.toSorted((a, b) => a.start - b.start)
|
||||
|
||||
let shift = 0
|
||||
let prevEdge = 0
|
||||
const rows: { id: string; left: number; width: number }[] = []
|
||||
for (const b of timed) {
|
||||
let left = b.left + shift
|
||||
if (left < prevEdge) {
|
||||
shift += prevEdge - left
|
||||
left = prevEdge
|
||||
}
|
||||
const width = Math.max(b.width, MIN_BLOCK_PCT)
|
||||
shift += width - b.width
|
||||
prevEdge = left + width
|
||||
rows.push({ id: b.id, left, width })
|
||||
}
|
||||
|
||||
const scale = avail / Math.max(prevEdge, avail)
|
||||
const out: Record<string, { left: number; width: number }> = {}
|
||||
for (const r of rows) out[r.id] = { left: zone + r.left * scale, width: r.width * scale }
|
||||
return out
|
||||
})
|
||||
|
||||
function blockGeom(p: Phase): { left: string; width: string } | null {
|
||||
// The request block fills its reserved zone, nothing else ever enters it.
|
||||
if (p.phase_id === requestPhase.value?.phase_id && zonePct.value > 0) {
|
||||
return { left: '0.4%', width: `${zonePct.value - 0.8}%` }
|
||||
}
|
||||
const geom = blockLayout.value[p.phase_id]
|
||||
if (!geom) return null
|
||||
return { left: `${geom.left}%`, width: `${geom.width}%` }
|
||||
}
|
||||
|
||||
function blockStyle(p: Phase, lane: Lane): Record<string, string> | undefined {
|
||||
const geom = blockGeom(p)
|
||||
if (!geom) return undefined
|
||||
return {
|
||||
left: geom.left,
|
||||
width: geom.width,
|
||||
background: `linear-gradient(180deg, ${hexAlpha(lane.color, 0.2)}, ${hexAlpha(lane.color, 0.05)})`,
|
||||
borderColor: p.status === 'fail' ? 'rgba(255, 111, 103, 0.8)' : hexAlpha(lane.color, 0.55),
|
||||
'--lane-glow': hexAlpha(lane.color, 0.28),
|
||||
}
|
||||
}
|
||||
|
||||
function blockDurationMs(p: Phase): number {
|
||||
const start = ts(p.started_at)
|
||||
if (!Number.isFinite(start)) return NaN
|
||||
const end = p.status === 'running' ? nowMs.value : ts(p.ended_at)
|
||||
if (!Number.isFinite(end)) return NaN
|
||||
return end - start
|
||||
}
|
||||
|
||||
const STATUS_GLYPH: Record<string, string> = {
|
||||
success: '✓',
|
||||
fail: '✗',
|
||||
running: '●',
|
||||
queued: '○',
|
||||
}
|
||||
|
||||
// Tool-call tick marks inside a phase block, positioned within the block's own span.
|
||||
interface ToolTick {
|
||||
t: number
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
const toolTicks = computed(() => {
|
||||
const map: Record<string, ToolTick[]> = {}
|
||||
for (const e of events.value) {
|
||||
if (e.type !== 'tool_call' || !e.phase_id) continue
|
||||
map[e.phase_id] ??= []
|
||||
map[e.phase_id]?.push({ t: ts(e.started_at), ok: payloadOk(e.payload_json) })
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function ticksFor(p: Phase): { x: number; ok: boolean }[] {
|
||||
const start = ts(p.started_at)
|
||||
if (!Number.isFinite(start)) return []
|
||||
let end = ts(p.ended_at)
|
||||
if (!Number.isFinite(end)) end = p.status === 'running' ? nowMs.value : start
|
||||
const width = Math.max(end - start, 1)
|
||||
return (toolTicks.value[p.phase_id] ?? [])
|
||||
.filter((mark) => Number.isFinite(mark.t))
|
||||
.map((mark) => ({
|
||||
x: Math.min(Math.max(((mark.t - start) / width) * 100, 1), 99),
|
||||
ok: mark.ok,
|
||||
}))
|
||||
}
|
||||
|
||||
const queuedByLane = computed(() => {
|
||||
const map: Record<string, Phase[]> = {}
|
||||
for (const lane of lanes.value) {
|
||||
map[lane.id] = lane.phases.filter((p) => !p.started_at)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
const sessionDurationMs = computed(() => {
|
||||
const s = session.value
|
||||
if (!s) return NaN
|
||||
const start = ts(s.started_at)
|
||||
if (!Number.isFinite(start)) return NaN
|
||||
const end = s.status === 'running' ? nowMs.value : ts(s.ended_at)
|
||||
return (Number.isFinite(end) ? end : nowMs.value) - start
|
||||
})
|
||||
|
||||
function selectPhase(p: Phase) {
|
||||
navigate(route.value.repo, props.adwId, p.phase_id === props.phaseId ? null : p.phase_id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="trace">
|
||||
<div v-if="apiError" class="error-bar">api unreachable — retrying {{ apiError }}</div>
|
||||
|
||||
<div v-if="session" class="run-strip">
|
||||
<span class="request" :title="session.request ?? ''">{{ session.request }}</span>
|
||||
<StatusChip :status="session.status ?? 'fail'" />
|
||||
<span class="dim">started {{ fmtDate(session.started_at) }}</span>
|
||||
<span class="run-stats">
|
||||
<StatChip kind="cost" :value="session.total_cost" />
|
||||
<StatChip kind="runtime" :value="sessionDurationMs" />
|
||||
<StatChip kind="tokens" :value="session.total_tokens" />
|
||||
<StatChip kind="read" :value="usage.read" />
|
||||
<StatChip kind="written" :value="usage.written" />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="phases.length" class="waterfall">
|
||||
<div class="row axis-row">
|
||||
<div class="label" />
|
||||
<div class="track">
|
||||
<span v-if="zonePct" class="zone-head" :style="{ width: `${zonePct}%` }">request</span>
|
||||
<span
|
||||
v-for="(t, i) in ticks"
|
||||
:key="i"
|
||||
class="axis-label"
|
||||
:style="{ left: `${t.pct}%` }"
|
||||
>{{ t.label }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="lane in lanes" :key="lane.id" class="row lane" :class="`kind-${lane.kind}`">
|
||||
<div class="label">
|
||||
<span class="lane-name" :style="{ color: lane.color }">
|
||||
<component :is="KIND_ICONS[lane.kind]" class="lane-icon" :size="22" :stroke-width="2" />
|
||||
{{ lane.label }}
|
||||
</span>
|
||||
<span v-if="lane.model" class="lane-meta lane-model" :title="lane.model">
|
||||
<img v-if="modelIcon(lane.model)" class="model-icon" :src="modelIcon(lane.model)!" alt="" />
|
||||
{{ modelName(lane.model) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="lane.context"
|
||||
class="lane-ctx"
|
||||
:title="`${NUM.format(lane.context.used)} / ${NUM.format(lane.context.window)} tokens used · ${NUM.format(lane.context.window - lane.context.used)} remaining`"
|
||||
>
|
||||
<span class="ctx-head">
|
||||
<span class="ctx-label">Context</span>
|
||||
<span class="ctx-pct">{{ contextLabel(lane.context) }}</span>
|
||||
</span>
|
||||
<span class="ctx-bar">
|
||||
<span
|
||||
class="ctx-fill"
|
||||
:style="{
|
||||
width: contextFill(lane.context),
|
||||
background: `linear-gradient(90deg, ${hexAlpha(lane.color, 0.55)}, ${lane.color})`,
|
||||
boxShadow: `0 0 10px ${hexAlpha(lane.color, 0.45)}`,
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span v-for="(line, i) in lane.metaLines" :key="i" class="lane-meta">{{ line }}</span>
|
||||
</div>
|
||||
<div class="track">
|
||||
<span v-if="zonePct" class="zone-divider" :style="{ left: `${zonePct}%` }" />
|
||||
<span v-for="(t, i) in ticks" :key="i" class="gridline" :style="{ left: `${t.pct}%` }" />
|
||||
<template v-for="p in lane.phases" :key="p.phase_id">
|
||||
<button
|
||||
v-if="blockGeom(p)"
|
||||
class="block"
|
||||
:class="[p.status, { selected: p.phase_id === phaseId }]"
|
||||
:style="blockStyle(p, lane)"
|
||||
:title="`${p.name} — ${p.status}${p.description ? `\n${p.description}` : ''}`"
|
||||
@click="selectPhase(p)"
|
||||
>
|
||||
<span class="b-top">
|
||||
<span class="b-status" :class="p.status">{{
|
||||
STATUS_GLYPH[p.status ?? ''] ?? '○'
|
||||
}}</span>
|
||||
<span class="b-name">{{ p.name }}</span>
|
||||
<StatChip
|
||||
v-if="Number.isFinite(blockDurationMs(p))"
|
||||
class="b-dur"
|
||||
kind="runtime"
|
||||
compact
|
||||
:value="blockDurationMs(p)"
|
||||
/>
|
||||
</span>
|
||||
<span class="b-desc">{{ p.description }}</span>
|
||||
<span
|
||||
v-for="(tick, i) in ticksFor(p)"
|
||||
:key="i"
|
||||
class="tool-tick"
|
||||
:class="{ err: !tick.ok }"
|
||||
:style="{ left: `${tick.x}%` }"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-for="(p, i) in queuedByLane[lane.id]"
|
||||
:key="p.phase_id"
|
||||
class="block queued"
|
||||
:class="{ selected: p.phase_id === phaseId }"
|
||||
:style="{ right: `${10 + i * 5}px`, width: '170px' }"
|
||||
:title="`${p.name} — queued`"
|
||||
@click="selectPhase(p)"
|
||||
>
|
||||
<span class="b-top">
|
||||
<span class="b-status queued">○</span>
|
||||
<span class="b-name">{{ p.name }}</span>
|
||||
</span>
|
||||
<span class="b-desc">queued</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loaded" class="empty-state">no phases recorded for this session</div>
|
||||
<div v-else-if="!apiError" class="empty-state">loading trace…</div>
|
||||
|
||||
<PhaseDetail
|
||||
v-if="selectedPhase"
|
||||
:phase="selectedPhase"
|
||||
:events="events"
|
||||
:envelopes="envelopes"
|
||||
:gates="gates"
|
||||
@close="navigate(route.repo, props.adwId)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trace {
|
||||
padding: 0 0 40px;
|
||||
}
|
||||
|
||||
.run-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 14px 24px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.run-strip .request {
|
||||
font-size: 17px;
|
||||
color: var(--text);
|
||||
max-width: 52ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.run-stats {
|
||||
display: inline-flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.waterfall {
|
||||
margin: 20px 28px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 16px;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
}
|
||||
|
||||
.axis-row {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.axis-row .track {
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zone-head {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: var(--amber);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.axis-label {
|
||||
position: absolute;
|
||||
bottom: 7px;
|
||||
transform: translateX(-50%);
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
color: var(--dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
border-right: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lane-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lane-icon {
|
||||
flex: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.lane-meta {
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
color: var(--dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lane-model {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.model-icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
flex: none;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Context occupancy — label row over a thin track, under the model. */
|
||||
.lane-ctx {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
max-width: 190px;
|
||||
}
|
||||
|
||||
.ctx-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ctx-label {
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.ctx-pct {
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.ctx-bar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(6, 8, 15, 0.75);
|
||||
border: 1px solid var(--border-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ctx-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
transition: width 300ms ease;
|
||||
}
|
||||
|
||||
.lane {
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.lane:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: 118px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zone-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.gridline {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-left: 1px dashed rgba(174, 191, 212, 0.14);
|
||||
}
|
||||
|
||||
.block {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
height: 92px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
transition: box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.block:hover {
|
||||
box-shadow: 0 0 18px var(--lane-glow, rgba(108, 182, 255, 0.2));
|
||||
}
|
||||
|
||||
.b-top {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b-status {
|
||||
flex: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.b-status.success {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.b-status.fail {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.b-status.running {
|
||||
color: var(--blue);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.b-status.queued {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.block .b-name {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.block .b-dur {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.block .b-desc {
|
||||
color: var(--dim);
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.block.running {
|
||||
animation: pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.block.queued {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
border-color: var(--faint);
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.block.selected {
|
||||
outline: 2px solid var(--blue);
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 22px var(--lane-glow, rgba(108, 182, 255, 0.25));
|
||||
}
|
||||
|
||||
.tool-tick {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
height: 9px;
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.tool-tick.err {
|
||||
background: var(--red);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
109
sssf/apps/visualizer/src/components/SessionsList.vue
Normal file
109
sssf/apps/visualizer/src/components/SessionsList.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import type { SessionSummary } from '../lib/types'
|
||||
import { fetchSessions } from '../lib/api'
|
||||
import { ts } from '../lib/format'
|
||||
import { useRoute } from '../lib/router'
|
||||
import SessionCard from './SessionCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const sessions = shallowRef<SessionSummary[]>([])
|
||||
const apiError = ref<string | null>(null)
|
||||
const loaded = ref(false)
|
||||
const nowMs = ref(Date.now())
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let inflight = false
|
||||
|
||||
async function tick() {
|
||||
if (inflight) return
|
||||
inflight = true
|
||||
try {
|
||||
sessions.value = await fetchSessions(route.value.repo)
|
||||
nowMs.value = Date.now()
|
||||
apiError.value = null
|
||||
loaded.value = true
|
||||
} catch (err) {
|
||||
apiError.value = err instanceof Error ? err.message : String(err)
|
||||
} finally {
|
||||
inflight = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void tick()
|
||||
timer = setInterval(() => void tick(), 500)
|
||||
})
|
||||
|
||||
// Switching repo in the topbar changes the route but not the mounted view
|
||||
// (adwId stays null), so refetch and reset the list.
|
||||
watch(
|
||||
() => route.value.repo,
|
||||
() => {
|
||||
sessions.value = []
|
||||
loaded.value = false
|
||||
void tick()
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
|
||||
/** Optimistic removal; an empty id means the write failed, so re-sync instead. */
|
||||
function onArchived(adwId: string) {
|
||||
if (!adwId) {
|
||||
void tick()
|
||||
return
|
||||
}
|
||||
sessions.value = sessions.value.filter((s) => s.adw_id !== adwId)
|
||||
}
|
||||
|
||||
const ordered = computed(() =>
|
||||
sessions.value.toSorted((a, b) => (ts(b.started_at) || 0) - (ts(a.started_at) || 0)),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sessions">
|
||||
<div v-if="apiError" class="error-bar">api unreachable — retrying {{ apiError }}</div>
|
||||
|
||||
<div v-if="ordered.length" class="list-head dim">{{ ordered.length }} runs</div>
|
||||
|
||||
<div v-if="ordered.length" class="cards">
|
||||
<SessionCard
|
||||
v-for="s in ordered"
|
||||
:key="s.adw_id"
|
||||
:session="s"
|
||||
:now-ms="nowMs"
|
||||
@archived="onArchived"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="loaded" class="empty-state">no sessions yet — run an ADW to see it here</div>
|
||||
<div v-else-if="!apiError" class="empty-state">loading sessions…</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
padding: 16px 24px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
/* Uniform grid: every card the same width and (fixed in SessionCard) height,
|
||||
independent of content. */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(460px, 1fr));
|
||||
gap: 18px;
|
||||
padding: 16px 24px 28px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
88
sssf/apps/visualizer/src/components/StatChip.vue
Normal file
88
sssf/apps/visualizer/src/components/StatChip.vue
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { BookOpen, CircleDollarSign, Coins, PenLine, Timer } from 'lucide-vue-next'
|
||||
import { fmtCost, fmtDuration, fmtTokens } from '../lib/format'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'cost' | 'tokens' | 'runtime' | 'read' | 'written'
|
||||
/** Raw value — cost in dollars, tokens as a count, runtime in milliseconds. */
|
||||
value: number | null | undefined
|
||||
/** Bare value, no pill chrome — for tight spots like waterfall blocks. */
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const ICONS = {
|
||||
cost: CircleDollarSign,
|
||||
tokens: Coins,
|
||||
runtime: Timer,
|
||||
read: BookOpen,
|
||||
written: PenLine,
|
||||
}
|
||||
|
||||
// Every chip explains itself on hover. The token numbers in particular are read
|
||||
// wrong without one — the headline is billed volume, not distinct tokens.
|
||||
const TITLES = {
|
||||
cost: 'Cost — dollars billed for this run, all agents combined.',
|
||||
tokens:
|
||||
'Tokens exchanged (billed) — everything sent or generated, counted once per turn. ' +
|
||||
'Each turn re-sends the whole conversation, so this is far larger than the ' +
|
||||
'conversation itself: it is spend, not size. The gap between it and read + ' +
|
||||
'written is cached context re-read on later turns.',
|
||||
runtime: 'Duration — wall-clock from the first phase starting to the last one ending.',
|
||||
read:
|
||||
'Read — raw tokens the models took in: prompts, file contents and tool results, ' +
|
||||
'counted the first time they enter the context. Excludes cached re-reads of ' +
|
||||
'material already counted here.',
|
||||
written:
|
||||
'Written — tokens the models actually generated. Each one produced exactly ' +
|
||||
'once, so this is a true count of output.',
|
||||
}
|
||||
|
||||
const text = computed(() => {
|
||||
if (props.kind === 'cost') return fmtCost(props.value)
|
||||
if (props.kind === 'runtime') return fmtDuration(props.value ?? NaN)
|
||||
return fmtTokens(props.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="stat" :class="{ compact }" :title="TITLES[kind]">
|
||||
<component :is="ICONS[kind]" class="stat-icon" :size="compact ? 17 : 19" :stroke-width="2" />
|
||||
<span class="stat-value">{{ text }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 3px 12px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 999px;
|
||||
background: rgba(19, 26, 38, 0.6);
|
||||
font-size: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
color: var(--faint);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.stat.compact {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stat.compact .stat-value {
|
||||
color: var(--dim);
|
||||
}
|
||||
</style>
|
||||
73
sssf/apps/visualizer/src/components/StatusChip.vue
Normal file
73
sssf/apps/visualizer/src/components/StatusChip.vue
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
<script setup lang="ts">
|
||||
import { Check, Circle, LoaderCircle, X } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{ status: string }>()
|
||||
|
||||
const ICONS: Record<string, unknown> = {
|
||||
success: Check,
|
||||
fail: X,
|
||||
running: LoaderCircle,
|
||||
queued: Circle,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="chip" :class="status">
|
||||
<component :is="ICONS[status] ?? Circle" class="chip-icon" :size="18" :stroke-width="2.5" />
|
||||
{{ status }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 3px 13px 3px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 16px;
|
||||
color: var(--dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.chip.success {
|
||||
color: var(--green);
|
||||
border-color: rgba(74, 222, 128, 0.45);
|
||||
background: rgba(74, 222, 128, 0.09);
|
||||
box-shadow: 0 0 12px rgba(74, 222, 128, 0.12);
|
||||
}
|
||||
|
||||
.chip.fail {
|
||||
color: var(--red);
|
||||
border-color: rgba(255, 111, 103, 0.45);
|
||||
background: rgba(255, 111, 103, 0.09);
|
||||
box-shadow: 0 0 12px rgba(255, 111, 103, 0.12);
|
||||
}
|
||||
|
||||
.chip.running {
|
||||
color: var(--blue);
|
||||
border-color: rgba(108, 182, 255, 0.45);
|
||||
background: rgba(108, 182, 255, 0.09);
|
||||
box-shadow: 0 0 12px rgba(108, 182, 255, 0.18);
|
||||
}
|
||||
|
||||
.chip.running .chip-icon {
|
||||
animation: spin 1.1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chip.queued {
|
||||
color: var(--dim);
|
||||
border-style: dashed;
|
||||
}
|
||||
</style>
|
||||
111
sssf/apps/visualizer/src/lib/api.ts
Normal file
111
sssf/apps/visualizer/src/lib/api.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import type {
|
||||
Envelope,
|
||||
EventRow,
|
||||
EventsPage,
|
||||
GateResult,
|
||||
HealthResponse,
|
||||
PromptsResponse,
|
||||
RepoInfo,
|
||||
SessionDetail,
|
||||
SessionSummary,
|
||||
} from './types'
|
||||
|
||||
async function getJson(url: string): Promise<unknown> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/** Prefix an API path with the repo slug; null/empty → the server's default repo. */
|
||||
function repoPath(repo: string | null, path: string): string {
|
||||
return repo ? `/api/${encodeURIComponent(repo)}${path}` : `/api${path}`
|
||||
}
|
||||
|
||||
export function fetchRepos(): Promise<RepoInfo[]> {
|
||||
return getJson('/api/repos') as Promise<RepoInfo[]>
|
||||
}
|
||||
|
||||
/** Re-read the server's repos.json and return the refreshed repo list. */
|
||||
export async function reloadRepos(): Promise<RepoInfo[]> {
|
||||
const res = await fetch('/api/reload', { method: 'POST' })
|
||||
if (!res.ok) throw new Error(`POST /api/reload → ${res.status}`)
|
||||
const data = (await res.json()) as { repos?: RepoInfo[] }
|
||||
return data.repos ?? []
|
||||
}
|
||||
|
||||
export function fetchSessions(repo: string | null): Promise<SessionSummary[]> {
|
||||
return getJson(repoPath(repo, '/sessions')) as Promise<SessionSummary[]>
|
||||
}
|
||||
|
||||
export async function fetchSession(repo: string | null, adwId: string): Promise<SessionDetail> {
|
||||
const detail = (await getJson(
|
||||
repoPath(repo, `/sessions/${encodeURIComponent(adwId)}`),
|
||||
)) as SessionDetail
|
||||
return {
|
||||
session: detail.session,
|
||||
usage: detail.usage ?? { read: 0, written: 0 },
|
||||
phases: detail.phases ?? [],
|
||||
agents: detail.agents ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEvents(
|
||||
repo: string | null,
|
||||
adwId: string,
|
||||
after: number,
|
||||
limit = 500,
|
||||
): Promise<EventsPage> {
|
||||
const page = (await getJson(
|
||||
repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/events?after=${after}&limit=${limit}`),
|
||||
)) as EventsPage | EventRow[]
|
||||
if (Array.isArray(page)) {
|
||||
const cursor = page.reduce((max, e) => Math.max(max, e.rowid), after)
|
||||
return { events: page, cursor, has_more: page.length === limit }
|
||||
}
|
||||
return { events: page.events ?? [], cursor: page.cursor ?? after, has_more: page.has_more ?? false }
|
||||
}
|
||||
|
||||
/** Archive a run out of the review list (or restore it with archived=false). */
|
||||
export async function archiveSession(
|
||||
repo: string | null,
|
||||
adwId: string,
|
||||
archived = true,
|
||||
): Promise<void> {
|
||||
const url = repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/archive`)
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ archived }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`POST ${url} → ${res.status}`)
|
||||
}
|
||||
|
||||
export function fetchHealth(repo: string | null): Promise<HealthResponse> {
|
||||
return getJson(repoPath(repo, '/health')) as Promise<HealthResponse>
|
||||
}
|
||||
|
||||
// PhaseDetail imports the prompts type from here alongside fetchPrompts.
|
||||
export type { PromptsResponse }
|
||||
|
||||
export async function fetchPrompts(
|
||||
repo: string | null,
|
||||
adwId: string,
|
||||
agent: string,
|
||||
): Promise<PromptsResponse> {
|
||||
const res = await fetch(
|
||||
repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/agents/${encodeURIComponent(agent)}/prompts`),
|
||||
)
|
||||
// Not recorded (or endpoint not deployed yet) renders as "no prompts", not an error.
|
||||
if (res.status === 404) return { system: null, user: null }
|
||||
if (!res.ok) throw new Error(`GET prompts → ${res.status}`)
|
||||
const data = (await res.json()) as Partial<PromptsResponse>
|
||||
return { system: data.system ?? null, user: data.user ?? null }
|
||||
}
|
||||
|
||||
export function fetchEnvelopes(repo: string | null, adwId: string): Promise<Envelope[]> {
|
||||
return getJson(repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/envelopes`)) as Promise<Envelope[]>
|
||||
}
|
||||
|
||||
export function fetchGates(repo: string | null, adwId: string): Promise<GateResult[]> {
|
||||
return getJson(repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/gates`)) as Promise<GateResult[]>
|
||||
}
|
||||
131
sssf/apps/visualizer/src/lib/events.ts
Normal file
131
sssf/apps/visualizer/src/lib/events.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import type { AgentStartPayload, EventRow, ToolCallPayload } from './types'
|
||||
|
||||
// ── Event dot colors ─────────────────────────────────────────────────────────
|
||||
// One color per event type, shared by the session-card timelines and the phase
|
||||
// detail list. gate_fail reads as an error signal on purpose.
|
||||
|
||||
export const EVENT_DOT_COLORS: Record<string, string> = {
|
||||
agent_start: '#c89bff',
|
||||
tool_call: '#5ad2dd',
|
||||
handoff: '#94a3ff',
|
||||
agent_end: '#4ade80',
|
||||
error: '#ff6f67',
|
||||
gate_fail: '#ff6f67',
|
||||
}
|
||||
|
||||
export function dotColor(type: string | null): string | null {
|
||||
if (!type) return null
|
||||
return EVENT_DOT_COLORS[type] ?? null
|
||||
}
|
||||
|
||||
// ── Agent lane colors ────────────────────────────────────────────────────────
|
||||
// Config color wins (agents[].color from the API, or the agent_start payload
|
||||
// for in-flight agents); the palette below covers dbs written before the
|
||||
// color column existed.
|
||||
|
||||
export const AGENT_FALLBACK_COLORS = ['#c89bff', '#5ad2dd', '#94a3ff', '#e8b64a', '#f2a2c4']
|
||||
|
||||
export function agentColor(
|
||||
configColor: string | null | undefined,
|
||||
payloadColor: string | null | undefined,
|
||||
index: number,
|
||||
): string {
|
||||
return (
|
||||
configColor ??
|
||||
payloadColor ??
|
||||
AGENT_FALLBACK_COLORS[index % AGENT_FALLBACK_COLORS.length] ??
|
||||
'#c89bff'
|
||||
)
|
||||
}
|
||||
|
||||
/** "#c89bff" + alpha → rgba() usable in inline styles. Invalid input → transparent. */
|
||||
export function hexAlpha(hex: string, alpha: number): string {
|
||||
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
|
||||
if (!m || !m[1]) return 'transparent'
|
||||
const n = Number.parseInt(m[1], 16)
|
||||
return `rgba(${(n >> 16) & 0xff}, ${(n >> 8) & 0xff}, ${n & 0xff}, ${alpha})`
|
||||
}
|
||||
|
||||
// ── Payload parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
export function parsePayload(raw: string | null | undefined): Record<string, unknown> | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
/* legacy or truncated payloads render raw */
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseToolCall(e: EventRow): ToolCallPayload | null {
|
||||
const payload = parsePayload(e.payload_json)
|
||||
if (!payload || typeof payload.tool !== 'string') return null
|
||||
return payload as ToolCallPayload
|
||||
}
|
||||
|
||||
export function parseAgentStart(e: EventRow): AgentStartPayload | null {
|
||||
return parsePayload(e.payload_json) as AgentStartPayload | null
|
||||
}
|
||||
|
||||
// Args keys most likely to BE the call, in priority order — a bash command, a
|
||||
// file path, a search pattern. Used to build the one-line label.
|
||||
const ARG_LABEL_KEYS = [
|
||||
'command',
|
||||
'cmd',
|
||||
'file_path',
|
||||
'path',
|
||||
'pattern',
|
||||
'query',
|
||||
'url',
|
||||
'prompt',
|
||||
'description',
|
||||
]
|
||||
|
||||
const LABEL_MAX = 160
|
||||
|
||||
function oneLine(value: string): string {
|
||||
const flat = value.replaceAll(/\s+/g, ' ').trim()
|
||||
return flat.length > LABEL_MAX ? `${flat.slice(0, LABEL_MAX)}…` : flat
|
||||
}
|
||||
|
||||
/** One-line summary of a tool's args: the call itself, not a JSON dump. */
|
||||
export function argsSummary(args: Record<string, unknown> | undefined): string {
|
||||
if (!args) return ''
|
||||
for (const key of ARG_LABEL_KEYS) {
|
||||
const v = args[key]
|
||||
if (typeof v === 'string' && v.trim() !== '') return oneLine(v)
|
||||
}
|
||||
const parts: string[] = []
|
||||
for (const [key, v] of Object.entries(args)) {
|
||||
if (v == null) continue
|
||||
parts.push(`${key}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
|
||||
}
|
||||
return oneLine(parts.join(' '))
|
||||
}
|
||||
|
||||
/**
|
||||
* Exact-call row label for an event.
|
||||
* Rich tool_call → "bash: bun test src". Legacy payloads fall back to
|
||||
* the event name plus whatever hint the payload carries.
|
||||
*/
|
||||
export function eventLabel(e: EventRow): string {
|
||||
if (e.type === 'tool_call') {
|
||||
const call = parseToolCall(e)
|
||||
if (call?.tool) {
|
||||
// New-tracer rows already carry the human label in events.name
|
||||
// ("bash: ls -la src") — prefer it over re-deriving.
|
||||
if (e.name?.startsWith(call.tool)) return oneLine(e.name)
|
||||
const summary = argsSummary(call.args)
|
||||
return summary ? `${call.tool}: ${summary}` : call.tool
|
||||
}
|
||||
const legacy = parsePayload(e.payload_json)
|
||||
if (legacy && typeof legacy.pi_event === 'string') {
|
||||
return `${e.name ?? 'tool'} ${legacy.pi_event}`
|
||||
}
|
||||
}
|
||||
return e.name ?? e.type ?? ''
|
||||
}
|
||||
92
sssf/apps/visualizer/src/lib/format.ts
Normal file
92
sssf/apps/visualizer/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
export function ts(iso: string | null | undefined): number {
|
||||
if (!iso) return NaN
|
||||
return new Date(iso).getTime()
|
||||
}
|
||||
|
||||
export function fmtDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '—'
|
||||
if (ms < 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||
const s = ms / 1000
|
||||
if (s < 60) return `${s.toFixed(1)}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const rem = Math.round(s % 60)
|
||||
if (m < 60) return `${m}m ${String(rem).padStart(2, '0')}s`
|
||||
const h = Math.floor(m / 60)
|
||||
return `${h}h ${String(m % 60).padStart(2, '0')}m`
|
||||
}
|
||||
|
||||
export function fmtClock(iso: string | null | undefined): string {
|
||||
const t = ts(iso)
|
||||
if (!Number.isFinite(t)) return '—'
|
||||
return new Date(t).toLocaleTimeString([], { hour12: false })
|
||||
}
|
||||
|
||||
export function fmtDate(iso: string | null | undefined): string {
|
||||
const t = ts(iso)
|
||||
if (!Number.isFinite(t)) return '—'
|
||||
const d = new Date(t)
|
||||
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${fmtClock(iso)}`
|
||||
}
|
||||
|
||||
export function fmtTokens(n: number | null | undefined): string {
|
||||
if (n == null) return '—'
|
||||
if (n < 1000) return String(n)
|
||||
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
|
||||
return `${(n / 1_000_000).toFixed(2)}M`
|
||||
}
|
||||
|
||||
export function fmtCost(n: number | null | undefined): string {
|
||||
if (n == null) return '—'
|
||||
return n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`
|
||||
}
|
||||
|
||||
// Compact offset label for time axes: 0s, 30s, 1m, 1m30s, 2m, 1h05m.
|
||||
export function fmtOffset(ms: number): string {
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) {
|
||||
const rem = s % 60
|
||||
return rem ? `${m}m${String(rem).padStart(2, '0')}s` : `${m}m`
|
||||
}
|
||||
const h = Math.floor(m / 60)
|
||||
const mrem = m % 60
|
||||
return mrem ? `${h}h${String(mrem).padStart(2, '0')}m` : `${h}h`
|
||||
}
|
||||
|
||||
const TICK_STEPS_MS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 1800, 3600].map(
|
||||
(s) => s * 1000,
|
||||
)
|
||||
|
||||
/** Evenly-stepped time-axis ticks over a span, ≤ maxTicks of them. */
|
||||
export function axisTicks(spanMs: number, maxTicks = 8): { pct: number; label: string }[] {
|
||||
const span = Math.max(spanMs, 1)
|
||||
const step = TICK_STEPS_MS.find((s) => span / s <= maxTicks) ?? 3_600_000
|
||||
const out: { pct: number; label: string }[] = []
|
||||
for (let t = 0; t <= span; t += step) {
|
||||
out.push({ pct: (t / span) * 100, label: fmtOffset(t) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// New-tracer tool_call payloads carry ok:false on tool errors; older payloads
|
||||
// have no ok key and count as ok.
|
||||
export function payloadOk(raw: string | null | undefined): boolean {
|
||||
if (!raw) return true
|
||||
try {
|
||||
const p: unknown = JSON.parse(raw)
|
||||
if (p && typeof p === 'object' && 'ok' in p) return (p as { ok?: unknown }).ok !== false
|
||||
} catch {
|
||||
/* not JSON — treat as ok */
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function prettyJson(raw: string | null | undefined): string {
|
||||
if (!raw) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
54
sssf/apps/visualizer/src/lib/highlight.ts
Normal file
54
sssf/apps/visualizer/src/lib/highlight.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Dependency-free JSON syntax highlighting.
|
||||
*
|
||||
* Same safety model as markdown.ts: every character of input is HTML-escaped;
|
||||
* the only tags in the output are the <span>s this module writes. Token
|
||||
* colors live in style.css under the .j-* classes.
|
||||
*/
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
// Strings first so digits/keywords inside them are consumed as part of the
|
||||
// string token. A string followed by a colon is an object key.
|
||||
const TOKEN =
|
||||
/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false)\b|\b(null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g
|
||||
|
||||
/** Highlight text that is already (or claims to be) JSON. Escapes everything. */
|
||||
export function highlightJsonText(text: string): string {
|
||||
let out = ''
|
||||
let last = 0
|
||||
for (const m of text.matchAll(TOKEN)) {
|
||||
out += escapeHtml(text.slice(last, m.index))
|
||||
const [full, str, colon, bool, nil, num] = m
|
||||
if (str !== undefined) {
|
||||
const cls = colon !== undefined ? 'j-key' : 'j-str'
|
||||
out += `<span class="${cls}">${escapeHtml(str)}</span>${escapeHtml(colon ?? '')}`
|
||||
} else if (bool !== undefined) {
|
||||
out += `<span class="j-bool">${bool}</span>`
|
||||
} else if (nil !== undefined) {
|
||||
out += `<span class="j-null">null</span>`
|
||||
} else {
|
||||
out += `<span class="j-num">${escapeHtml(num ?? full)}</span>`
|
||||
}
|
||||
last = (m.index ?? 0) + full.length
|
||||
}
|
||||
out += escapeHtml(text.slice(last))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Pretty-print raw JSON and highlight it; non-JSON falls back to escaped raw. */
|
||||
export function highlightJson(raw: string | null | undefined): string {
|
||||
if (!raw) return ''
|
||||
try {
|
||||
return highlightJsonText(JSON.stringify(JSON.parse(raw), null, 2))
|
||||
} catch {
|
||||
return escapeHtml(raw)
|
||||
}
|
||||
}
|
||||
120
sssf/apps/visualizer/src/lib/markdown.ts
Normal file
120
sssf/apps/visualizer/src/lib/markdown.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Minimal, dependency-free markdown → HTML for the compiled prompt panels.
|
||||
*
|
||||
* Safety model: ALL input is HTML-escaped before any tags are produced, so
|
||||
* the only HTML in the output is what this module writes. No raw-HTML
|
||||
* passthrough, links restricted to http(s).
|
||||
*
|
||||
* Supported: #–#### headings, fenced code blocks, inline code, bold, links,
|
||||
* unordered/ordered lists, blockquotes, horizontal rules, paragraphs
|
||||
* (pre-wrap preserves intra-paragraph line breaks).
|
||||
*/
|
||||
|
||||
import { escapeHtml, highlightJsonText } from './highlight'
|
||||
|
||||
/** Bold + links, applied only OUTSIDE inline-code spans. */
|
||||
function inline(escaped: string): string {
|
||||
const parts = escaped.split(/(`[^`\n]+`)/g)
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) return `<code>${part.slice(1, -1)}</code>`
|
||||
return part
|
||||
.replaceAll(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replaceAll(
|
||||
/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
)
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function renderMarkdown(src: string): string {
|
||||
const lines = src.replaceAll('\r\n', '\n').split('\n')
|
||||
const out: string[] = []
|
||||
let i = 0
|
||||
|
||||
const paragraph: string[] = []
|
||||
function flushParagraph() {
|
||||
if (paragraph.length) {
|
||||
out.push(`<p>${paragraph.map(inline).join('\n')}</p>`)
|
||||
paragraph.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const raw = lines[i] ?? ''
|
||||
const line = escapeHtml(raw)
|
||||
|
||||
// Fenced code block — verbatim until the closing fence. json fences get
|
||||
// syntax highlighting (the Report contract in every user.md is one).
|
||||
const fence = /^\s*```(\w*)/.exec(raw)
|
||||
if (fence) {
|
||||
flushParagraph()
|
||||
const code: string[] = []
|
||||
i += 1
|
||||
while (i < lines.length && !/^\s*```/.test(lines[i] ?? '')) {
|
||||
code.push(lines[i] ?? '')
|
||||
i += 1
|
||||
}
|
||||
i += 1
|
||||
const text = code.join('\n')
|
||||
const body =
|
||||
(fence[1] ?? '').toLowerCase() === 'json' ? highlightJsonText(text) : escapeHtml(text)
|
||||
out.push(`<pre class="md-code"><code>${body}</code></pre>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = /^(#{1,4})\s+(.*)$/.exec(raw)
|
||||
if (heading?.[1] && heading[2] !== undefined) {
|
||||
flushParagraph()
|
||||
const level = heading[1].length
|
||||
out.push(`<h${level}>${inline(escapeHtml(heading[2]))}</h${level}>`)
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*(---+|\*\*\*+)\s*$/.test(raw)) {
|
||||
flushParagraph()
|
||||
out.push('<hr>')
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^\s*>\s?/.test(line)) {
|
||||
flushParagraph()
|
||||
const quote: string[] = []
|
||||
while (i < lines.length && /^\s*>\s?/.test(lines[i] ?? '')) {
|
||||
quote.push(inline(escapeHtml((lines[i] ?? '').replace(/^\s*>\s?/, ''))))
|
||||
i += 1
|
||||
}
|
||||
out.push(`<blockquote>${quote.join('\n')}</blockquote>`)
|
||||
continue
|
||||
}
|
||||
|
||||
const ulItem = /^\s*[-*]\s+/.test(raw)
|
||||
const olItem = /^\s*\d+\.\s+/.test(raw)
|
||||
if (ulItem || olItem) {
|
||||
flushParagraph()
|
||||
const tag = ulItem ? 'ul' : 'ol'
|
||||
const marker = ulItem ? /^\s*[-*]\s+/ : /^\s*\d+\.\s+/
|
||||
const items: string[] = []
|
||||
while (i < lines.length && marker.test(lines[i] ?? '')) {
|
||||
items.push(`<li>${inline(escapeHtml((lines[i] ?? '').replace(marker, '')))}</li>`)
|
||||
i += 1
|
||||
}
|
||||
out.push(`<${tag}>${items.join('')}</${tag}>`)
|
||||
continue
|
||||
}
|
||||
|
||||
if (raw.trim() === '') {
|
||||
flushParagraph()
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
paragraph.push(line)
|
||||
i += 1
|
||||
}
|
||||
flushParagraph()
|
||||
return out.join('\n')
|
||||
}
|
||||
29
sssf/apps/visualizer/src/lib/models.ts
Normal file
29
sssf/apps/visualizer/src/lib/models.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Model → provider icon, by contains-check on the model name.
|
||||
*
|
||||
* Icons live in public/models/ (served from the site root). First matching
|
||||
* needle wins; unknown models render no icon.
|
||||
*/
|
||||
|
||||
const MODEL_ICONS: [needles: string[], icon: string][] = [
|
||||
[['claude', 'opus', 'sonnet', 'haiku'], '/models/claude.png'],
|
||||
[['gemini'], '/models/gemini.png'],
|
||||
[['kimi', 'moonshot'], '/models/kimi.png'],
|
||||
[['gpt', 'openai', 'codex', 'o3', 'o4'], '/models/openai.png'],
|
||||
[['glm', 'zai', 'z.ai'], '/models/zai.png'],
|
||||
]
|
||||
|
||||
export function modelIcon(model: string | null | undefined): string | null {
|
||||
if (!model) return null
|
||||
const m = model.toLowerCase()
|
||||
for (const [needles, icon] of MODEL_ICONS) {
|
||||
if (needles.some((n) => m.includes(n))) return icon
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Keep provider-qualified IDs compact while preserving the full ID in titles. */
|
||||
export function modelName(model: string | null | undefined): string {
|
||||
if (!model) return ''
|
||||
return model.split('/').filter(Boolean).at(-1) ?? model
|
||||
}
|
||||
44
sssf/apps/visualizer/src/lib/router.ts
Normal file
44
sssf/apps/visualizer/src/lib/router.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
// Hash routes: #/ → sessions · #/<repo>/<adw_id> → waterfall · #/<repo>/<adw_id>/<phase_id> → phase panel open
|
||||
// The repo segment is optional and defaults to the first repo the server serves.
|
||||
export interface Route {
|
||||
repo: string | null
|
||||
adwId: string | null
|
||||
phaseId: string | null
|
||||
}
|
||||
|
||||
function parse(): Route {
|
||||
const parts = window.location.hash
|
||||
.replace(/^#\/?/, '')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map(decodeURIComponent)
|
||||
return { repo: parts[0] ?? null, adwId: parts[1] ?? null, phaseId: parts[2] ?? null }
|
||||
}
|
||||
|
||||
const route = ref<Route>(parse())
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
route.value = parse()
|
||||
})
|
||||
|
||||
export function useRoute() {
|
||||
return route
|
||||
}
|
||||
|
||||
// Display name for the phase crumb — set by the trace view once phases load,
|
||||
// since the phase_id in the URL is not the display name.
|
||||
export const phaseCrumb = ref<string | null>(null)
|
||||
|
||||
export function hrefFor(repo?: string | null, adwId?: string | null, phaseId?: string | null): string {
|
||||
const parts: string[] = []
|
||||
if (repo) parts.push(encodeURIComponent(repo))
|
||||
if (adwId) parts.push(encodeURIComponent(adwId))
|
||||
if (phaseId) parts.push(encodeURIComponent(phaseId))
|
||||
return '#/' + parts.join('/')
|
||||
}
|
||||
|
||||
export function navigate(repo?: string | null, adwId?: string | null, phaseId?: string | null): void {
|
||||
window.location.hash = hrefFor(repo, adwId, phaseId)
|
||||
}
|
||||
27
sssf/apps/visualizer/src/lib/types.ts
Normal file
27
sssf/apps/visualizer/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Single switch point onto the server's contract: everything UI-side imports
|
||||
// table shapes from here, which re-exports shared/types.ts.
|
||||
export type {
|
||||
Session,
|
||||
SessionSummary,
|
||||
SessionUsage,
|
||||
SessionDetail,
|
||||
Phase,
|
||||
Event as EventRow,
|
||||
EventsPage,
|
||||
Envelope,
|
||||
GateResult,
|
||||
GateCheck,
|
||||
AgentSession,
|
||||
AgentStartPayload,
|
||||
AgentEndPayload,
|
||||
UsageBreakdown,
|
||||
ToolCallPayload,
|
||||
AgentPrompts,
|
||||
PromptsResponse,
|
||||
HealthResponse,
|
||||
RepoInfo,
|
||||
SessionStatus,
|
||||
PhaseStatus,
|
||||
PhaseKind,
|
||||
EventType,
|
||||
} from '@shared/types'
|
||||
7
sssf/apps/visualizer/src/main.ts
Normal file
7
sssf/apps/visualizer/src/main.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createApp } from 'vue'
|
||||
import '@fontsource/play/400.css'
|
||||
import '@fontsource/play/700.css'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
206
sssf/apps/visualizer/src/style.css
Normal file
206
sssf/apps/visualizer/src/style.css
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
:root {
|
||||
--bg: #06080f;
|
||||
--panel: #0d1119;
|
||||
--panel-2: #131a26;
|
||||
--panel-3: #0a0e16;
|
||||
--border: #232c3d;
|
||||
--border-soft: #222b3d;
|
||||
--text: #f2f5fa;
|
||||
--dim: #aabdd5;
|
||||
--faint: #8b9cb6;
|
||||
--green: #4ade80;
|
||||
--red: #ff6f67;
|
||||
--blue: #6cb6ff;
|
||||
--amber: #e8b64a;
|
||||
--purple: #c89bff;
|
||||
--cyan: #5ad2dd;
|
||||
--violet: #94a3ff;
|
||||
/* Play carries the UI voice; mono is reserved for data (ids, times, code). */
|
||||
--sans: 'Play', 'Helvetica Neue', system-ui, sans-serif;
|
||||
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Monaco, 'Cascadia Mono',
|
||||
'Roboto Mono', monospace;
|
||||
/* Surface gradient shared by cards and panels — one recipe, everywhere. */
|
||||
--surface: linear-gradient(180deg, #10141f 0%, #0b0f18 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
/* Deep space with a faint violet/cyan aurora — fixed so scrolling glides over it. */
|
||||
background:
|
||||
radial-gradient(1100px 700px at 8% -10%, rgba(148, 163, 255, 0.09), transparent 62%),
|
||||
radial-gradient(1000px 720px at 102% 108%, rgba(90, 210, 221, 0.07), transparent 60%),
|
||||
linear-gradient(180deg, #06080f 0%, #090d17 100%);
|
||||
background-attachment: fixed;
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
/* Readability floor: nothing in the app renders below 16px. */
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-3);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
color: #cfdded;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* JSON token colors — spans emitted by lib/highlight.ts */
|
||||
.j-key {
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.j-str {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.j-num {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.j-bool {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.j-null {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.faint {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.error-bar {
|
||||
margin: 12px 24px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid rgba(255, 111, 103, 0.5);
|
||||
background: rgba(255, 111, 103, 0.1);
|
||||
color: var(--red);
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
color: var(--dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Rendered-markdown blocks (compiled prompts) — global because the content is
|
||||
injected via v-html and scoped styles can't reach it. */
|
||||
.md {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.md h1 {
|
||||
font-size: 20px;
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
|
||||
.md h2 {
|
||||
font-size: 18px;
|
||||
margin: 14px 0 8px;
|
||||
}
|
||||
|
||||
.md h3,
|
||||
.md h4 {
|
||||
font-size: 17px;
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
|
||||
.md h1:first-child,
|
||||
.md h2:first-child,
|
||||
.md h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.md p {
|
||||
margin: 8px 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.md code {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
padding: 1px 7px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.md pre.md-code {
|
||||
margin: 10px 0;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.md pre.md-code code {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.md ul,
|
||||
.md ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.md li {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.md blockquote {
|
||||
margin: 10px 0;
|
||||
padding: 4px 0 4px 14px;
|
||||
border-left: 3px solid var(--border);
|
||||
color: var(--dim);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.md hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
30
sssf/apps/visualizer/tsconfig.json
Normal file
30
sssf/apps/visualizer/tsconfig.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["bun", "vite/client"],
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue",
|
||||
"noEmit": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@shared/*": ["./shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["shared/**/*.ts", "server/**/*.ts", "src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
28
sssf/apps/visualizer/vite.config.ts
Normal file
28
sssf/apps/visualizer/vite.config.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
const API_PORT = process.env.PORT ?? "4600";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
"@shared": fileURLToPath(new URL("./shared", import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 4601,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: `http://localhost:${API_PORT}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue