diff --git a/bun.lock b/bun.lock index 22f0686..aa0843b 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "zod": "^3.25.76", }, "devDependencies": { + "@astrojs/check": "0.9.10", "@playwright/test": "^1.62.1", "@types/bun": "^1.4.0", "@typescript-eslint/eslint-plugin": "^8.69.0", @@ -25,6 +26,10 @@ }, }, "packages": { + "@astrojs/astro2tsx": ["@astrojs/astro2tsx@0.1.1", "", { "dependencies": { "@emnapi/core": "1.11.3", "@napi-rs/wasm-runtime": "1.2.4" } }, "sha512-VNhvoKzs0FXF3iq+2QVIjTqA6awKDKpd2U7khYg3AhcxjM1KMINEma4IeqQnMqJUakw0MNQygntLvLmSPS5pLA=="], + + "@astrojs/check": ["@astrojs/check@0.9.10", "", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], + "@astrojs/compiler-binding": ["@astrojs/compiler-binding@0.4.0", "", { "optionalDependencies": { "@astrojs/compiler-binding-darwin-arm64": "0.4.0", "@astrojs/compiler-binding-darwin-x64": "0.4.0", "@astrojs/compiler-binding-linux-arm64-gnu": "0.4.0", "@astrojs/compiler-binding-linux-arm64-musl": "0.4.0", "@astrojs/compiler-binding-linux-x64-gnu": "0.4.0", "@astrojs/compiler-binding-linux-x64-musl": "0.4.0", "@astrojs/compiler-binding-wasm32-wasi": "0.4.0", "@astrojs/compiler-binding-win32-arm64-msvc": "0.4.0", "@astrojs/compiler-binding-win32-x64-msvc": "0.4.0" } }, "sha512-x2RjDUuWfwLNtc3mjAdSRInwqh/rqbLar9cm/5FOMbHvmYZB7yfKewzSclAxWjIZsypJDXv1lhaP2WG+P8TK3g=="], "@astrojs/compiler-binding-darwin-arm64": ["@astrojs/compiler-binding-darwin-arm64@0.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZVUwHundaQyFNjE6uoa0usaC0WOCitDCLS/4mdb4rOiJXwVUuKJBMxI5WMzXLWmamsXtK/Z//ifLXvV5Yeh4Hw=="], @@ -49,6 +54,8 @@ "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.11.0", "", { "dependencies": { "@types/hast": "^3.0.4", "@types/mdast": "^4.0.4", "js-yaml": "^4.3.0", "picomatch": "^4.0.4", "retext-smartypants": "^6.2.0", "shiki": "^4.0.2", "smol-toml": "^1.6.0", "unified": "^11.0.5" } }, "sha512-3rzxJ+xbo0+8YyqOzLziIN32wmsHdCjEVz2sGOpRxJ+Ben/KiLph4ItxBy1abEL+E8fkRzqjg0rfXmaHJGw9JA=="], + "@astrojs/language-server": ["@astrojs/language-server@2.17.0", "", { "dependencies": { "@astrojs/astro2tsx": "^0.1.0", "@astrojs/yaml2ts": "^0.2.4", "@volar/kit": "~2.4.28", "@volar/language-core": "~2.4.28", "@volar/language-server": "~2.4.28", "@volar/language-service": "~2.4.28", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.16", "volar-service-css": "0.0.71", "volar-service-emmet": "0.0.71", "volar-service-html": "0.0.71", "volar-service-prettier": "0.0.71", "volar-service-typescript": "0.0.71", "volar-service-typescript-twoslash-queries": "0.0.71", "volar-service-yaml": "0.0.71", "vscode-html-languageservice": "^5.6.2", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "./bin/nodeServer.js" } }, "sha512-ZvT7UEo7/jBdfjXD3nNYssziEezNKM8G6FUYRnGUidAs7FmowqsQhQ0Xwl5Q4tjqlOjhnpxQuoEb4jwESL+nKQ=="], + "@astrojs/markdown-satteri": ["@astrojs/markdown-satteri@0.4.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.11.0", "@astrojs/prism": "4.0.2", "github-slugger": "^2.0.0", "satteri": "^0.10.3" } }, "sha512-EniHbFNa6SQHDih7JnpPos3NWXO6hdt4raBcOosfGmlfc3gP9CI/sESA3VOf1PgJZ7SFfewlZW9Livn0JugEZg=="], "@astrojs/prism": ["@astrojs/prism@4.0.2", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA=="], @@ -57,6 +64,8 @@ "@astrojs/telemetry": ["@astrojs/telemetry@3.3.3", "", { "dependencies": { "ci-info": "^4.4.0", "dset": "^3.1.4", "is-docker": "^4.0.0", "package-manager-detector": "^1.6.0" } }, "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ=="], + "@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.4", "", { "dependencies": { "yaml": "^2.8.3" } }, "sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A=="], + "@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=="], @@ -89,11 +98,25 @@ "@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="], - "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], + + "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], + + "@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="], + + "@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="], + + "@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="], + + "@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="], + + "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], + + "@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], @@ -353,6 +376,22 @@ "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="], + + "@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="], + + "@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=="], + + "@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="], + + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@xyflow/svelte": ["@xyflow/svelte@1.6.6", "", { "dependencies": { "@svelte-put/shortcut": "^4.1.0", "@xyflow/system": "0.0.82" }, "peerDependencies": { "svelte": "^5.25.0" } }, "sha512-gQk3fc+zmX5ak6xIXGcPpxFRl0MJvVG7AKDaoMEFjmx2sPE/43Aqa9YMefSR5aIokBaTdErrsQBqSiBAZGonpg=="], "@xyflow/system": ["@xyflow/system@0.0.82", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-4DKnL3CGtCGLRSmgDqaajRVgeksMXq/Yw4wPfdMfm7JvdIiWHGzVYOfFUztiATwdBXZqUU6HhehwUdbV9G23PQ=="], @@ -363,8 +402,16 @@ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "3.1.3", "fast-json-stable-stringify": "2.1.0", "json-schema-traverse": "0.4.1", "uri-js": "4.4.1" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + + "ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="], + "am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="], + "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -391,10 +438,12 @@ "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], @@ -471,12 +520,18 @@ "electrobun": ["electrobun@2.0.1", "", { "bin": { "electrobun": "bin/electrobun.cjs" } }, "sha512-1+OjJ0tMemjVrUEsh/AhmxnqUNpyCfJslCbcWOvkogFJuNVMmTlWMzyZem/Ts/7lg8f7ULWu8uuQBsKeGQsu7A=="], + "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "eslint": ["eslint@10.9.1", "", { "dependencies": { "@eslint-community/eslint-utils": "4.10.1", "@eslint-community/regexpp": "4.12.2", "@eslint/config-array": "0.23.5", "@eslint/config-helpers": "0.7.0", "@eslint/core": "1.2.1", "@eslint/plugin-kit": "0.7.2", "@humanfs/node": "0.16.8", "@humanwhocodes/module-importer": "1.0.1", "@humanwhocodes/retry": "0.4.3", "@types/estree": "1.0.9", "ajv": "6.15.0", "cross-spawn": "7.0.6", "debug": "4.4.3", "escape-string-regexp": "4.0.0", "eslint-scope": "9.1.2", "eslint-visitor-keys": "5.0.1", "espree": "11.2.0", "esquery": "1.7.0", "esutils": "2.0.3", "fast-deep-equal": "3.1.3", "file-entry-cache": "8.0.0", "find-up": "5.0.0", "glob-parent": "6.0.2", "ignore": "5.3.2", "imurmurhash": "0.1.4", "is-glob": "4.0.3", "json-stable-stringify-without-jsonify": "1.0.1", "minimatch": "10.2.6", "natural-compare": "1.4.0", "optionator": "0.9.4" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A=="], @@ -513,6 +568,8 @@ "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.8", "", {}, "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -535,6 +592,10 @@ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + "get-tsconfig": ["get-tsconfig@5.0.0-beta.4", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], @@ -583,6 +644,8 @@ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "1.2.1", "type-check": "0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], @@ -639,6 +702,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + "nanoid": ["nanoid@3.3.19", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -677,6 +742,8 @@ "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -695,6 +762,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@3.9.8", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A=="], + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], "process-ancestry": ["process-ancestry@0.1.0", "", {}, "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg=="], @@ -705,7 +774,7 @@ "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], - "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], @@ -713,6 +782,10 @@ "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + "request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="], @@ -743,8 +816,12 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "svelte": ["svelte@5.57.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg=="], "svelte2tsx": ["svelte2tsx@0.7.61", "", { "dependencies": { "dedent-js": "^1.0.1", "scule": "^1.3.0" }, "peerDependencies": { "svelte": "^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0", "typescript": "^4.9.4 || ^5.0.0 || ^6.0.0" } }, "sha512-EpQ/+UHITBULeUojx/LLD3uTYasZXcX1BrqlrzfLUnT9vdUhaOWK59a3ryWcFU8L0sY1SP+gRhJ2Beiis98+qw=="], @@ -769,8 +846,12 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="], + "typescript-eslint": ["typescript-eslint@8.69.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.69.0", "@typescript-eslint/parser": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/utils": "8.69.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA=="], "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], @@ -809,14 +890,56 @@ "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + "volar-service-css": ["volar-service-css@0.0.71", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw=="], + + "volar-service-emmet": ["volar-service-emmet@0.0.71", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA=="], + + "volar-service-html": ["volar-service-html@0.0.71", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw=="], + + "volar-service-prettier": ["volar-service-prettier@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg=="], + + "volar-service-typescript": ["volar-service-typescript@0.0.71", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw=="], + + "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A=="], + + "volar-service-yaml": ["volar-service-yaml@0.0.71", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.23.0" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q=="], + + "vscode-css-languageservice": ["vscode-css-languageservice@6.3.10", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA=="], + + "vscode-html-languageservice": ["vscode-html-languageservice@5.6.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg=="], + + "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@9.0.2", "", {}, "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.18.3", "", { "dependencies": { "vscode-jsonrpc": "9.0.2", "vscode-languageserver-types": "3.18.3" } }, "sha512-DF49+WeV5py4zO5hhobp60jjsDSK0lAqA0OuKBLBvp423HPWQcCbhZz3JgyfIewsEz2f8U+X75xNIFHdiXZm2w=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.14", "", {}, "sha512-EQyqJMi552E4ZTf46izQ4Fj6XquqxCySR3J5ZSD1SisMf6RfpeOWHxGBE8Gr6V0/3GHIGdAzDn8F8+1nTGCnoQ=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.18.3", "", {}, "sha512-XIlzJ7Qp/jzSI1ds7/FwPAWrPeTZA7pAtlW4hdJ1J6xXWJL6dR9QYnDhJOdLzdKhUQ5Mm6mvUMw+3DcOQQasPw=="], + + "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], + + "vscode-uri": ["vscode-uri@3.2.0", "", {}, "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], + "yaml-language-server": ["yaml-language-server@1.23.0", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "ajv-i18n": "^4.2.0", "prettier": "^3.8.1", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.8.3" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A=="], + + "yargs": ["yargs@18.2.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^8.2.1", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w=="], + "yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], @@ -827,14 +950,22 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], + "@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + + "ajv-i18n/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], "astro/zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], + "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "csso/css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -845,10 +976,36 @@ "svelte/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "vscode-css-languageservice/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-languageserver/vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "yaml-language-server/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], + + "yaml-language-server/yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + + "@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "ajv-i18n/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "unstorage/chokidar/readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver/vscode-languageserver-protocol/vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "yaml-language-server/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], } } diff --git a/frontend/src/layouts/AppLayout.astro b/frontend/src/layouts/AppLayout.astro new file mode 100644 index 0000000..e098b48 --- /dev/null +++ b/frontend/src/layouts/AppLayout.astro @@ -0,0 +1,115 @@ +--- +import "../styles/global.css"; +import pkg from "../../../package.json"; +import Sidebar from "../svelte/navigation/Sidebar.svelte"; +import Topbar from "../svelte/navigation/Topbar.svelte"; +import StatusBar from "../svelte/navigation/StatusBar.svelte"; +import CommandPalette from "../svelte/navigation/CommandPalette.svelte"; +import { getApiOrigin } from "../lib/api/config.js"; + +interface Props { + /** Page title, used as the breadcrumb terminal and document title. */ + title: string; + /** Optional full breadcrumb trail; defaults to the page title alone. */ + breadcrumb?: string[]; +} + +const { title, breadcrumb = [title] } = Astro.props; +const origin = getApiOrigin(); +const currentPath = Astro.url.pathname; +const documentTitle = `${breadcrumb[breadcrumb.length - 1] ?? title} · WeaveLLM`; +--- + + + + + + + + {documentTitle} + + + + + {/* + Chrome hydrates lazily: these islands render their interactive shell + server-side and hydrate when the browser is idle. Page content islands + keep client:load so their data loads without waiting on idle. + */} + + +
+ + +
+ +
+ + +
+ + + + + + \ No newline at end of file diff --git a/frontend/src/lib/api/config.test.ts b/frontend/src/lib/api/config.test.ts new file mode 100644 index 0000000..c64979c --- /dev/null +++ b/frontend/src/lib/api/config.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; +import { getApiOrigin, getWsOrigin, wsOriginFrom } from "./config.js"; + +describe("api origin helpers", () => { + test("ws origin derives from an http origin", () => { + expect(wsOriginFrom("http://127.0.0.1:4317")).toBe("ws://127.0.0.1:4317"); + }); + + test("https origins map to wss", () => { + expect(wsOriginFrom("https://models.example.com")).toBe("wss://models.example.com"); + }); + + test("getApiOrigin returns an http url", () => { + expect(getApiOrigin()).toMatch(/^https?:\/\//); + }); + + test("getWsOrigin returns a ws url", () => { + expect(getWsOrigin()).toMatch(/^wss?:\/\//); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/api/config.ts b/frontend/src/lib/api/config.ts new file mode 100644 index 0000000..d03fe34 --- /dev/null +++ b/frontend/src/lib/api/config.ts @@ -0,0 +1,31 @@ +/** + * API origin resolution for the console shell. + * + * The backend listens on 127.0.0.1:4317 in development. The origin can be + * overridden at build/dev time with `PUBLIC_WEAVELLM_API_ORIGIN` (Astro + * exposes PUBLIC_* environment variables to client code through + * `import.meta.env`). This is the only API-layer module in phase 1; typed + * clients for /api, /v1 and /ws arrive in phase 2. + */ + +const DEFAULT_API_ORIGIN = "http://127.0.0.1:4317"; + +/** Base origin for backend HTTP calls (defaults to the local runtime). */ +export function getApiOrigin(): string { + const override: string | undefined = import.meta.env.PUBLIC_WEAVELLM_API_ORIGIN as + | string + | undefined; + return typeof override === "string" && override.length > 0 + ? override + : DEFAULT_API_ORIGIN; +} + +/** Derive a websocket origin from an http(s) origin (http→ws, https→wss). */ +export function wsOriginFrom(apiOrigin: string): string { + return apiOrigin.replace(/^http/, "ws"); +} + +/** Base origin for backend websocket calls (WS /ws). */ +export function getWsOrigin(): string { + return wsOriginFrom(getApiOrigin()); +} \ No newline at end of file diff --git a/frontend/src/lib/api/health.test.ts b/frontend/src/lib/api/health.test.ts new file mode 100644 index 0000000..7352df0 --- /dev/null +++ b/frontend/src/lib/api/health.test.ts @@ -0,0 +1,43 @@ +/** + * bun:test suite for the /api/health client (getHealth). NO auth gate on the + * backend — the health branch is open. fetchImpl injected; no network. + */ + +import { describe, expect, test } from "bun:test"; +import { ApiError, type FetchLike } from "./http.js"; +import { getHealth } from "./health.js"; + +function jsonBody( + body: unknown, + status = 200, +): FetchLike { + return async () => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("getHealth()", () => { + test("returns {status:\"ok\"} for a live backend", async () => { + const fetchImpl = jsonBody({ status: "ok" }); + const result = await getHealth({ origin: "http://test", fetchImpl }); + expect(result).toEqual({ status: "ok" }); + }); + + test("propagates localModels when a hub is wired", async () => { + const fetchImpl = jsonBody({ status: "ok", localModels: ["llama-3.1-8b"] }); + const result = await getHealth({ origin: "http://test", fetchImpl }); + expect(result.localModels).toEqual(["llama-3.1-8b"]); + }); + + test("throws ApiError on a non-ok backend status", async () => { + const fetchImpl = jsonBody({ error: "service_unavailable" }, 503); + const caught = await getHealth({ origin: "http://test", fetchImpl }).catch( + (err: unknown) => err, + ); + expect(caught).toBeInstanceOf(ApiError); + expect((caught as ApiError).status).toBe(503); + expect((caught as ApiError).code).toBe("service_unavailable"); + }); +}); diff --git a/frontend/src/lib/api/health.ts b/frontend/src/lib/api/health.ts new file mode 100644 index 0000000..ea6673c --- /dev/null +++ b/frontend/src/lib/api/health.ts @@ -0,0 +1,21 @@ +/** + * Typed client for the health surface. + * + * GET /api/health → HealthStatus (liveness; NO auth gate — the probe the + * console polls freely at boot and on the status bar). + */ + +import { request, type FetchLike } from "./http.js"; +import type { HealthStatus } from "./types.js"; + +export interface HealthOptions { + origin?: string; + fetchImpl?: FetchLike; + signal?: AbortSignal; + timeoutMs?: number; +} + +/** GET /api/health → {status:"ok", localModels?}. */ +export async function getHealth(options: HealthOptions = {}): Promise { + return request("/api/health", { method: "GET", ...options }); +} diff --git a/frontend/src/lib/api/http.test.ts b/frontend/src/lib/api/http.test.ts new file mode 100644 index 0000000..335692c --- /dev/null +++ b/frontend/src/lib/api/http.test.ts @@ -0,0 +1,91 @@ +/** + * bun:test suite for the http transport (request + ApiError). Mirrors the + * config.test.ts style (U01) — bun:test, describe/test, imports "./x.js" — + * and drives request() with an injected fake fetch so no real network or + * backend process is touched. + */ + +import { describe, expect, test } from "bun:test"; +import { ApiError, request, type FetchLike } from "./http.js"; + +/** Fake fetch returning a canned Response for a single call. */ +function jsonBody( + body: unknown, + status = 200, + headers: Record = { "content-type": "application/json" }, +): FetchLike { + return async () => new Response(JSON.stringify(body), { status, headers }); +} + +describe("request()", () => { + test("parses a 200 JSON response body", async () => { + const fetchImpl = jsonBody({ name: "file", version: 3 }); + const result = await request<{ name: string; version: number }>("/api/workflows", { + origin: "http://test", + fetchImpl, + }); + expect(result).toEqual({ name: "file", version: 3 }); + }); + + test("returns undefined for a 204", async () => { + const fetchImpl = jsonBody(undefined, 204); + const result = await request("/api/workflows/del", { + method: "DELETE", + origin: "http://test", + fetchImpl, + }); + expect(result).toBeUndefined(); + }); + + test("throws ApiError wrapping the {error} envelope on non-2xx", async () => { + const fetchImpl = jsonBody( + { error: "workflow_not_found" }, + 404, + { "content-type": "application/json" }, + ); + const caught = await request("/api/workflows/missing", { + origin: "http://test", + fetchImpl, + }).catch((err: unknown) => err); + + expect(caught).toBeInstanceOf(ApiError); + const apiError = caught as ApiError; + expect(apiError.status).toBe(404); + expect(apiError.code).toBe("workflow_not_found"); + expect(apiError.message).toBe("workflow_not_found"); + }); + + test("throws ApiError(0, network_error) on transport failure", async () => { + const fetchImpl: FetchLike = async () => { + throw new TypeError("fetch failed"); + }; + + const caught = await request("/api/health", { + origin: "http://test", + fetchImpl, + }).catch((err: unknown) => err); + + expect(caught).toBeInstanceOf(ApiError); + const apiError = caught as ApiError; + expect(apiError.status).toBe(0); + expect(apiError.code).toBe("network_error"); + }); + + test("carries backend envelope errors when present", async () => { + const fetchImpl = jsonBody( + { error: "invalid_yaml", errors: [{ path: "nodes", message: "missing start" }] }, + 400, + { "content-type": "application/json" }, + ); + const caught = await request("/api/workflows/x", { + method: "PUT", + origin: "http://test", + fetchImpl, + }).catch((err: unknown) => err); + + const apiError = caught as ApiError; + expect(apiError.status).toBe(400); + expect(apiError.code).toBe("invalid_yaml"); + expect(apiError.errors).toEqual([{ path: "nodes", message: "missing start" }]); + }); +}); diff --git a/frontend/src/lib/api/http.ts b/frontend/src/lib/api/http.ts new file mode 100644 index 0000000..a5572a0 --- /dev/null +++ b/frontend/src/lib/api/http.ts @@ -0,0 +1,164 @@ +/** + * Minimal HTTP transport for the console API surface. + * + * Funnels every typed call in this layer through `request()`, which: + * - resolves the base origin via getApiOrigin() (config.ts, phase 1); + * - sends JSON (jsonBody) or raw text (rawBody — the workflows PUT sends + * YAML text, not JSON); + * - treats 204 as undefined, everything else 2xx as parsed JSON; + * - maps non-2xx onto the backend envelope `{error, errors?}` throwing an + * `ApiError(status, message, errors?)`, and transport failures onto + * `ApiError(0, "network_error"|"request_timeout")`; + * - lets tests inject a fake fetch and/or a base origin. + * + * No React/Astro imports — stays importable under bun:test in any runtime. + */ + +import { getApiOrigin } from "./config.js"; +import type { ApiErrorEnvelope } from "./types.js"; + +export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; + +/** + * Minimal fetch contract the transport actually uses: input + init → + * Response promise. Deliberately narrower than `typeof fetch` (which also + * carries `preconnect` and stricter RequestInfo typing) so tests can inject + * plain async functions without casts; the global `fetch` satisfies it. + */ +export type FetchLike = ( + input: string | URL | Request, + init?: RequestInit, +) => Promise; + +export interface RequestOptions { + method?: HttpMethod; + /** JSON body (mutually exclusive with rawBody). */ + jsonBody?: unknown; + /** Raw text body — the workflows PUT sends YAML text. */ + rawBody?: string; + /** Content type for rawBody (defaults to application/x-yaml). */ + rawBodyType?: string; + /** Override the API origin (defaults to getApiOrigin()). */ + origin?: string; + /** Inject a fetch implementation (tests). Defaults to global fetch. */ + fetchImpl?: FetchLike; + /** Abort the request. */ + signal?: AbortSignal; + /** Timeout in ms (default 10_000). */ + timeoutMs?: number; +} + +export interface ApiErrorOptions { + status: number; + message: string; + code: string; + errors?: unknown; +} + +/** Error thrown on non-2xx responses (status>0) or transport failures (0). */ +export class ApiError extends Error { + readonly status: number; + readonly code: string; + readonly errors?: unknown; + + constructor(options: ApiErrorOptions) { + super(options.message); + this.name = "ApiError"; + this.status = options.status; + this.code = options.code; + this.errors = options.errors; + } +} + +async function parseBody(res: Response): Promise { + return res.text(); +} + +function toEnvelope(value: unknown): ApiErrorEnvelope | null { + if (typeof value !== "object" || value === null) return null; + const e = value as ApiErrorEnvelope; + return typeof e.error === "string" ? e : null; +} + +/** + * Execute a request against the /api surface. + * + * @param path URL path, e.g. "/api/health". + * @param options + * @returns the parsed JSON body (T), or undefined for 204 / empty body. + * @throws ApiError for non-2xx and transport failures. + */ +export async function request( + path: string, + options: RequestOptions = {}, +): Promise { + const origin = options.origin ?? getApiOrigin(); + const fetchImpl = options.fetchImpl ?? fetch; + const method = options.method ?? "GET"; + const headers = new Headers({ accept: "application/json" }); + + let body: string | undefined; + if (options.rawBody !== undefined) { + body = options.rawBody; + headers.set("content-type", options.rawBodyType ?? "application/x-yaml"); + } else if (options.jsonBody !== undefined) { + body = JSON.stringify(options.jsonBody); + headers.set("content-type", "application/json"); + } + + const timeoutMs = options.timeoutMs ?? 10_000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + if (options.signal?.aborted) controller.abort(); + else options.signal?.addEventListener("abort", () => controller.abort(), { once: true }); + + let res: Response; + try { + res = await fetchImpl(`${origin}${path}`, { + method, + headers, + body, + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + const name = (err as Error)?.name; + if (name === "AbortError" && controller.signal.aborted) { + throw new ApiError({ status: 0, message: "request_timeout", code: "request_timeout" }); + } + throw new ApiError({ + status: 0, + message: (err as Error)?.message ?? "network_error", + code: "network_error", + }); + } + clearTimeout(timer); + + const isOk = res.ok || res.status === 204; + if (!isOk) { + const text = await parseBody(res).catch(() => ""); + let envelope: ApiErrorEnvelope | null = null; + if (text.length > 0) { + try { + envelope = toEnvelope(JSON.parse(text)); + } catch { + // non-JSON error body — fall through to the raw status + } + } + throw new ApiError({ + status: res.status, + message: envelope?.error ?? `http_${res.status}`, + code: envelope?.error ?? `http_${res.status}`, + errors: envelope?.errors, + }); + } + + if (res.status === 204) return undefined as T ; + const text = await parseBody(res); + if (text.length === 0) return undefined as T; + try { + return JSON.parse(text) as T; + } catch { + throw new ApiError({ status: 0, message: "invalid_json", code: "invalid_json" }); + } +} diff --git a/frontend/src/lib/api/index.ts b/frontend/src/lib/api/index.ts new file mode 100644 index 0000000..03ffc51 --- /dev/null +++ b/frontend/src/lib/api/index.ts @@ -0,0 +1,22 @@ +/** + * Public barrel for the console API layer. + * + * config.ts — origin resolution (phase 1, U01) — re-exported so the + * console consumes ONE api surface from one import root. + * http.ts — request() + ApiError (typed transport funnel). + * types.ts — shared wire types (verbatim backend shapes). + * health.ts — /api/health. + * models.ts — /api/models hub surface. + * workflows.ts — /api/workflows CRUD + run + logs. + * runtime.ts — combined runtime status probe. + * v1.ts — /v1/chat/completions (JSON + OpenAI-wire SSE). + */ + +export * from "./config.js"; +export * from "./http.js"; +export * from "./types.js"; +export * from "./health.js"; +export * from "./models.js"; +export * from "./workflows.js"; +export * from "./runtime.js"; +export * from "./v1.js"; diff --git a/frontend/src/lib/api/models.test.ts b/frontend/src/lib/api/models.test.ts new file mode 100644 index 0000000..d0e7d6a --- /dev/null +++ b/frontend/src/lib/api/models.test.ts @@ -0,0 +1,69 @@ +/** + * bun:test suite for the models surface (models.ts). Mirrors config.test.ts + * (U01) style — describe/test, imports "./x.js" — and injects a fake fetch + * via ModelOptions.fetchImpl so no real network / backend process is touched. + */ + +import { expect, test } from "bun:test"; +import { + activateModel, + deactivateModel, + getModel, + listModels, +} from "./models.js"; +import { ApiError, type FetchLike } from "./http.js"; +import type { ModelStatus } from "./types.js"; + +function jsonBody( + body: unknown, + status = 200, + headers: Record = { "content-type": "application/json" }, +): FetchLike { + return async () => + new Response(JSON.stringify(body), { status, headers }); +} + +const opts = { origin: "http://test", } as const; + +test("listModels() GETs /api/models and returns the registry rows", async () => { + const rows: ModelStatus[] = [ + { id: "llama-3.1-8b", state: "active", pid: 44, port: 11180 }, + { id: "qwen2.5-coder", state: "disabled" }, + ]; + const result = await listModels({ fetchImpl: jsonBody(rows), ...opts }); + expect(result).toEqual(rows); +}); + +test("getModel() fetches /api/models/:id/status", async () => { + const row: ModelStatus = { id: "llama-3.1-8b", state: "active", pid: 44, port: 11180 }; + const result = await getModel("llama-3.1-8b", { fetchImpl: jsonBody(row), ...opts }); + expect(result).toEqual(row); +}); + +test("activateModel() POSTs /activate and returns the pid/port", async () => { + const result = await activateModel("llama-3.1-8b", { + fetchImpl: jsonBody({ state: "active", pid: 44, port: 11180 }), + ...opts, + }); + expect(result).toEqual({ state: "active", pid: 44, port: 11180 }); +}); + +test("deactivateModel() POSTs /deactivate and returns {state:disabled}", async () => { + const result = await deactivateModel("llama-3.1-8b", { + fetchImpl: jsonBody({ state: "disabled" }), + ...opts, + }); + expect(result).toEqual({ state: "disabled" }); +}); + +test("returns backend error envelope via ApiError on 404", async () => { + const caught = await getModel("missing", { + fetchImpl: jsonBody({ error: "model_not_found" }, 404), + ...opts, + }).catch((err: unknown) => err); ; + + expect(caught).toBeInstanceOf(ApiError); + expect((caught as ApiError).status).toBe(404); + expect((caught as ApiError).code).toBe("model_not_found"); + expect((caught as ApiError).message).toBe("model_not_found"); +}); diff --git a/frontend/src/lib/api/models.ts b/frontend/src/lib/api/models.ts new file mode 100644 index 0000000..c9a0164 --- /dev/null +++ b/frontend/src/lib/api/models.ts @@ -0,0 +1,60 @@ +/** + * Typed client for the model-management surface (auth-gated on the backend + * through the optional `auth` gate — 401 {error:"unauthorized"} when the + * console has no valid bearer token). + * + * Real backend surface (src/routes/api.ts, models branch, hub-gated): + * GET /api/models → ModelStatus[] (registry snapshot) + * GET /api/models/:id/status → ModelStatus | 404 model_not_found + * POST /api/models/:id/activate → {state,pid,port} | 400/404/503 + * POST /api/models/:id/deactivate → {state:"disabled"} | 404 + */ + +import { request, type FetchLike } from "./http.js"; +import type { + ActivateModelResponse, + DeactivateModelResponse, + ModelStatus, +} from "./types.js"; + +export interface ModelOptions { + origin?: string; + fetchImpl?: FetchLike; + signal?: AbortSignal; + timeoutMs?: number; +} + +/** GET /api/models → full registry snapshot. */ +export async function listModels(options: ModelOptions = {}): Promise { + return request("/api/models", { method: "GET", ...options }); +} + +/** GET /api/models/:id/status → one row; throws ApiError(404, "model_not_found"). */ +export async function getModel(id: string, options: ModelOptions = {}): Promise { + return request(`/api/models/${encodeURIComponent(id)}/status`, { + method: "GET", + ...options, + }); +} + +/** POST /api/models/:id/activate → {state:"active",pid,port}. */ +export async function activateModel( + id: string, + options: ModelOptions = {}, +): Promise { + return request(`/api/models/${encodeURIComponent(id)}/activate`, { + method: "POST", + ...options, + }); +} + +/** POST /api/models/:id/deactivate → {state:"disabled"}. */ +export async function deactivateModel( + id: string, + options: ModelOptions = {}, +): Promise { + return request(`/api/models/${encodeURIComponent(id)}/deactivate`, { + method: "POST", + ...options, + }); +} diff --git a/frontend/src/lib/api/runtime.test.ts b/frontend/src/lib/api/runtime.test.ts new file mode 100644 index 0000000..605e67c --- /dev/null +++ b/frontend/src/lib/api/runtime.test.ts @@ -0,0 +1,67 @@ +/** + * bun:test suite for the runtime status probe (runtime.ts). Mirrors + * config.test.ts (U01) style — describe/test, imports "./x.js" — and injects + * fake fetch implementations so no real network/backend is touched. + * + * runtimeStatus() is deliberately non-throwing; its two key behaviours: + * - transport failure ⇒ { reachable:false, authEnabled:false }; + * - reachable + 401 on the models branch ⇒ authEnabled:true. + */ + +import { expect, test } from "bun:test"; +import { runtimeStatus } from "./runtime.js"; +import type { FetchLike } from "./http.js"; + +function jsonBody( + body: unknown, + status = 200, +): FetchLike { + return async () => new Response(JSON.stringify(body), { status }); +} + +function failingFetch(): FetchLike { + return async () => { + throw new TypeError("fetch failed"); + }; +} + +const opts = { origin: "http://test" } as const; + +test("returns reachable=true plus health when the backend answers", async () => { + const result = await runtimeStatus({ + ...opts, + fetchImpl: jsonBody({ status: "ok" }), + }); + expect(result.reachable).toBe(true); + expect(result.health).toEqual({ status: "ok" }); + expect(result.authEnabled).toBe(false); +}); + +test("sets authEnabled=true when the models branch is 401-gated", async () => { + // Real backend behaviour: /api/health is open (200), only /api/models + // is auth-gated (401) — so the probe must route by URL. + const routedFetch: FetchLike = async (url: string | URL | Request) => { + const path = String(url); + if (path.endsWith("/api/health")) { + return new Response(JSON.stringify({ status: "ok" }), { status: 200 }); + } + return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 }); + }; + + const result = await runtimeStatus({ + ...opts, + fetchImpl: routedFetch, + }); + expect(result.reachable).toBe(true); + expect(result.authEnabled).toBe(true); +}); + +test("returns reachable=false on transport failure, never throwing", async () => { + const result = await runtimeStatus({ + ...opts, + fetchImpl: failingFetch(), + }); + expect(result.reachable).toBe(false); + expect(result.authEnabled).toBe(false); + expect(result.health).toBeUndefined(); +}); diff --git a/frontend/src/lib/api/runtime.ts b/frontend/src/lib/api/runtime.ts new file mode 100644 index 0000000..78574b1 --- /dev/null +++ b/frontend/src/lib/api/runtime.ts @@ -0,0 +1,58 @@ +/** + * Combined runtime status probe — the "is the console actually usable right + * now?" affordance the status bar polls. + * + * Real backend surface (NO auth, like the whole /api surface): + * GET /api/health → HealthStatus (liveness; localModels? when hub wired) + * GET /api/models → ModelStatus[] (registry snapshot) + * + * runtimeStatus() is deliberately **non-throwing**: it runs the liveness probe + * and, when reachable, a second probe of the models branch. A 401 (no bearer + * token configured) is not a failure — it means `authEnabled: true` and the + * console should prompt for a token. Transport failure ⇒ reachable:false. + */ + +import { getHealth } from "./health.js"; +import { request, type FetchLike } from "./http.js"; +import { ApiError } from "./http.js"; +import type { HealthStatus } from "./types.js"; + +export interface RuntimeStatus { + /** Liveness probe answered (any HTTP status, incl. 401). */ + reachable: boolean; + /** GET /api/health body, present when reachable. */ + health?: HealthStatus; + /** True when a 401 gate guards the /api/models branch (token required). */ + authEnabled: boolean; +} + +/** Forwarded transport options (DI seam: origin/fetch/signal/timeout). */ +export interface RuntimeStatusOptions { + origin?: string; + fetchImpl?: FetchLike; + signal?: AbortSignal; + timeoutMs?: number; +} + +/** + * Probe the runtime: health first, then (when reachable) the auth gate via + * the models branch. Never throws — always resolves to a status object. + */ +export async function runtimeStatus(options: RuntimeStatusOptions = {}): Promise { + let health: HealthStatus | undefined; + try { + health = await getHealth(options); + } catch { + return { reachable: false, authEnabled: false }; + } + + let authEnabled = false; + try { + await request("/api/models", { method: "GET", ...options }); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + authEnabled = true; + } + } + return { reachable: true, health, authEnabled }; +} diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts new file mode 100644 index 0000000..fb3caaf --- /dev/null +++ b/frontend/src/lib/api/types.ts @@ -0,0 +1,83 @@ +/** + * Shared wire types for the console API, mirroring the REAL backend shapes + * (verbatim field-by-field from src/backend/hub.ts, src/orchestrator/store.ts + * and src/routes/api.ts). No invented fields — this layer types exactly the + * /api surface the server serves. + */ + +/** GET /api/health → backend health (liveness; no auth). */ +export interface HealthStatus { + status: "ok"; + /** ids of locally-managed models, only when a hub is wired. */ + localModels?: string[]; +} + +/** GET /api/models / GET /api/models/:id (hub registry; auth-gated). */ +export interface ModelStatus { + id: string; + state: "active" | "disabled" | "error"; + pid?: number; + port?: number; + error?: string; +} + +/** POST /api/models/:id/activate → activation affordance. */ +export interface ActivateModelResponse { + state: "active"; + pid: number; + port: number; +} + +/** POST /api/models/:id/deactivate → {state:"disabled"}. */ +export interface DeactivateModelResponse { + state: "disabled"; +} + +/** GET /api/workflows → workflow metadata rows. */ +export interface WorkflowRecord { + name: string; + version: number; + updatedAt: string; +} + +/** GET /api/workflows/:name → record + canonical YAML. */ +export interface WorkflowWithYaml extends WorkflowRecord { + yaml: string; +} + +/** PUT /api/workflows/:name → accepted envelope. */ +export interface SaveWorkflowResult { + ok: true; + name: string; + version: number; +} + +/** POST /api/workflows/:name/run → the runner's completion output. */ +export interface RunWorkflowResult { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: Array<{ + index: number; + message: { role: "assistant"; content: string }; + finish_reason: string; + }>; + usage: null; +} + +/** GET /api/workflows/:name/logs → execution history rows. */ +export interface ExecutionLogRow { + id: string; + workflowId: string; + status: "ok" | "error"; + error: string | null; + startedAt: string; + ms: number | null; +} + +/** Backend error envelope → thrown as ApiError(status, message, errors?). */ +export interface ApiErrorEnvelope { + error: string; + errors?: unknown; +} diff --git a/frontend/src/lib/api/v1.test.ts b/frontend/src/lib/api/v1.test.ts new file mode 100644 index 0000000..6cc9d2e --- /dev/null +++ b/frontend/src/lib/api/v1.test.ts @@ -0,0 +1,363 @@ +/** + * bun:test suite for the /v1 client (v1.ts, U11). Drives both wire paths + * with an injected fake fetch — JSON completion, OpenAI-wire SSE streams, + * the 401 Bearer envelope, the 404 unknown-model envelope, stream split + * across chunk boundaries, truncation, and mid-stream abort. + * + * Mirrors the config.test.ts / http.test.ts conventions: bun:test, + * describe/test, imports with explicit .js extensions, no real network. + */ + +import { describe, expect, test } from "bun:test"; +import { + chatCompletion, + chatCompletionStream, + splitSSEFrames, + v1ErrorFrom, +} from "./v1.js"; +import type { ChatStreamHandlers, V1ChatRequest, V1Completion, V1StreamEvent } from "./v1.js"; +import { ApiError, type FetchLike } from "./http.js"; + +const encoder = new TextEncoder(); + +const REQUEST: V1ChatRequest = { + model: "gateway/summary", + messages: [{ role: "user", content: "hi" }], + stream: false, +}; + +function jsonResponse( + body: unknown, + status = 200, + headers: Record = { "content-type": "application/json" }, +): FetchLike { + return async () => new Response(JSON.stringify(body), { status, headers }); +} + +/** Fake fetch returning an SSE body built from raw text chunks. */ +function sseResponse(chunks: string[]): FetchLike { + const stream = new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)); + controller.close(); + }, + }); + return async () => + new Response(stream, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +/** Capture the RequestInit a fetch impl receives (header/body assertions). */ +function captureFetch(): { fetchImpl: FetchLike; calls: Array<{ url: string; init: RequestInit }> } { + const calls: Array<{ url: string; init: RequestInit }> = []; + const fetchImpl: FetchLike = async (url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), init: init ?? {} }); + return jsonResponse({ ok: true })("http://test"); + }; + return { fetchImpl, calls }; +} + +async function collectStream( + body: V1ChatRequest, + handlers: ChatStreamHandlers, + fetchImpl: FetchLike, +): Promise { + const events: V1StreamEvent[] = []; + for await (const event of chatCompletionStream(body, handlers, { + origin: "http://test", + fetchImpl, + })) { + events.push(event); + } + return events; +} + +const COMPLETION: V1Completion = { + id: "cmpl-1", + object: "chat.completion", + created: 1710000000, + model: "gateway/summary", + choices: [ + { index: 0, message: { role: "assistant", content: "Hello!" }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 }, +}; + +describe("splitSSEFrames()", () => { + test("splits one frame per chunk and keeps an unterminated tail", () => { + const state = { rest: "" }; + expect(splitSSEFrames(state, "data: a\n\n")).toEqual(["data: a"]); + expect(state.rest).toBe(""); + expect(splitSSEFrames(state, "data: b")).toEqual([]); + expect(state.rest).toBe("data: b"); + expect(splitSSEFrames(state, "\n\ndata: c\n\n")).toEqual(["data: b", "data: c"]); + expect(state.rest).toBe(""); + }); + + test("completes a frame split across ANY chunk boundary, LF or CRLF", () => { + const state = { rest: "" }; + // LF separator, separator itself split across chunks + expect(splitSSEFrames(state, "data: o")).toEqual([]); + expect(splitSSEFrames(state, "ne\n")).toEqual([]); + expect(splitSSEFrames(state, "\n")).toEqual(["data: one"]); + // CRLF separator + expect(splitSSEFrames(state, "data: two\r\n\r")).toEqual([]); + expect(splitSSEFrames(state, "\n")).toEqual(["data: two"]); + expect(state.rest).toBe(""); + }); + + test("handles multiple complete frames in one chunk", () => { + const state = { rest: "" }; + expect(splitSSEFrames(state, "data: 1\n\ndata: 2\n\ndata: 3\n\n")).toEqual([ + "data: 1", + "data: 2", + "data: 3", + ]); + }); +}); + +describe("v1ErrorFrom()", () => { + test("extracts the OpenAI envelope verbatim", () => { + const err = v1ErrorFrom( + 404, + JSON.stringify({ + error: { message: "The model 'nope' does not exist", type: "invalid_request_error", param: null, code: "model_not_found" }, + }), + ); + expect(err).toBeInstanceOf(ApiError); + expect(err.status).toBe(404); + expect(err.message).toBe("The model 'nope' does not exist"); + expect(err.code).toBe("model_not_found"); + expect(err.errors).toEqual({ + error: { message: "The model 'nope' does not exist", type: "invalid_request_error", param: null, code: "model_not_found" }, + }); + }); + + test("keeps non-JSON bodies as raw text verbatim", () => { + const err = v1ErrorFrom(502, "upstream exploded"); + expect(err.status).toBe(502); + expect(err.message).toBe("http_502"); + expect(err.errors).toBe("upstream exploded"); + }); +}); + +describe("chatCompletion()", () => { + test("parses a JSON completion body", async () => { + const result = await chatCompletion(REQUEST, { + origin: "http://test", + fetchImpl: jsonResponse(COMPLETION), + }); + expect(result).toEqual(COMPLETION); + }); + + test("posts the typed body to /v1/chat/completions with stream:false and NO Authorization header", async () => { + const { fetchImpl, calls } = captureFetch(); + await chatCompletion( + { ...REQUEST, temperature: 0.4 }, + { origin: "http://test", fetchImpl }, + ); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe("http://test/v1/chat/completions"); + const init = calls[0].init; + expect(init.method).toBe("POST"); + const headers = new Headers(init.headers as HeadersInit); + expect(headers.get("authorization")).toBeNull(); + expect(headers.get("content-type")).toBe("application/json"); + const sent = JSON.parse(String(init.body)) as Record; + expect(sent.model).toBe("gateway/summary"); + expect(sent.stream).toBe(false); + expect(sent.temperature).toBe(0.4); + }); + + test("omits temperature when not provided", async () => { + const { fetchImpl, calls } = captureFetch(); + await chatCompletion(REQUEST, { origin: "http://test", fetchImpl }); + const sent = JSON.parse(String(calls[0].init.body)) as Record; + expect("temperature" in sent).toBe(false); + }); + + test("throws ApiError with the 401 Bearer envelope verbatim", async () => { + const err = await chatCompletion(REQUEST, { + origin: "http://test", + fetchImpl: jsonResponse( + { error: { message: "Unauthorized", type: "authentication_error", param: null, code: null } }, + 401, + ), + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + const apiError = err as ApiError; + expect(apiError.status).toBe(401); + expect(apiError.message).toBe("Unauthorized"); + expect(apiError.errors).toMatchObject({ error: { message: "Unauthorized" } }); + }); + + test("throws ApiError with the unknown-model 404 envelope verbatim", async () => { + const err = await chatCompletion( + { ...REQUEST, model: "qwen-local" }, + { + origin: "http://test", + fetchImpl: jsonResponse( + { error: { message: "The model 'qwen-local' does not exist", type: "invalid_request_error", param: null, code: "model_not_found" } }, + 404, + ), + }, + ).catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + const apiError = err as ApiError; + expect(apiError.status).toBe(404); + expect(apiError.message).toBe("The model 'qwen-local' does not exist"); + expect(apiError.code).toBe("model_not_found"); + }); + + test("rejects as aborted when the signal fires before the request", async () => { + const controller = new AbortController(); + controller.abort(); + // Realistic browser fetch: an already-aborted signal rejects immediately. + const signalAwareFetch: FetchLike = async (_url: string | URL | Request, init?: RequestInit) => { + if (init?.signal?.aborted === true) { + throw new DOMException("The operation was aborted.", "AbortError"); + } + return new Response("", { status: 200 }); + }; + const err = await chatCompletion(REQUEST, { + origin: "http://test", + fetchImpl: signalAwareFetch, + signal: controller.signal, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).code).toBe("aborted"); + }); +}); + +describe("chatCompletionStream()", () => { + const CHUNK_1 = JSON.stringify({ + id: "cmpl-1", + object: "chat.completion.chunk", + created: 1710000000, + model: "gateway/summary", + choices: [{ index: 0, delta: { role: "assistant", content: "Hello" }, finish_reason: null }], + }); + const CHUNK_2 = JSON.stringify({ + id: "cmpl-1", + object: "chat.completion.chunk", + created: 1710000000, + model: "gateway/summary", + choices: [{ index: 0, delta: { content: "!" }, finish_reason: "stop" }], + }); + + test("yields parsed frames, then done, with raw text and rolled-up content", async () => { + const rawParts: string[] = []; + let summary: unknown = null; + const events = await collectStream( + { ...REQUEST, stream: true }, + { + onRaw: (chunk) => rawParts.push(chunk), + onDone: (s) => { + summary = s; + }, + }, + sseResponse([`data: ${CHUNK_1}\n\n`, `data: ${CHUNK_2}\n\n`, "data: [DONE]\n\n"]), + ); + expect(events.map((e) => e.type)).toEqual(["frame", "frame", "done"]); + expect(rawParts.join("")).toBe(`data: ${CHUNK_1}\n\ndata: ${CHUNK_2}\n\ndata: [DONE]\n\n`); + const first = events[0]; + if (first.type !== "frame") throw new Error("expected first frame"); + expect(first.payload.choices[0].delta.content).toBe("Hello"); + expect(summary).toEqual({ + done: true, + raw: `data: ${CHUNK_1}\n\ndata: ${CHUNK_2}\n\ndata: [DONE]\n\n`, + content: "Hello!", + frames: 2, + }); + }); + + test("parses frames split across chunk boundaries (mid-JSON cut)", async () => { + const events = await collectStream( + { ...REQUEST, stream: true }, + { onRaw: () => {} }, + sseResponse([`data: ${CHUNK_1.slice(0, 24)}`, `${CHUNK_1.slice(24)}\n\ndata: [DONE]\n\n`]), + ); + expect(events.map((e) => e.type)).toEqual(["frame", "done"]); + }); + + test("throws ApiError(404) with the envelope verbatim, before any event", async () => { + const events: V1StreamEvent[] = []; + let caught: unknown = null; + try { + for await (const event of chatCompletionStream( + { ...REQUEST, stream: true }, + { onRaw: () => {} }, + { + origin: "http://test", + fetchImpl: jsonResponse( + { error: { message: "The model 'x' does not exist", type: "invalid_request_error", param: null, code: "model_not_found" } }, + 404, + ), + }, + )) { + events.push(event); + } + } catch (err) { + caught = err; + } + expect(events).toEqual([]); + expect(caught).toBeInstanceOf(ApiError); + expect((caught as ApiError).status).toBe(404); + expect((caught as ApiError).message).toBe("The model 'x' does not exist"); + }); + + test("yields truncated when the body ends without the terminal [DONE]", async () => { + let summary: unknown = null; + const events = await collectStream( + { ...REQUEST, stream: true }, + { + onRaw: () => {}, + onDone: (s) => { + summary = s; + }, + }, + sseResponse([`data: ${CHUNK_1}\n\n`]), + ); + expect(events.map((e) => e.type)).toEqual(["frame", "truncated"]); + expect(summary).toMatchObject({ done: false, frames: 1 }); + }); + + test("yields aborted mid-stream and skips onDone", async () => { + const controller = new AbortController(); + const pendingRead = new Promise((_resolve, reject) => { + controller.signal.addEventListener("abort", () => { + reject(new DOMException("The operation was aborted.", "AbortError")); + }, { once: true }); + }); + pendingRead.catch(() => {}); + const stream = new ReadableStream({ + start(streamController) { + streamController.enqueue(encoder.encode(`data: ${CHUNK_1}\n\n`)); + }, + async pull() { + await pendingRead; + }, + }); + const fetchImpl: FetchLike = async () => + new Response(stream, { headers: { "content-type": "text/event-stream" } }); + + let doneCalled = false; + const events: V1StreamEvent[] = []; + const iterator = chatCompletionStream( + { ...REQUEST, stream: true }, + { + onRaw: () => {}, + onDone: () => { + doneCalled = true; + }, + signal: controller.signal, + }, + { origin: "http://test", fetchImpl }, + ); + for await (const event of iterator) { + events.push(event); + if (event.type === "frame") controller.abort(); + } + expect(events.map((e) => e.type)).toEqual(["frame", "aborted"]); + expect(doneCalled).toBe(false); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/api/v1.ts b/frontend/src/lib/api/v1.ts new file mode 100644 index 0000000..4535a5b --- /dev/null +++ b/frontend/src/lib/api/v1.ts @@ -0,0 +1,401 @@ +/** + * Typed client for the OpenAI-compatible /v1 surface (real backend: + * src/routes/v1.ts). The console exercises exactly the wire paths that + * exist server-side: + * + * POST /v1/chat/completions — JSON completion when `stream: false`; + * the same endpoint with `stream: true` returns OpenAI-wire SSE: one + * `data: {JSON}\n\n` frame per chunk, terminated by EXACTLY one + * `data: [DONE]\n\n` (src/routes/relay.ts). Client disconnect makes the + * server abort the upstream call (v1.ts streamResponse). + * + * Error mapping keeps the backend envelope VERBATIM. /v1 errors use the + * OpenAI shape `{error:{message,type,param,code}}` — 401 from the Bearer + * gate (src/routes/v1.ts auth branch), 404 model_not_found, 4xx/5xx from + * the provider gate. They are thrown as ApiError with the parsed envelope + * carried on `errors`, so the UI renders what the server actually said. + * + * This client NEVER sets an Authorization header. Auth lives server-side + * (WEAVELLM_AUTH gate, src/routes/auth.ts); if the server demands a Bearer + * key it answers 401 and the playground shows that envelope verbatim. + * + * No Svelte/Astro imports — importable under bun:test with an injected + * fetch and/or origin. + */ + +import { getApiOrigin } from "./config.js"; +import { ApiError, type FetchLike } from "./http.js"; +import { dataField, decodeTokenData } from "../sse.js"; + +export type V1Role = "system" | "user" | "assistant"; + +export interface V1ChatMessage { + role: V1Role; + content: string; +} + +/** Request body sent to POST /v1/chat/completions. */ +export interface V1ChatRequest { + model: string; + messages: V1ChatMessage[]; + temperature?: number; + stream: boolean; +} + +/** usage on the completed wire body — verbatim, only when the server sent it. */ +export interface V1Usage { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; +} + +export interface V1CompletionChoice { + index: number; + message: { role: string; content: string }; + finish_reason: string | null; +} + +/** Non-stream completion body (object: "chat.completion"). */ +export interface V1Completion { + id: string; + object: "chat.completion"; + created: number; + model: string; + choices: V1CompletionChoice[]; + usage: V1Usage | null; +} + +/** + * One parsed SSE data payload on the streaming wire. External adapters can + * add extra fields (usage in the final chunk, tool calls, ...) — the index + * signatures keep the frame VERBATIM; the UI renders the raw wire text and + * never fabricates fields from these. + */ +export interface V1ChatChunk { + id: string; + object: "chat.completion.chunk"; + created: number; + model: string; + choices: Array<{ + index: number; + delta: { role?: string; content?: string; [field: string]: unknown }; + finish_reason?: string | null; + }>; + [field: string]: unknown; +} + +/** Events yielded by `chatCompletionStream`, in wire order. */ +export type V1StreamEvent = + | { readonly type: "frame"; readonly payload: V1ChatChunk } + | { readonly type: "done" } + | { readonly type: "truncated" } + | { readonly type: "aborted" }; + +/** Rolled-up stream summary handed to `onDone`. */ +export interface V1StreamSummary { + /** True when the terminal `data: [DONE]` frame was received. */ + done: boolean; + /** Raw SSE text as received, verbatim (frames + separators). */ + raw: string; + /** Concatenated assistant text via decodeTokenData semantics. */ + content: string; + /** Number of parsed JSON data frames (excludes [DONE]). */ + frames: number; +} + +export interface V1ClientOptions { + /** Override the API origin (defaults to getApiOrigin()). */ + origin?: string; + /** Inject a fetch implementation (tests). Defaults to global fetch. */ + fetchImpl?: FetchLike; + /** Abort the request. */ + signal?: AbortSignal; + /** + * Non-stream timeout in ms (default 60_000 — a local completion can take + * minutes). Streams have NO timeout: they run until [DONE] or abort. + */ + timeoutMs?: number; +} + +/** Incremental SSE frame state — mutated by splitSSEFrames across chunks. */ +export interface SSEFrameState { + rest: string; +} + +/** SSE event separator: blank line between events, LF or CRLF (relay: \n\n). */ +const FRAME_SEPARATOR = /\r?\n\r?\n/; + +/** + * Feed one decoded text chunk into `state`, returning every COMPLETE SSE + * frame it closes (frame body only, separator stripped). An unterminated + * tail stays in `state.rest` until a later chunk completes it. Safe across + * ANY chunk boundary, LF/CRLF separators, and multiple frames per chunk. + */ +export function splitSSEFrames(state: SSEFrameState, chunk: string): string[] { + state.rest += chunk; + const frames: string[] = []; + for (;;) { + const match = FRAME_SEPARATOR.exec(state.rest); + if (match === null) break; + frames.push(state.rest.slice(0, match.index)); + state.rest = state.rest.slice(match.index + match[0].length); + } + return frames; +} + +/** OpenAI /v1 error body: {error:{message,...}} or a bare {error:string}. */ +interface V1ErrorEnvelope { + error: + | { message: string; type?: string | null; param?: unknown; code?: string | null } + | string; +} + +function isV1ErrorEnvelope(value: unknown): value is V1ErrorEnvelope { + if (typeof value !== "object" || value === null) return false; + const error = (value as { error?: unknown }).error; + if (typeof error === "string") return true; + return ( + typeof error === "object" && + error !== null && + typeof (error as { message?: unknown }).message === "string" + ); +} + +/** + * Map a non-2xx /v1 response onto ApiError with the envelope VERBATIM in + * `errors`: the parsed OpenAI body when JSON, the raw text otherwise. + */ +export function v1ErrorFrom(status: number, rawText: string): ApiError { + let envelope: unknown = null; + let message = ""; + let code = ""; + if (rawText.length > 0) { + try { + const parsed: unknown = JSON.parse(rawText) as unknown; + envelope = parsed; + if (isV1ErrorEnvelope(parsed)) { + if (typeof parsed.error === "string") { + message = parsed.error; + } else { + message = parsed.error.message; + // The envelope's own code wins when present (e.g. model_not_found); + // ApiError.code stays a stable discriminator for the UI hints. + if (typeof parsed.error.code === "string" && parsed.error.code.length > 0) { + code = parsed.error.code; + } + } + } + } catch { + envelope = rawText; // non-JSON error body — keep it verbatim + } + } + const fallback = `http_${status}`; + return new ApiError({ + status, + code: code !== "" ? code : message !== "" ? message : fallback, + message: message !== "" ? message : fallback, + errors: envelope, + }); +} + +/** Shared /v1 payload builder — only fields the user actually set. */ +function payloadOf(body: V1ChatRequest): Record { + const payload: Record = { + model: body.model, + messages: body.messages, + stream: body.stream, + }; + if (body.temperature !== undefined) payload.temperature = body.temperature; + return payload; +} + +/** + * One-shot (non-streaming) chat completion. Resolves with the parsed + * `chat.completion` body, or throws ApiError carrying the backend envelope + * verbatim (401 Bearer-required, 404 unknown-model, provider 4xx/5xx). + */ +export async function chatCompletion( + body: V1ChatRequest, + options: V1ClientOptions = {}, +): Promise { + const origin = options.origin ?? getApiOrigin(); + const fetchImpl = options.fetchImpl ?? fetch; + const externalSignal = options.signal; + + const timeoutMs = options.timeoutMs ?? 60_000; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + if (externalSignal !== undefined) { + if (externalSignal.aborted) controller.abort(); + else externalSignal.addEventListener("abort", () => controller.abort(), { once: true }); + } + + let res: Response; + try { + res = await fetchImpl(`${origin}/v1/chat/completions`, { + method: "POST", + headers: { accept: "application/json", "content-type": "application/json" }, + body: JSON.stringify(payloadOf(body)), + signal: controller.signal, + }); + } catch (err) { + clearTimeout(timer); + if (externalSignal?.aborted === true) { + throw new ApiError({ status: 0, code: "aborted", message: "request aborted" }); + } + if (controller.signal.aborted) { + throw new ApiError({ status: 0, code: "request_timeout", message: "request_timeout" }); + } + throw new ApiError({ + status: 0, + code: "network_error", + message: (err as Error)?.message ?? "network_error", + }); + } + clearTimeout(timer); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw v1ErrorFrom(res.status, text); + } + const text = await res.text(); + if (text.length === 0) { + throw new ApiError({ status: 0, code: "invalid_json", message: "empty response body" }); + } + try { + return JSON.parse(text) as V1Completion; + } catch { + throw new ApiError({ status: 0, code: "invalid_json", message: "response body is not JSON" }); + } +} + +export interface ChatStreamHandlers { + /** Every raw decoded text chunk of the SSE body, verbatim. */ + onRaw: (chunk: string) => void; + /** + * Terminal summary, called exactly once after [DONE] or on truncation. + * Never called on abort — the consumer observes the `aborted` event. + */ + onDone?: (summary: V1StreamSummary) => void; + /** Aborts the fetch and closes the reader; the server aborts upstream. */ + signal?: AbortSignal; +} + +function streamSummary(raw: string, frames: number, done: boolean): V1StreamSummary { + return { done, raw, frames, content: decodeTokenData(raw).content }; +} + +/** + * Streaming chat completion. Async generator over wire events in order + * (`frame` → ... → `done`; `truncated` when the body ended without the + * terminal [DONE]; `aborted` when `signal` fired). Raw text goes to + * `handlers.onRaw` as it arrives so the UI renders the live transcript; + * JSON frames are yielded parsed; the rolled-up content reuses + * decodeTokenData semantics on the aggregated buffer. + * + * Non-2xx responses throw ApiError with the envelope verbatim BEFORE any + * event is yielded. Aborting the signal makes the generator yield `aborted` + * and close the reader — the browser closes the socket, so the server's + * relay unwinds and aborts the upstream call (v1.ts contract). + */ +export async function* chatCompletionStream( + body: V1ChatRequest, + handlers: ChatStreamHandlers, + options: V1ClientOptions = {}, +): AsyncGenerator { + const origin = options.origin ?? getApiOrigin(); + const fetchImpl = options.fetchImpl ?? fetch; + const signal = handlers.signal; + + let res: Response; + try { + res = await fetchImpl(`${origin}/v1/chat/completions`, { + method: "POST", + headers: { accept: "text/event-stream", "content-type": "application/json" }, + body: JSON.stringify(payloadOf(body)), + signal, + }); + } catch (err) { + if (signal?.aborted === true) { + yield { type: "aborted" }; + return; + } + throw new ApiError({ + status: 0, + code: "network_error", + message: (err as Error)?.message ?? "network_error", + }); + } + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw v1ErrorFrom(res.status, text); + } + if (res.body === null) { + throw new ApiError({ status: 0, code: "network_error", message: "empty stream body" }); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const state: SSEFrameState = { rest: "" }; + let readError: unknown = null; + let raw = ""; + let frames = 0; + let finished = false; + + try { + readerLoop: for (;;) { + // read() never rejects here: an errored read resolves done and sets + // readError, so abandoning the generator mid-read can never leave an + // unhandled rejection behind. + const result = await reader.read().catch((err: unknown): ReadableStreamReadResult => { + readError = err; + return { done: true, value: new Uint8Array(0) }; + }); + if (result.done) break; + + const text = decoder.decode(result.value, { stream: true }); + raw += text; + handlers.onRaw(text); + + for (const frame of splitSSEFrames(state, text)) { + const payloadText = dataField(frame); + if (payloadText === null) continue; + if (payloadText === "[DONE]") { + finished = true; + yield { type: "done" }; + break readerLoop; + } + try { + frames += 1; + yield { type: "frame", payload: JSON.parse(payloadText) as V1ChatChunk }; + } catch { + // Non-JSON data frame — kept in the raw transcript, not thrown. + } + } + } + } finally { + try { + reader.releaseLock(); + } catch { + // A read is still pending (consumer abandoned the generator without + // aborting): cancelling the body makes the server see the disconnect. + void reader.cancel().catch(() => {}); + } + } + + if (readError !== null) { + if (signal?.aborted === true) { + yield { type: "aborted" }; + return; + } + throw new ApiError({ + status: 0, + code: "network_error", + message: (readError as Error)?.message ?? "stream read failed", + }); + } + + if (!finished) yield { type: "truncated" }; + handlers.onDone?.(streamSummary(raw, frames, finished)); +} \ No newline at end of file diff --git a/frontend/src/lib/api/workflows.test.ts b/frontend/src/lib/api/workflows.test.ts new file mode 100644 index 0000000..0fa173d --- /dev/null +++ b/frontend/src/lib/api/workflows.test.ts @@ -0,0 +1,105 @@ +/** + * bun:test suite for the workflows surface (workflows.ts). Mirrors + * config.test.ts (U01) style — describe/test, imports "./x.js" — and injects + * a fake fetch via WorkflowOptions.fetchImpl, so no real network/backend. + */ + +import { describe, expect, test } from "bun:test"; +import { + deleteWorkflow, + getWorkflow, + listWorkflows, + runWorkflow, + saveWorkflow, + workflowLogs, +} from "./workflows.js"; +import type { ExecutionLogRow, WorkflowWithYaml } from "./types.js"; +import type { FetchLike } from "./http.js"; + +function jsonBody( + body: unknown, + status = 200, + headers: Record = { "content-type": "application/json" }, +): FetchLike { + return async () => + new Response(JSON.stringify(body), { status, headers }); +} + +describe("workflows surface", () => { + test("listWorkflows() returns metadata rows", async () => { + const rows = [{ name: "summary", version: 3, updatedAt: "2024-01-01T00:00:00Z" }]; + const result = await listWorkflows({ + origin: "http://test", + fetchImpl: jsonBody(rows), + }); + expect(result).toEqual(rows); + }); + + test("getWorkflow() returns the record with yaml", async () => { + const row: WorkflowWithYaml = { + name: "summary", + version: 3, + updatedAt: "2024-01-01T00:00:00Z", + yaml: "name: summary\n", + }; + const result = await getWorkflow("summary", { + origin: "http://test", + fetchImpl: jsonBody(row), + }); + expect(result.yaml).toContain("name: summary"); + }); + + test("saveWorkflow() PUTs raw YAML text and returns the envelope", async () => { + const yaml = "name: summary\nversion: 1\n"; + const result = await saveWorkflow("summary", yaml, { + origin: "http://test", + fetchImpl: jsonBody({ ok: true, name: "summary", version: 4 }), + }); + expect(result).toEqual({ ok: true, name: "summary", version: 4 }); + }); + + test("deleteWorkflow() resolves on 204", async () => { + const result = await deleteWorkflow("summary", { + origin: "http://test", + fetchImpl: jsonBody(undefined, 204), + }); + expect(result).toBeUndefined(); + }); + + test("runWorkflow() POSTs messages and returns the completion", async () => { + const result = await runWorkflow( + "summary", + [{ role: "user", content: "hi" }], + { + origin: "http://test", + fetchImpl: jsonBody({ + id: "cmpl-1", + object: "chat.completion", + created: 1, + model: "gateway/summary", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello!" }, + finish_reason: "stop", + }, + ], + usage: null, + }), + }, + ); + expect(result.model).toBe("gateway/summary"); + expect(result.choices[0].message.content).toBe("Hello!"); + }); + + test("workflowLogs() returns execution history rows", async () => { + const rows: ExecutionLogRow[] = [ + { id: "e1", workflowId: "summary", status: "ok", error: null, startedAt: "2024-01-01T00:00:00Z", ms: 12 }, + ]; + const result = await workflowLogs("summary", { + origin: "http://test", + fetchImpl: jsonBody(rows), + }); + expect(result).toEqual(rows); + }); +}); diff --git a/frontend/src/lib/api/workflows.ts b/frontend/src/lib/api/workflows.ts new file mode 100644 index 0000000..b0398d7 --- /dev/null +++ b/frontend/src/lib/api/workflows.ts @@ -0,0 +1,87 @@ +/** + * Typed client for the workflow CRUD + run + logs surface (NO auth gate — + * the workflows branch is intentionally open on the backend). + * + * Real backend surface (src/routes/api.ts, workflows branch): + * GET /api/workflows → WorkflowRecord[] + * GET /api/workflows/:name → WorkflowWithYaml | 404 not_found + * PUT /api/workflows/:name → YAML text body → SaveWorkflowResult | 400 + * DELETE /api/workflows/:name → 204 | 404 + * POST /api/workflows/:name/run → OpenAI chat body → ChainCompletion | 400/404/502 + * GET /api/workflows/:name/logs → ExecutionLogRow[] | 404 + * + * The PUT body is RAW YAML TEXT (not JSON) — `saveWorkflow` sends YAML via + * the http layer's rawBody path, exactly like the backend reads req.text(). + */ + +import { request, type FetchLike } from "./http.js"; +import type { + ExecutionLogRow, + RunWorkflowResult, + SaveWorkflowResult, + WorkflowRecord, + WorkflowWithYaml, +} from "./types.js"; + +export interface WorkflowOptions { + origin?: string; + fetchImpl?: FetchLike; + signal?: AbortSignal; + timeoutMs?: number; +} + +/** GET /api/workflows → metadata rows. */ +export async function listWorkflows(options: WorkflowOptions = {}): Promise { + return request("/api/workflows", { method: "GET", ...options }); +} + +/** GET /api/workflows/:name → {name,version,updatedAt,yaml} | throws 404. */ +export async function getWorkflow(name: string, options: WorkflowOptions = {}): Promise { + return request(`/api/workflows/${encodeURIComponent(name)}`, { + method: "GET", + ...options, + }); +} + +/** PUT /api/workflows/:name — raw YAML text body → {ok,name,version}. */ +export async function saveWorkflow( + name: string, + yaml: string, + options: WorkflowOptions = {}, +): Promise { + return request(`/api/workflows/${encodeURIComponent(name)}`, { + method: "PUT", + rawBody: yaml, + rawBodyType: "application/x-yaml", + ...options, + }); +} + +/** DELETE /api/workflows/:name → 204; throws 404. */ +export async function deleteWorkflow(name: string, options: WorkflowOptions = {}): Promise { + return request(`/api/workflows/${encodeURIComponent(name)}`, { + method: "DELETE", + ...options, + }); +} + +/** POST /api/workflows/:name/run — OpenAI chat messages → completion. */ +export async function runWorkflow( + name: string, + messages: { role: string; content: string }[], + options: WorkflowOptions = {}, +): Promise { + return request(`/api/workflows/${encodeURIComponent(name)}/run`, { + method: "POST", + jsonBody: { messages }, + ...options, + }); +} + +/** GET /api/workflows/:name/logs → execution history rows. */ +export async function workflowLogs(name: string, options: WorkflowOptions = {}): Promise { + return request(`/api/workflows/${encodeURIComponent(name)}/logs`, { + method: "GET", + ...options, + }); +} diff --git a/frontend/src/lib/executions-ui.test.ts b/frontend/src/lib/executions-ui.test.ts new file mode 100644 index 0000000..c2c7d47 --- /dev/null +++ b/frontend/src/lib/executions-ui.test.ts @@ -0,0 +1,155 @@ +/** + * bun:test suite for executions-ui.ts (U10) — pure, deterministic helpers. + * Mirrors the sibling suites (models-ui.test.ts / workflows-ui.test.ts): + * describe/test, imports "./x.js", no network, no Svelte imports. + */ + +import { describe, expect, test } from "bun:test"; +import type { ExecutionLogRow } from "./api/types.js"; +import { + EXECUTION_STATUS_CHIPS, + executionRowView, + latestRun, + summarizeExecutions, +} from "./executions-ui.js"; +import { formatLocalDateTime, formatMs } from "./workflows-ui.js"; + +const OK_ROW: ExecutionLogRow = { + id: "r-ok-1", + workflowId: "demo", + status: "ok", + error: null, + startedAt: "2026-09-20T14:30:00.000Z", + ms: 1240, +}; + +const ERR_ROW: ExecutionLogRow = { + id: "r-err-1", + workflowId: "demo", + status: "error", + error: "llama-server exited with code 1", + startedAt: "2026-09-20T15:00:00.000Z", + ms: null, +}; + +const ERR_EMPTY_MSG: ExecutionLogRow = { + id: "r-err-2", + workflowId: "demo", + status: "error", + error: "", + startedAt: "2026-09-20T15:10:00.000Z", + ms: 500, +}; + +const OK_BAD_DATE: ExecutionLogRow = { + id: "r-ok-2", + workflowId: "demo", + status: "ok", + error: null, + startedAt: "not-a-date", + ms: 12, +}; + +describe("summarizeExecutions", () => { + test("counts ok and error over a mixed array", () => { + expect(summarizeExecutions([OK_ROW, ERR_ROW, OK_ROW])).toEqual({ + total: 3, + ok: 2, + error: 1, + }); + }); + + test("an empty array is all-zero, never invented numbers", () => { + expect(summarizeExecutions([])).toEqual({ total: 0, ok: 0, error: 0 }); + }); + + test("an all-error array counts zero ok", () => { + expect(summarizeExecutions([ERR_ROW, ERR_ROW])).toEqual({ + total: 2, + ok: 0, + error: 2, + }); + }); +}); + +describe("latestRun", () => { + test("empty rows have no latest run", () => { + expect(latestRun([])).toBeNull(); + }); + + test("newest-first input (the backend contract) returns the first row", () => { + const rows = [ERR_ROW, OK_ROW]; + expect(latestRun(rows)).toBe(ERR_ROW); + }); + + test("is defensive against unsorted input — returns the actual newest", () => { + const rows = [OK_ROW, ERR_ROW, OK_BAD_DATE]; + const latest = latestRun(rows); + expect(latest).toBe(ERR_ROW); + }); + + test("invalid timestamps sort last, never as the newest run", () => { + expect(latestRun([OK_BAD_DATE, OK_ROW])).toBe(OK_ROW); + }); + + test("ties keep their input order (stable sort)", () => { + const same = [ + { ...OK_ROW, id: "a" }, + { ...OK_ROW, id: "b" }, + { ...OK_ROW, id: "c" }, + ]; + expect(latestRun(same)?.id).toBe("a"); + }); +}); + +describe("EXECUTION_STATUS_CHIPS", () => { + test("the two real statuses map to stable chips", () => { + expect(EXECUTION_STATUS_CHIPS.ok).toEqual({ tone: "ok", label: "OK" }); + expect(EXECUTION_STATUS_CHIPS.error).toEqual({ tone: "error", label: "Error" }); + }); +}); + +describe("executionRowView", () => { + test("ok rows carry the ok chip, formatted time and duration, no error", () => { + const view = executionRowView(OK_ROW); + expect(view.key).toBe("r-ok-1"); + expect(view.status).toBe("ok"); + expect(view.chip).toEqual(EXECUTION_STATUS_CHIPS.ok); + expect(view.startedAt).toBe(formatLocalDateTime(new Date(OK_ROW.startedAt))); + expect(view.startedAt).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + expect(view.duration).toBe(formatMs(1240)); + expect(view.duration).toBe("1.2s"); + expect(view.error).toBeNull(); + expect(view.errorCell).toBe("—"); + }); + + test("error rows expose the verbatim message and an error chip", () => { + const view = executionRowView(ERR_ROW); + expect(view.status).toBe("error"); + expect(view.chip).toEqual(EXECUTION_STATUS_CHIPS.error); + expect(view.error).toBe("llama-server exited with code 1"); + expect(view.errorCell).toBe("llama-server exited with code 1"); + }); + + test("null ms renders a dash duration", () => { + expect(executionRowView(ERR_ROW).duration).toBe("—"); + }); + + test("an empty error string on an error row renders as no error", () => { + const view = executionRowView(ERR_EMPTY_MSG); + expect(view.error).toBeNull(); + expect(view.errorCell).toBe("—"); + }); + + test("invalid startedAt renders a dash, never NaN", () => { + expect(executionRowView(OK_BAD_DATE).startedAt).toBe("—"); + }); + + test("long error messages truncate with an ellipsis in the cell", () => { + const long = "x".repeat(200); + const view = executionRowView({ ...ERR_ROW, error: long }); + expect(view.errorCell.length).toBe(97); // 96 chars + the ellipsis + expect(view.errorCell.endsWith("…")).toBe(true); + expect(view.error).toBe(long); // the full text survives for titles + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/executions-ui.ts b/frontend/src/lib/executions-ui.ts new file mode 100644 index 0000000..69b85b5 --- /dev/null +++ b/frontend/src/lib/executions-ui.ts @@ -0,0 +1,117 @@ +/** + * Pure display helpers for the executions surface (U10). + * + * Deterministic and unit-tested in executions-ui.test.ts. No IO, no Svelte + * imports: importable under bun:test. Every value derives from the + * ExecutionLogRow[] arrays the backend actually serves (GET + * /api/workflows/:name/logs — src/orchestrator/store.ts selects + * `ORDER BY started_at DESC`, so rows arrive newest first). + * + * Honest-aggregate discipline: there is NO global executions endpoint, so a + * page summary can only sum the per-workflow log rows it actually fetched. + * These helpers count exactly the rows they are given — nothing more, and + * never "all-time" numbers. + */ + +import type { ExecutionLogRow } from "./api/types.js"; +import { errorCell } from "./models-ui.js"; +import { formatLocalDateTime, formatMs } from "./workflows-ui.js"; + +/** The two real log statuses the backend writes (store.ts recordExecution). */ +export type ExecutionStatus = ExecutionLogRow["status"]; + +/** Counts over one workflow's log rows — total and per-status buckets. */ +export interface ExecutionSummary { + total: number; + ok: number; + error: number; +} + +/** + * Count a log array by status. Every bucket derives from the rows verbatim; + * an empty array maps to all-zero counts (the honest "No runs yet" case). + */ +export function summarizeExecutions(rows: readonly ExecutionLogRow[]): ExecutionSummary { + const summary: ExecutionSummary = { total: rows.length, ok: 0, error: 0 }; + for (const row of rows) { + if (row.status === "ok") { + summary.ok += 1; + } else { + summary.error += 1; + } + } + return summary; +} + +/** + * Parse an ISO timestamp; invalid input sorts last so broken rows never + * masquerade as the newest run. + */ +function timestampMs(iso: string): number { + const ms = Date.parse(iso); + return Number.isFinite(ms) ? ms : -Infinity; +} + +/** + * The most recent run: rows sorted by startedAt descending, first element. + * The backend already orders newest-first; the defensive sort keeps the + * helper correct against any input order, with stable ties (equal + * timestamps keep their input order). + */ +export function latestRun(rows: readonly ExecutionLogRow[]): ExecutionLogRow | null { + if (rows.length === 0) return null; + const sorted = [...rows].sort((a, b) => timestampMs(b.startedAt) - timestampMs(a.startedAt)); + return sorted[0] ?? null; +} + +/** Chip mapping per status. `tone` is a StatusDot tone; `label` the chip text. */ +export const EXECUTION_STATUS_CHIPS = { + ok: { tone: "ok", label: "OK" }, + error: { tone: "error", label: "Error" }, +} as const; + +export type ExecutionStatusChip = (typeof EXECUTION_STATUS_CHIPS)[ExecutionStatus]; + +/** Absolute local "YYYY-MM-DD HH:mm"; invalid timestamps render "—". */ +function formatStartedAt(iso: string): string { + const ms = timestampMs(iso); + if (!Number.isFinite(ms)) return "—"; + return formatLocalDateTime(new Date(ms)); +} + +/** How far error text is truncated in the summary line (title keeps the full text). */ +const ERROR_CELL_MAX = 96; + +/** + * Display view for one log row: stable {#each} key, chip mapping, formatted + * timestamps/duration, and the error surface (full + truncated). The caller + * renders exactly this — no row-level formatting logic in the component. + */ +export interface ExecutionRowView { + /** Stable identity for {#each} keys — the backend log row id. */ + key: string; + status: ExecutionStatus; + chip: ExecutionStatusChip; + /** Absolute local "YYYY-MM-DD HH:mm". */ + startedAt: string; + /** "1.2s" / "384ms" / "—". */ + duration: string; + /** Verbatim backend message on error rows, null otherwise. */ + error: string | null; + /** Ellipsis-truncated error text for compact rows. */ + errorCell: string; +} + +export function executionRowView(row: ExecutionLogRow): ExecutionRowView { + const isError = row.status === "error" && row.error !== null && row.error !== ""; + const error: string | null = isError ? row.error : null; + return { + key: row.id, + status: row.status, + chip: EXECUTION_STATUS_CHIPS[row.status], + startedAt: formatStartedAt(row.startedAt), + duration: formatMs(row.ms), + error, + errorCell: errorCell(error ?? undefined, ERROR_CELL_MAX), + }; +} \ No newline at end of file diff --git a/frontend/src/lib/focus-trap.ts b/frontend/src/lib/focus-trap.ts new file mode 100644 index 0000000..ab39572 --- /dev/null +++ b/frontend/src/lib/focus-trap.ts @@ -0,0 +1,56 @@ +/** + * Keyboard focus containment for modal dialogs (WAI-ARIA dialog pattern). + * + * On activation it moves focus to the first interactive element inside + * `container` unless focus already landed there, then traps Tab / Shift+Tab + * within it (cycles at both ends). Returns a cleanup that detaches the key + * listener. + * + * DOM glue (querySelectorAll + focus) — exercised through the console's + * dialog components, not unit-tested (no DOM harness in this repo). + */ + +const FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + '[tabindex]:not([tabindex="-1"])', +].join(","); + +function focusables(container: HTMLElement): HTMLElement[] { + return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (el) => el.getClientRects().length > 0, + ); +} + +export function trapFocus(container: HTMLElement): () => void { + function onKeydown(event: KeyboardEvent): void { + if (event.key !== "Tab") return; + const els = focusables(container); + const first = els.at(0); + const last = els.at(-1); + if (first === undefined || last === undefined) return; + const active = document.activeElement; + const focusInside = active instanceof HTMLElement && container.contains(active); + if (event.shiftKey) { + if (!focusInside || active === first) { + event.preventDefault(); + last.focus(); + } + } else if (!focusInside || active === last) { + event.preventDefault(); + first.focus(); + } + } + + // Move focus in unless it already landed inside (some dialogs focus a + // specific field themselves). + const active = document.activeElement; + const focusInside = active instanceof HTMLElement && container.contains(active); + if (!focusInside) focusables(container).at(0)?.focus(); + + container.addEventListener("keydown", onKeydown); + return () => container.removeEventListener("keydown", onKeydown); +} \ No newline at end of file diff --git a/frontend/src/lib/models-ui.test.ts b/frontend/src/lib/models-ui.test.ts new file mode 100644 index 0000000..9920009 --- /dev/null +++ b/frontend/src/lib/models-ui.test.ts @@ -0,0 +1,143 @@ +/** + * bun:test suite for models-ui.ts (U08) — pure, deterministic helpers. + * Mirrors the sibling suites (overview.test.ts / workflows-ui.test.ts): + * describe/test, imports "./x.js", no network, no Svelte imports. + */ + +import { describe, expect, test } from "bun:test"; +import { + MODEL_STATE_CHIPS, + errorCell, + formatError, + formatPid, + formatPort, + modelRows, + stateChip, + summarizeModels, +} from "./models-ui.js"; +import type { ModelStatus } from "./api/types.js"; + +const ACTIVE: ModelStatus = { id: "zebra-7b", state: "active", pid: 4242, port: 11180 }; +const DISABLED: ModelStatus = { id: "alpha-1b", state: "disabled" }; +const ERROR: ModelStatus = { + id: "mid-3b", + state: "error", + error: "GGUF file not found: '(none)'", +}; + +describe("summarizeModels", () => { + test("counts each state bucket over a mixed registry", () => { + expect(summarizeModels([ACTIVE, DISABLED, ERROR, ACTIVE])).toEqual({ + total: 4, + active: 2, + disabled: 1, + error: 1, + }); + }); + + test("an empty registry is all-zero, never invented numbers", () => { + expect(summarizeModels([])).toEqual({ total: 0, active: 0, disabled: 0, error: 0 }); + }); + + test("error bucket counts only state error", () => { + expect(summarizeModels([ERROR, DISABLED])).toEqual({ + total: 2, + active: 0, + disabled: 1, + error: 1, + }); + }); +}); + +describe("stateChip / MODEL_STATE_CHIPS", () => { + test("the three real states map to stable chips", () => { + expect(stateChip("active")).toEqual(MODEL_STATE_CHIPS.active); + expect(stateChip("disabled")).toEqual(MODEL_STATE_CHIPS.disabled); + expect(stateChip("error")).toEqual(MODEL_STATE_CHIPS.error); + }); + + test("chip contract: ok/idle/error tones with Active/Disabled/Error labels", () => { + expect(MODEL_STATE_CHIPS.active).toEqual({ tone: "ok", label: "Active" }); + expect(MODEL_STATE_CHIPS.disabled).toEqual({ tone: "idle", label: "Disabled" }); + expect(MODEL_STATE_CHIPS.error).toEqual({ tone: "error", label: "Error" }); + }); +}); + +describe("modelRows", () => { + const registry = [ACTIVE, DISABLED, ERROR]; + + test("no hints → every model sorted by id ascending", () => { + expect(modelRows(registry).map((m) => m.id)).toEqual(["alpha-1b", "mid-3b", "zebra-7b"]); + }); + + test("hint ids pin first, in the given order, rest sorted after", () => { + const rows = modelRows(registry, ["zebra-7b", "alpha-1b"]); + expect(rows.map((m) => m.id)).toEqual(["zebra-7b", "alpha-1b", "mid-3b"]); + expect(rows[0]).toEqual(ACTIVE); + expect(rows[1]).toEqual(DISABLED); + }); + + test("duplicate hint ids pin once", () => { + expect(modelRows(registry, ["zebra-7b", "zebra-7b"]).map((m) => m.id)).toEqual([ + "zebra-7b", + "alpha-1b", + "mid-3b", + ]); + }); + + test("hint ids absent from the registry are ignored", () => { + expect(modelRows(registry, ["ghost"]).map((m) => m.id)).toEqual([ + "alpha-1b", + "mid-3b", + "zebra-7b", + ]); + }); + + test("empty registry stays empty", () => { + expect(modelRows([])).toEqual([]); + }); +}); + +describe("formatPid / formatPort", () => { + test("numbers render verbatim", () => { + expect(formatPid(4242)).toBe("4242"); + expect(formatPort(11180)).toBe("11180"); + }); + + test("absent values render a dash", () => { + expect(formatPid(undefined)).toBe("—"); + expect(formatPort(undefined)).toBe("—"); + }); + + test("non-finite numbers render a dash, never NaN", () => { + expect(formatPid(Number.NaN)).toBe("—"); + expect(formatPort(Number.POSITIVE_INFINITY)).toBe("—"); + }); +}); + +describe("formatError / errorCell", () => { + test("the backend message renders verbatim", () => { + expect(formatError("spawn failed: boom")).toBe("spawn failed: boom"); + }); + + test("absent errors render a dash", () => { + expect(formatError(undefined)).toBe("—"); + expect(formatError("")).toBe("—"); + }); + + test("short messages pass through untruncated", () => { + expect(errorCell("spawn failed")).toBe("spawn failed"); + }); + + test("long messages truncate with an ellipsis at the max length", () => { + const long = "x".repeat(200); + const cell = errorCell(long); + expect(cell.length).toBe(97); // 96 chars + the ellipsis + expect(cell.endsWith("…")).toBe(true); + expect(cell.startsWith(long.slice(0, 96))).toBe(true); + }); + + test("absent errors stay a dash even under truncation", () => { + expect(errorCell(undefined)).toBe("—"); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/models-ui.ts b/frontend/src/lib/models-ui.ts new file mode 100644 index 0000000..342e5e7 --- /dev/null +++ b/frontend/src/lib/models-ui.ts @@ -0,0 +1,109 @@ +/** + * Pure display helpers for the models registry surface (U08). + * + * Deterministic and unit-tested in models-ui.test.ts. No IO, no Svelte + * imports: importable under bun:test. Every value derives from the + * ModelStatus[] rows the backend actually serves (src/backend/hub.ts — + * state is exactly "active" | "disabled" | "error", plus optional + * pid/port/error). Nothing here invents telemetry: there is no GPU/VRAM/ + * throughput data anywhere in the real surface, so none is rendered. + */ + +import type { ModelStatus } from "./api/types.js"; + +/** Registry state means exactly the three real hub states. */ +export type ModelState = ModelStatus["state"]; + +/** Counts over a registry snapshot — total and per-state buckets. */ +export interface ModelCounts { + total: number; + active: number; + disabled: number; + error: number; +} + +/** + * Count the registry by state. Every bucket derives from the rows verbatim; + * an empty registry maps to all-zero counts (the honest "No models" case). + */ +export function summarizeModels(models: readonly ModelStatus[]): ModelCounts { + const counts: ModelCounts = { total: models.length, active: 0, disabled: 0, error: 0 }; + for (const model of models) { + if (model.state === "active") { + counts.active += 1; + } else if (model.state === "disabled") { + counts.disabled += 1; + } else { + counts.error += 1; + } + } + return counts; +} + +/** + * Chip mapping per state. `tone` is a StatusDot tone; `label` the chip text. + * The literal values are the stable contract tests assert against. + */ +export const MODEL_STATE_CHIPS = { + active: { tone: "ok", label: "Active" }, + disabled: { tone: "idle", label: "Disabled" }, + error: { tone: "error", label: "Error" }, +} as const; + +export type ModelStateChip = (typeof MODEL_STATE_CHIPS)[ModelState]; + +/** Resolve the chip mapping for one state. */ +export function stateChip(state: ModelState): ModelStateChip { + return MODEL_STATE_CHIPS[state]; +} + +/** + * Display order for the registry table: hint ids first (pinned, in the given + * order, deduped, only those actually present in the registry), then every + * remaining model sorted by id ascending. Deterministic, so tests and + * renders always agree. + */ +export function modelRows( + registry: readonly ModelStatus[], + hintIds: readonly string[] = [], +): ModelStatus[] { + const pinnedIds = new Set(); + const pinned: ModelStatus[] = []; + for (const id of hintIds) { + if (pinnedIds.has(id)) continue; + const match = registry.find((model) => model.id === id); + if (match === undefined) continue; + pinnedIds.add(id); + pinned.push(match); + } + const rest = registry + .filter((model) => !pinnedIds.has(model.id)) + .sort((a, b) => a.id.localeCompare(b.id)); + return [...pinned, ...rest]; +} + +/** pid display: the number verbatim, "—" when the backend sends none. */ +export function formatPid(pid: number | undefined): string { + return typeof pid === "number" && Number.isFinite(pid) ? String(pid) : "—"; +} + +/** port display: the number verbatim, "—" when the backend sends none. */ +export function formatPort(port: number | undefined): string { + return typeof port === "number" && Number.isFinite(port) ? String(port) : "—"; +} + +/** error display: the backend message verbatim, "—" when there is none. */ +export function formatError(error: string | undefined): string { + return typeof error === "string" && error.length > 0 ? error : "—"; +} + +/** + * Error cell copy: the full message when it fits, ellipsis-truncated beyond + * `max`. The row keeps the full text in its title attribute, so truncation + * never loses information. + */ +export function errorCell(error: string | undefined, max = 96): string { + const text = formatError(error); + if (text === "—" || text.length <= max) return text; + return `${text.slice(0, max)}…`; +} \ No newline at end of file diff --git a/frontend/src/lib/navigation.test.ts b/frontend/src/lib/navigation.test.ts new file mode 100644 index 0000000..aedfe37 --- /dev/null +++ b/frontend/src/lib/navigation.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { NAV_GROUPS, NAV_ITEMS, isRouteActive } from "./navigation.js"; + +describe("navigation registry", () => { + test("every href is unique", () => { + const hrefs = NAV_ITEMS.map((item) => item.href); + expect(new Set(hrefs).size).toBe(hrefs.length); + }); + + test("every href is root-relative", () => { + for (const item of NAV_ITEMS) { + expect(item.href.startsWith("/")).toBe(true); + } + }); + + test("every group has at least one item", () => { + for (const group of NAV_GROUPS) { + expect(group.items.length).toBeGreaterThan(0); + } + }); + + test("exact path matches its item", () => { + expect(isRouteActive("/workflows", "/workflows")).toBe(true); + }); + + test("nested path highlights its parent item", () => { + expect(isRouteActive("/models", "/models/catalog")).toBe(true); + }); + + test("root never highlights nested paths", () => { + expect(isRouteActive("/", "/workflows")).toBe(false); + }); + + test("sibling section does not highlight", () => { + expect(isRouteActive("/workflows", "/models")).toBe(false); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/navigation.ts b/frontend/src/lib/navigation.ts new file mode 100644 index 0000000..3a42bed --- /dev/null +++ b/frontend/src/lib/navigation.ts @@ -0,0 +1,177 @@ +/** + * Console navigation registry — single source of truth for sidebar links, + * command palette entries and active-route matching. Pure data + pure + * functions, so the same module serves Astro SSR, Svelte islands and tests. + */ + +export const NAV_SECTIONS = { + overview: "Overview", + build: "Build", + manage: "Manage", + observe: "Observe", + system: "System", +} as const; + +/** + * Stable section id (a key of NAV_SECTIONS) — display labels live in + * NAV_SECTIONS itself (`NAV_SECTIONS[section]`), so ids stay lowercase. + */ +export type NavSection = keyof typeof NAV_SECTIONS; + +/** Inline SVG icon names (geometry lives in svelte/common/Icon.svelte). */ +export const ICON_NAMES = { + overview: "overview", + workflows: "workflows", + models: "models", + catalog: "catalog", + downloads: "downloads", + providers: "providers", + executions: "executions", + api: "api", + runtime: "runtime", + settings: "settings", + about: "about", + search: "search", + github: "github", + menu: "menu", + close: "close", + back: "back", + chevron: "chevron", + plus: "plus", + play: "play", + check: "check", +} as const; + +export type IconName = (typeof ICON_NAMES)[keyof typeof ICON_NAMES]; + +export interface NavItem { + href: string; + label: string; + description: string; + icon: IconName; + section: NavSection; +} + +export interface NavGroup { + section: NavSection; + items: readonly NavItem[]; +} + +export const NAV_GROUPS: readonly NavGroup[] = [ + { + section: "overview", + items: [ + { + href: "/", + label: "Overview", + description: "Runtime overview and quick actions", + icon: "overview", + section: "overview", + }, + ], + }, + { + section: "build", + items: [ + { + href: "/workflows", + label: "Workflows", + description: "Graph workflow library and editor", + icon: "workflows", + section: "build", + }, + ], + }, + { + section: "manage", + items: [ + { + href: "/models", + label: "Models", + description: "Local model registry", + icon: "models", + section: "manage", + }, + { + href: "/models/catalog", + label: "Model catalog", + description: "Curated and community models", + icon: "catalog", + section: "manage", + }, + { + href: "/models/downloads", + label: "Downloads", + description: "Checksum-verified GGUF downloads", + icon: "downloads", + section: "manage", + }, + { + href: "/providers", + label: "Providers", + description: "External provider connections", + icon: "providers", + section: "manage", + }, + ], + }, + { + section: "observe", + items: [ + { + href: "/executions", + label: "Executions", + description: "Workflow run logs", + icon: "executions", + section: "observe", + }, + { + href: "/playground", + label: "API", + description: "OpenAI-compatible playground", + icon: "api", + section: "observe", + }, + { + href: "/runtime", + label: "Runtime", + description: "Backend health and endpoint", + icon: "runtime", + section: "observe", + }, + ], + }, + { + section: "system", + items: [ + { + href: "/settings", + label: "Settings", + description: "Application configuration", + icon: "settings", + section: "system", + }, + { + href: "/about", + label: "About", + description: "Version and credits", + icon: "about", + section: "system", + }, + ], + }, +]; + +/** Flat list over every group (sidebar renders groups, palette renders flat). */ +export const NAV_ITEMS: readonly NavItem[] = NAV_GROUPS.flatMap( + (group) => group.items, +); + +/** + * Active-route rule: exact match, or a path nested under the item. The root + * href ("/") never prefix-matches, so Overview only highlights on `/`. + */ +export function isRouteActive(href: string, current: string): boolean { + if (href === "/") return current === "/"; + return current === href || current.startsWith(`${href}/`); +} \ No newline at end of file diff --git a/frontend/src/lib/overview.test.ts b/frontend/src/lib/overview.test.ts new file mode 100644 index 0000000..2351cd9 --- /dev/null +++ b/frontend/src/lib/overview.test.ts @@ -0,0 +1,142 @@ +/** + * Unit tests for the pure overview mapping (overview.ts). No IO: each test + * feeds a deterministic input object and asserts on the derived snapshot — + * empty inputs, state counts, newest-first truncation, unreachable runtime + * and allSettled-style partial inputs (missing pieces arrive as empty arrays + * / null / false and must map deterministically). + */ + +import { describe, expect, test } from "bun:test"; +import { buildOverviewSnapshot, RECENT_WORKFLOWS_LIMIT } from "./overview.js"; +import type { OverviewInput } from "./overview.js"; +import type { HealthStatus, ModelStatus, WorkflowRecord } from "./api/types.js"; + +const NOW = 1_700_000_000_000; + +function sampleHealth(): HealthStatus { + return { status: "ok" }; +} + +function sampleModel(id: string, state: ModelStatus["state"], error?: string): ModelStatus { + return error === undefined ? { id, state } : { id, state, error }; +} + +function sampleWorkflow(name: string, version = 1): WorkflowRecord { + return { + name, + version, + updatedAt: `2026-09-${String(version).padStart(2, "0")}T10:00:00Z`, + }; +} + +function baseInput(): OverviewInput { + return { + health: sampleHealth(), + authEnabled: false, + reachable: true, + models: [], + workflows: [], + now: NOW, + }; +} + +describe("buildOverviewSnapshot", () => { + test("empty inputs produce a deterministic zeroed snapshot", () => { + const snapshot = buildOverviewSnapshot({ + health: null, + authEnabled: false, + reachable: false, + models: [], + workflows: [], + now: NOW, + }); + + expect(snapshot).toEqual({ + runtime: { reachable: false, status: "unavailable", authEnabled: false }, + models: { total: 0, active: 0, error: 0 }, + workflows: { total: 0, recent: [] }, + fetchedAt: NOW, + }); + }); + + test("counts active and error states from the model rows", () => { + const snapshot = buildOverviewSnapshot({ + ...baseInput(), + authEnabled: true, + models: [ + sampleModel("a", "active"), + sampleModel("b", "active"), + sampleModel("c", "disabled"), + sampleModel("d", "error", "spawn failed"), + ], + }); + + expect(snapshot.runtime).toEqual({ + reachable: true, + status: "ok", + authEnabled: true, + }); + expect(snapshot.models).toEqual({ total: 4, active: 2, error: 1 }); + }); + + test("truncates the newest-first workflow list to the limit", () => { + const workflows = Array.from({ length: RECENT_WORKFLOWS_LIMIT + 2 }, (_x, i) => + sampleWorkflow(`wf-${i}`, i + 1), + ); + const snapshot = buildOverviewSnapshot({ + ...baseInput(), + workflows, + }); + + expect(snapshot.workflows.total).toBe(RECENT_WORKFLOWS_LIMIT + 2); + expect(snapshot.workflows.recent).toHaveLength(RECENT_WORKFLOWS_LIMIT); + expect(snapshot.workflows.recent[0].name).toBe("wf-0"); + expect(snapshot.workflows.recent[RECENT_WORKFLOWS_LIMIT - 1].name).toBe( + `wf-${RECENT_WORKFLOWS_LIMIT - 1}`, + ); + }); + + test("keeps the caller-given newest-first order untouched", () => { + const workflows = [sampleWorkflow("newest", 4), sampleWorkflow("older", 1)]; + const snapshot = buildOverviewSnapshot({ ...baseInput(), workflows }); + + expect(snapshot.workflows.recent.map((wf) => wf.name)).toEqual(["newest", "older"]); + }); + + test("an unreachable runtime reads unavailable even when health data exists", () => { + const snapshot = buildOverviewSnapshot({ + health: sampleHealth(), + authEnabled: false, + reachable: false, + models: [sampleModel("a", "active")], + workflows: [sampleWorkflow("w")], + now: NOW, + }); + + expect(snapshot.runtime).toEqual({ + reachable: false, + status: "unavailable", + authEnabled: false, + }); + // The rest of the dashboard still maps from whatever actually arrived. + expect(snapshot.models.total).toBe(1); + expect(snapshot.workflows.total).toBe(1); + }); + + test("allSettled-style partial inputs map deterministically", () => { + // Mirrors the island: a failed service call lands as an empty array. + const snapshot = buildOverviewSnapshot({ + health: null, + authEnabled: true, + reachable: true, + models: [], + workflows: [], + now: NOW, + }); + + expect(snapshot.runtime.status).toBe("ok"); + expect(snapshot.runtime.authEnabled).toBe(true); + expect(snapshot.models).toEqual({ total: 0, active: 0, error: 0 }); + expect(snapshot.workflows).toEqual({ total: 0, recent: [] }); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/overview.ts b/frontend/src/lib/overview.ts new file mode 100644 index 0000000..27463ce --- /dev/null +++ b/frontend/src/lib/overview.ts @@ -0,0 +1,76 @@ +/** + * Overview dashboard data mapping — PURE transform (no IO). + * + * The Overview island gathers live inputs through the typed API clients + * (./api) and funnels them through `buildOverviewSnapshot`, so every derived + * value is deterministic and fully unit-testable. Missing data is represented + * honestly: an unreachable backend maps to runtime.status "unavailable", and a + * failed service call arrives from the caller as an empty array / null — never + * an invented number. + */ + +import type { HealthStatus, ModelStatus, WorkflowRecord } from "./api/types.js"; + +/** Input gathered by the island (or a test) before mapping. */ +export interface OverviewInput { + /** GET /api/health body, or null when the backend is unreachable. */ + health: HealthStatus | null; + /** True when a bearer-token gate guards the /api/models branch. */ + authEnabled: boolean; + /** True when the /api/health probe answered (any status, incl. 401). */ + reachable: boolean; + /** Registry snapshot; empty when the models branch failed. */ + models: ModelStatus[]; + /** Workflow metadata rows (newest first); empty when the call failed. */ + workflows: WorkflowRecord[]; + /** Epoch ms captured at fetch time — injected so the mapper stays pure. */ + now: number; +} + +/** How many of the newest-first workflow rows the dashboard lists. */ +export const RECENT_WORKFLOWS_LIMIT = 5; + +/** Derived dashboard state — every value comes from the input, honestly. */ +export interface OverviewSnapshot { + runtime: { + reachable: boolean; + /** "ok" when the runtime probe answered, "unavailable" otherwise. */ + status: "ok" | "unavailable"; + authEnabled: boolean; + }; + models: { + total: number; + /** Rows in state "active". */ + active: number; + /** Rows in state "error". */ + error: number; + }; + workflows: { + total: number; + /** First RECENT_WORKFLOWS_LIMIT rows of the newest-first list. */ + recent: Array<{ name: string; version: number; updatedAt: string }>; + }; + fetchedAt: number; +} + +/** Map the raw gathered inputs onto the dashboard snapshot. */ +export function buildOverviewSnapshot(input: OverviewInput): OverviewSnapshot { + const active = input.models.filter((model) => model.state === "active").length; + const error = input.models.filter((model) => model.state === "error").length; + const recent = input.workflows.slice(0, RECENT_WORKFLOWS_LIMIT).map((workflow) => ({ + name: workflow.name, + version: workflow.version, + updatedAt: workflow.updatedAt, + })); + + return { + runtime: { + reachable: input.reachable, + status: input.reachable ? "ok" : "unavailable", + authEnabled: input.authEnabled, + }, + models: { total: input.models.length, active, error }, + workflows: { total: input.workflows.length, recent }, + fetchedAt: input.now, + }; +} \ No newline at end of file diff --git a/frontend/src/lib/run-transcript.test.ts b/frontend/src/lib/run-transcript.test.ts new file mode 100644 index 0000000..a683bcf --- /dev/null +++ b/frontend/src/lib/run-transcript.test.ts @@ -0,0 +1,219 @@ +/** + * bun:test suite for the run-view state machine (run-transcript.ts). All + * events are applied synchronously in arrival order; each test drives an + * explicit event sequence and asserts the resulting snapshot. + */ + +import { describe, expect, test } from "bun:test"; +import { + RUN_STATUS, + createRunTranscript, + applyRunEvent, +} from "./run-transcript.js"; +import type { RunTranscript, RunStep } from "./run-transcript.js"; +import type { WorkflowWsEvent } from "./ws.js"; + +function status( + state: "bound" | "running" | "ok" | "error", + error?: string, +): WorkflowWsEvent { + return { type: "status", workflow: "demo", state, error }; +} + +function started(nodeId: string, nodeType = "llm_call"): WorkflowWsEvent { + return { type: "step_started", workflow: "demo", nodeId, nodeType }; +} + +function completed(nodeId: string, ms: number): WorkflowWsEvent { + return { type: "step_completed", workflow: "demo", nodeId, ms }; +} + +function token(data: string): WorkflowWsEvent { + return { type: "token", workflow: "demo", data }; +} + +function runError(error: string, nodeId?: string): WorkflowWsEvent { + return { type: "error", workflow: "demo", error, nodeId }; +} + +function contentFrame(text: string): string { + return `data: {"choices":[{"message":{"content":${JSON.stringify(text)}}}]}\n\n`; +} + +function doneFrame(): string { + return "data: [DONE]\n\n"; +} + +function run(fresh: RunTranscript, ...events: WorkflowWsEvent[]): RunTranscript { + return events.reduce(applyRunEvent, fresh); +} + +describe("createRunTranscript", () => { + test("starts idle with no steps, output or error", () => { + expect(createRunTranscript()).toEqual({ + status: "idle", + steps: [], + output: "", + done: false, + }); + }); + + test("accepts an explicit initial status (connecting)", () => { + expect(createRunTranscript(RUN_STATUS.CONNECTING).status).toBe("connecting"); + }); +}); + +describe("applyRunEvent — lifecycle", () => { + test("status bound is a no-op for the run view", () => { + const t = run(createRunTranscript(), status("bound")); + expect(t.status).toBe("idle"); + }); + + test("status running marks the run active", () => { + const t = run(createRunTranscript(), status("running")); + expect(t.status).toBe("running"); + }); + + test("status ok is terminal", () => { + const fresh = createRunTranscript(); + const t = run(fresh, status("running"), status("ok")); + expect(t.status).toBe("ok"); + expect(t.done).toBe(true); + expect(t.error).toBeUndefined(); + }); + + test("status error is terminal and carries the verbatim error", () => { + const t = run(createRunTranscript(), status("running"), status("error", "boom")); + expect(t.status).toBe("error"); + expect(t.done).toBe(true); + expect(t.error).toBe("boom"); + }); +}); + +describe("applyRunEvent — steps", () => { + test("step_started appends a running step", () => { + const t = run(createRunTranscript(), started("llm-1", "llm_call")); + expect(t.steps).toEqual([{ nodeId: "llm-1", nodeType: "llm_call", state: "running" }]); + }); + + test("an earlier running step resolves to done when a new step starts", () => { + const t = run(createRunTranscript(), started("start"), started("llm-1")); + expect(t.steps.map((s) => s.state)).toEqual(["done", "running"]); + }); + + test("step_completed marks the running step done with its duration", () => { + const t = run(createRunTranscript(), started("llm-1"), completed("llm-1", 384)); + const step = t.steps[0] as RunStep; + expect(step.state).toBe("done"); + expect(step.ms).toBe(384); + }); + + test("step_completed for an unknown node leaves the trace untouched", () => { + const t = run(createRunTranscript(), started("llm-1"), completed("ghost", 5)); + expect(t.steps[0]?.state).toBe("running"); + }); + + test("the same node id across loop iterations keeps separate step entries", () => { + const t = run( + createRunTranscript(), + started("llm-1"), + completed("llm-1", 10), + started("llm-1"), + completed("llm-1", 20), + ); + expect(t.steps).toHaveLength(2); + expect(t.steps.map((s) => s.ms)).toEqual([10, 20]); + expect(t.steps.every((s) => s.state === "done")).toBe(true); + }); +}); + +describe("applyRunEvent — tokens", () => { + test("token frames append decoded content in order", () => { + const t = run( + createRunTranscript(), + token(contentFrame("The ")), + token(contentFrame("answer.")), + ); + expect(t.output).toBe("The answer."); + expect(t.done).toBe(false); + }); + + test("a [DONE] token frame sets done without appending content", () => { + const t = run(createRunTranscript(), token(contentFrame("hi")), token(doneFrame())); + expect(t.output).toBe("hi"); + expect(t.done).toBe(true); + }); + + test("empty token frames leave the output untouched", () => { + const t = run(createRunTranscript(), token("")); + expect(t.output).toBe(""); + expect(t.done).toBe(false); + }); +}); + +describe("applyRunEvent — errors", () => { + test("an error with a nodeId marks that running step error", () => { + const t = run(createRunTranscript(), started("llm-1"), runError("boom", "llm-1")); + expect(t.steps[0]?.state).toBe("error"); + // Terminality is decided by the runner's status error, not the step error. + expect(t.status).toBe("idle"); + expect(t.done).toBe(false); + }); + + test("an error without a nodeId (protocol-level) ends the run", () => { + const t = run(createRunTranscript(), runError("unknown workflow \"ghost\"")); + expect(t.status).toBe("error"); + expect(t.done).toBe(true); + expect(t.error).toBe('unknown workflow "ghost"'); + }); + + test("a step error followed by status error stays terminal with the status text", () => { + const t = run( + createRunTranscript(), + started("llm-1"), + runError("boom", "llm-1"), + status("error", "boom"), + ); + expect(t.status).toBe("error"); + expect(t.done).toBe(true); + expect(t.error).toBe("boom"); + expect(t.steps[0]?.state).toBe("error"); + }); +}); + +describe("applyRunEvent — full wire sequence", () => { + test("sequential happy path mirrors the real protocol", () => { + const fresh = createRunTranscript(RUN_STATUS.CONNECTING); + const t = run( + fresh, + status("bound"), + status("running"), + started("start"), + completed("start", 2), + started("llm-1"), + completed("llm-1", 384), + started("end"), + completed("end", 1), + token(contentFrame("Hello!")), + token(doneFrame()), + status("ok"), + ); + expect(t.status).toBe("ok"); + expect(t.done).toBe(true); + expect(t.output).toBe("Hello!"); + expect(t.steps.map((s) => s.nodeId)).toEqual(["start", "llm-1", "end"]); + expect(t.steps.every((s) => s.state === "done")).toBe(true); + }); + + test("protocol error mid-run ends with the error visible", () => { + const t = run( + createRunTranscript(RUN_STATUS.CONNECTING), + status("running"), + started("llm-1"), + token("data: not-json\n\n"), + runError("received a message that is not valid JSON"), + ); + expect(t.status).toBe("error"); + expect(t.error).toBe("received a message that is not valid JSON"); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/run-transcript.ts b/frontend/src/lib/run-transcript.ts new file mode 100644 index 0000000..0e9793f --- /dev/null +++ b/frontend/src/lib/run-transcript.ts @@ -0,0 +1,150 @@ +/** + * Pure run-view state machine for the workflow RunPanel (frontend, U07). + * + * Turns the WS event stream (WorkflowWsEvent, ../lib/ws.ts) into the panel's + * render model: a status, a step trace, and aggregated output text. Events + * are applied synchronously in arrival order — the machine never reorders or + * batches them. No IO, no Svelte: importable under bun:test. + */ + +import { decodeTokenData } from "./sse.js"; +import type { WorkflowWsEvent } from "./ws.js"; + +export const RUN_STATUS = { + IDLE: "idle", + CONNECTING: "connecting", + RUNNING: "running", + OK: "ok", + ERROR: "error", +} as const; + +export type RunStatus = (typeof RUN_STATUS)[keyof typeof RUN_STATUS]; + +export const STEP_STATE = { + RUNNING: "running", + DONE: "done", + ERROR: "error", +} as const; + +export type StepState = (typeof STEP_STATE)[keyof typeof STEP_STATE]; + +export interface RunStep { + nodeId: string; + nodeType: string; + /** Duration in ms once the step completed. */ + ms?: number; + state: StepState; +} + +export interface RunTranscript { + status: RunStatus; + /** Step lifecycle events in arrival order (loops may repeat node ids). */ + steps: RunStep[]; + /** Aggregated assistant output decoded from token events. */ + output: string; + /** True once the run finished (status ok/error or a [DONE] token frame). */ + done: boolean; + /** Verbatim run error (status error / error events). */ + error?: string; +} + +/** Fresh run view; `connecting` is the status the panel sets while opening. */ +export function createRunTranscript(status: RunStatus = RUN_STATUS.IDLE): RunTranscript { + return { status, steps: [], output: "", done: false }; +} + +/** Apply one wire event to the transcript, returning the next snapshot. */ +export function applyRunEvent( + transcript: RunTranscript, + event: WorkflowWsEvent, +): RunTranscript { + switch (event.type) { + case "status": + return applyStatus(transcript, event); + case "step_started": + return applyStepStarted(transcript, event); + case "step_completed": + return applyStepCompleted(transcript, event); + case "token": + return applyToken(transcript, event); + case "error": + return applyError(transcript, event); + } +} + +function applyStatus( + transcript: RunTranscript, + event: Extract, +): RunTranscript { + switch (event.state) { + case "running": + return { ...transcript, status: RUN_STATUS.RUNNING }; + case "ok": + return { ...transcript, status: RUN_STATUS.OK, done: true, error: undefined }; + case "error": + return { ...transcript, status: RUN_STATUS.ERROR, done: true, error: event.error }; + default: + // "bound" carries no run-view transition. + return transcript; + } +} + +function applyStepStarted( + transcript: RunTranscript, + event: Extract, +): RunTranscript { + // A new step starting resolves any still-running predecessor(s) to done — + // the engine's events are sequential per path; any branch the runner left + // running converges to done once its own step_completed arrives. + const steps = transcript.steps.map((step) => + step.state === STEP_STATE.RUNNING ? { ...step, state: STEP_STATE.DONE } : step, + ); + steps.push({ + nodeId: event.nodeId, + nodeType: event.nodeType, + state: STEP_STATE.RUNNING, + }); + return { ...transcript, steps }; +} + +function applyStepCompleted( + transcript: RunTranscript, + event: Extract, +): RunTranscript { + const steps = transcript.steps.map((step) => + step.nodeId === event.nodeId && step.state === STEP_STATE.RUNNING + ? { ...step, state: STEP_STATE.DONE, ms: event.ms } + : step, + ); + return { ...transcript, steps }; +} + +function applyToken( + transcript: RunTranscript, + event: Extract, +): RunTranscript { + const decoded = decodeTokenData(event.data); + return { + ...transcript, + output: + decoded.content.length > 0 ? transcript.output + decoded.content : transcript.output, + done: transcript.done || decoded.done, + }; +} + +function applyError( + transcript: RunTranscript, + event: Extract, +): RunTranscript { + if (event.nodeId !== undefined) { + // A step-level error marks that step; the runner's status error follows. + const steps = transcript.steps.map((step) => + step.nodeId === event.nodeId && step.state === STEP_STATE.RUNNING + ? { ...step, state: STEP_STATE.ERROR } + : step, + ); + return { ...transcript, steps }; + } + // Protocol-level errors (bind failure, bad JSON, closed mid-run) end the run. + return { ...transcript, status: RUN_STATUS.ERROR, done: true, error: event.error }; +} \ No newline at end of file diff --git a/frontend/src/lib/runtime-ui.test.ts b/frontend/src/lib/runtime-ui.test.ts new file mode 100644 index 0000000..e41a916 --- /dev/null +++ b/frontend/src/lib/runtime-ui.test.ts @@ -0,0 +1,163 @@ +/** + * Unit tests for the pure runtime display helpers (runtime-ui.ts). No IO: + * each test feeds deterministic inputs and asserts on the mapped views — + * health with/without latency and localModels, the websocket probe state + * machine (happy path, error path, retry reset, illegal-transition no-ops) + * and the package.json version line. + */ + +import { describe, expect, test } from "bun:test"; +import { + WS_PROBE_INITIAL, + formatLatency, + formatLocalModels, + healthView, + versionLine, + wsProbeState, +} from "./runtime-ui.js"; +import type { WsProbeState } from "./runtime-ui.js"; + +describe("healthView", () => { + test("a null health maps to placeholders and a neutral tone", () => { + expect(healthView(null, null)).toEqual({ + status: null, + statusText: "—", + latency: "—", + localModels: "—", + tone: "neutral", + }); + }); + + test("an answered probe shows the backend status verbatim", () => { + const view = healthView({ status: "ok" }, 12.7); + expect(view.status).toBe("ok"); + expect(view.statusText).toBe("ok"); + expect(view.tone).toBe("ok"); + }); + + test("localModels count renders with pluralization when present", () => { + expect(healthView({ status: "ok", localModels: ["a"] }, null).localModels).toBe( + "1 local model", + ); + expect(healthView({ status: "ok", localModels: ["a", "b"] }, null).localModels).toBe( + "2 local models", + ); + expect(healthView({ status: "ok", localModels: [] }, null).localModels).toBe( + "0 local models", + ); + }); + + test("localModels is an honest dash when the backend sends none", () => { + expect(healthView({ status: "ok" }, null).localModels).toBe("—"); + }); +}); + +describe("formatLatency", () => { + test("rounds milliseconds below one second", () => { + expect(formatLatency(3.2)).toBe("3 ms"); + expect(formatLatency(250.6)).toBe("251 ms"); + }); + + test("switches to seconds at and past one second", () => { + expect(formatLatency(1000)).toBe("1.0 s"); + expect(formatLatency(1234)).toBe("1.2 s"); + }); + + test("dashes for absent or invalid measurements", () => { + expect(formatLatency(null)).toBe("—"); + expect(formatLatency(Number.NaN)).toBe("—"); + expect(formatLatency(-5)).toBe("—"); + }); +}); + +describe("formatLocalModels", () => { + test("dashes when the key is absent (hub not wired)", () => { + expect(formatLocalModels(undefined)).toBe("—"); + }); + + test("counts the array when present, singular for one", () => { + expect(formatLocalModels([])).toBe("0 local models"); + expect(formatLocalModels(["a"])).toBe("1 local model"); + expect(formatLocalModels(["a", "b", "c"])).toBe("3 local models"); + }); +}); + +describe("wsProbeState", () => { + test("happy path: idle → connecting → connected → closed", () => { + const started = wsProbeState(WS_PROBE_INITIAL, { type: "start" }); + expect(started).toEqual({ phase: "connecting", everConnected: false, error: null }); + + const opened = wsProbeState(started, { type: "open" }); + expect(opened).toEqual({ phase: "connected", everConnected: true, error: null }); + + const closed = wsProbeState(opened, { type: "close" }); + expect(closed.phase).toBe("closed"); + // Closed after a successful handshake keeps the positive record. + expect(closed.everConnected).toBe(true); + }); + + test("error path: connecting → error keeps the message", () => { + const state = wsProbeState( + wsProbeState(WS_PROBE_INITIAL, { type: "start" }), + { type: "error", error: "websocket error" }, + ); + expect(state).toEqual({ phase: "error", everConnected: false, error: "websocket error" }); + }); + + test("retry resets the probe from any phase", () => { + const errored: WsProbeState = { + phase: "error", + everConnected: false, + error: "websocket error", + }; + expect(wsProbeState(errored, { type: "start" })).toEqual({ + phase: "connecting", + everConnected: false, + error: null, + }); + + const closed: WsProbeState = { phase: "closed", everConnected: true, error: null }; + expect(wsProbeState(closed, { type: "start" }).phase).toBe("connecting"); + }); + + test("illegal transitions are no-ops (late browser events never corrupt)", () => { + // close / open / error before any probe: unchanged. + expect(wsProbeState(WS_PROBE_INITIAL, { type: "close" })).toBe(WS_PROBE_INITIAL); + expect(wsProbeState(WS_PROBE_INITIAL, { type: "open" })).toBe(WS_PROBE_INITIAL); + expect(wsProbeState(WS_PROBE_INITIAL, { type: "error", error: "x" })).toBe( + WS_PROBE_INITIAL, + ); + + // open from connected / closed: unchanged. + const connected: WsProbeState = { phase: "connected", everConnected: true, error: null }; + expect(wsProbeState(connected, { type: "open" })).toBe(connected); + + // close arriving after error (browser fires onclose after onerror): unchanged. + const errored: WsProbeState = { + phase: "error", + everConnected: true, + error: "websocket error", + }; + expect(wsProbeState(errored, { type: "close" })).toBe(errored); + + // error arriving after a clean close: unchanged. + const closed: WsProbeState = { phase: "closed", everConnected: true, error: null }; + expect(wsProbeState(closed, { type: "error", error: "x" })).toBe(closed); + }); +}); + +describe("versionLine", () => { + test("renders name and version from the package metadata", () => { + expect(versionLine({ name: "weavellm", version: "0.1.0" })).toBe("weavellm v0.1.0"); + }); + + test("falls back to the bare version when the name is missing", () => { + expect(versionLine({ version: "0.1.0" })).toBe("v0.1.0"); + }); + + test("dashes when there is no version to show", () => { + expect(versionLine({ name: "weavellm" })).toBe("—"); + expect(versionLine({})).toBe("—"); + expect(versionLine({ name: "weavellm", version: "" })).toBe("—"); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/runtime-ui.ts b/frontend/src/lib/runtime-ui.ts new file mode 100644 index 0000000..836193d --- /dev/null +++ b/frontend/src/lib/runtime-ui.ts @@ -0,0 +1,146 @@ +/** + * Pure display helpers for the Runtime page (U12). + * + * Deterministic and unit-tested in runtime-ui.test.ts. No IO, no Svelte + * imports: importable under bun:test. Everything derives from the REAL + * surface — GET /api/health (status + measured latency + localModels), the + * runtime auth probe outcome (runtime.ts) and the package.json version — + * with honest absence: values the probe did not observe render as "—", + * never as invented telemetry. + */ + +import type { HealthStatus } from "./api/types.js"; + +/** Tone choices the Runtime cards render (StatusCard / StatusDot set). */ +export type RuntimeTone = "ok" | "error" | "neutral"; + +/** Display view for the Health card, from health + measured latency. */ +export interface HealthView { + /** The backend status verbatim ("ok"), or null when nothing answered. */ + status: HealthStatus["status"] | null; + /** "ok" verbatim, or "—" when there is no answer. */ + statusText: string; + /** Real round-trip latency line ("123 ms", "1.2 s"), or "—". */ + latency: string; + /** localModels line ("3 local models"), or "—" when none was sent. */ + localModels: string; + tone: Exclude; +} + +/** + * Map the health probe onto its display view. A null health is the honest + * "no answer" case (backend unreachable, probe failed, or not probed yet) — + * it maps to placeholders, never to invented ok/error telemetry. + */ +export function healthView( + health: HealthStatus | null, + latencyMs: number | null, +): HealthView { + if (health === null) { + return { + status: null, + statusText: "—", + latency: "—", + localModels: "—", + tone: "neutral", + }; + } + return { + status: health.status, + statusText: health.status, + latency: formatLatency(latencyMs), + localModels: formatLocalModels(health.localModels), + tone: "ok", + }; +} + +/** Latency line: rounded ms, seconds past 1s, "—" when not measurable. */ +export function formatLatency(ms: number | null): string { + if (ms === null || !Number.isFinite(ms) || ms < 0) return "—"; + if (ms >= 1000) return `${(ms / 1000).toFixed(1)} s`; + return `${Math.round(ms)} ms`; +} + +/** localModels line: count of hub-served ids, "—" when the key is absent. */ +export function formatLocalModels(count: string[] | undefined): string { + if (count === undefined) return "—"; + return count.length === 1 ? "1 local model" : `${count.length} local models`; +} + +/** Phases of the one-shot WebSocket probe (not the run protocol). */ +export const WS_PROBE_PHASES = { + IDLE: "idle", + CONNECTING: "connecting", + CONNECTED: "connected", + CLOSED: "closed", + ERROR: "error", +} as const; + +export type WsProbePhase = (typeof WS_PROBE_PHASES)[keyof typeof WS_PROBE_PHASES]; + +/** Probe state machine state. */ +export interface WsProbeState { + phase: WsProbePhase; + /** True once the socket ever reached open — closed-after-ok reads positively. */ + everConnected: boolean; + /** Last error message; present only in the error phase. */ + error: string | null; +} + +/** Initial state: no probe has run yet. */ +export const WS_PROBE_INITIAL: WsProbeState = { + phase: "idle", + everConnected: false, + error: null, +}; + +/** Probe lifecycle actions (a probe connects, waits, then closes). */ +export type WsProbeAction = + | { type: "start" } + | { type: "open" } + | { type: "close" } + | { type: "error"; error: string }; + +/** + * Pure probe-state reducer. Transitions are deliberately narrow: + * start → connecting (any phase; a retry re-probes from scratch) + * open → connected (only from connecting) + * close → closed (only from connecting / connected) + * error → error (only from connecting / connected) + * Anything else is a no-op, so late browser events (e.g. close after error) + * never corrupt the honest state. + */ +export function wsProbeState(state: WsProbeState, action: WsProbeAction): WsProbeState { + switch (action.type) { + case "start": + return { phase: "connecting", everConnected: false, error: null }; + case "open": + if (state.phase !== "connecting") return state; + return { ...state, phase: "connected", everConnected: true }; + case "close": + if (state.phase !== "connecting" && state.phase !== "connected") return state; + return { ...state, phase: "closed" }; + case "error": + if (state.phase !== "connecting" && state.phase !== "connected") return state; + return { phase: "error", everConnected: state.everConnected, error: action.error }; + } +} + +/** Minimum package metadata the version card renders (root package.json). */ +export interface PackageMeta { + name?: string; + version?: string; +} + +/** + * Version line: "WeaveLLM v0.1.0" from the build-time package.json. Falls + * back to "v0.1.0" when the name is missing, and to "—" when there is no + * version to show — the version is whatever the package really declares. + */ +export function versionLine(pkg: PackageMeta): string { + const version = + typeof pkg.version === "string" && pkg.version.length > 0 ? pkg.version : null; + if (version === null) return "—"; + const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : ""; + return name.length > 0 ? `${name} v${version}` : `v${version}`; +} \ No newline at end of file diff --git a/frontend/src/lib/settings-ui.test.ts b/frontend/src/lib/settings-ui.test.ts new file mode 100644 index 0000000..a851c1c --- /dev/null +++ b/frontend/src/lib/settings-ui.test.ts @@ -0,0 +1,98 @@ +/** + * Pure helper tests for the Settings page (U13). The honest-data discipline + * is enforced here: the server-environment rows may only carry the "Coming + * from backend" placeholder, and auth views derive strictly from the probe. + */ + +import { describe, expect, test } from "bun:test"; +import type { RuntimeStatus } from "./api/runtime.js"; +import { + AUTH_NOTE, + COMING_FROM_BACKEND, + SERVER_ENV_NOTE, + SERVER_ENV_ROWS, + authProbe, + authView, +} from "./settings-ui.js"; + +describe("authProbe", () => { + test("reachable with the gate active reports enabled", () => { + const status: RuntimeStatus = { reachable: true, authEnabled: true }; + expect(authProbe(status)).toEqual({ status: "loaded", authEnabled: true }); + }); + + test("reachable with the gate inactive reports disabled", () => { + const status: RuntimeStatus = { reachable: true, authEnabled: false }; + expect(authProbe(status)).toEqual({ status: "loaded", authEnabled: false }); + }); + + test("unreachable backend is an honest error, never telemetry", () => { + const status: RuntimeStatus = { reachable: false, authEnabled: false }; + expect(authProbe(status)).toEqual({ + status: "error", + error: "backend unreachable", + }); + }); +}); + +describe("authView", () => { + test("loading renders the probing placeholder with no status dot", () => { + expect(authView({ status: "loading" })).toEqual({ + value: "…", + hint: "Probing the auth gate…", + tone: "neutral", + statusLabel: "", + }); + }); + + test("enabled renders Enabled with the server-owned note", () => { + expect(authView({ status: "loaded", authEnabled: true })).toEqual({ + value: "Enabled", + hint: AUTH_NOTE, + tone: "ok", + statusLabel: "Enabled", + }); + }); + + test("disabled renders Disabled with the same server-owned note", () => { + expect(authView({ status: "loaded", authEnabled: false })).toEqual({ + value: "Disabled", + hint: AUTH_NOTE, + tone: "neutral", + statusLabel: "Disabled", + }); + }); + + test("error renders Unavailable with the error verbatim", () => { + expect(authView({ status: "error", error: "backend unreachable" })).toEqual({ + value: "—", + hint: "backend unreachable", + tone: "error", + statusLabel: "Unavailable", + }); + }); +}); + +describe("SERVER_ENV_ROWS", () => { + test("covers the four server-owned settings", () => { + expect(SERVER_ENV_ROWS.map((row) => row.label)).toEqual([ + "Host", + "Port", + "App data directory", + "State store", + ]); + }); + + test("every row shows the honest placeholder, never a fake path", () => { + for (const row of SERVER_ENV_ROWS) { + expect(row.value).toBe(COMING_FROM_BACKEND); + } + }); + + test("the note is a single neutral line naming the real env keys", () => { + expect(SERVER_ENV_NOTE).toContain("WEAVELLM_HOST"); + expect(SERVER_ENV_NOTE).toContain("WEAVELLM_PORT"); + expect(SERVER_ENV_NOTE).toContain("WEAVELLM_APP_DATA"); + expect(SERVER_ENV_NOTE).toContain("not exposed through the public API"); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/settings-ui.ts b/frontend/src/lib/settings-ui.ts new file mode 100644 index 0000000..6f01aa1 --- /dev/null +++ b/frontend/src/lib/settings-ui.ts @@ -0,0 +1,88 @@ +/** + * Pure display helpers for the Settings page (U13). + * + * The settings surface is deliberately read-only: the backend exposes no + * settings API, and server-owned env (WEAVELLM_HOST / WEAVELLM_PORT / + * WEAVELLM_APP_DATA and the SQLite store path) is not observable from the + * browser. Everything here derives from the REAL surface — the runtime auth + * probe (runtime.ts) — plus an honest placeholder for what the browser + * cannot observe. The row table exists so a test can enforce that no fake + * path or invented value ever enters the server-environment card. + */ + +import type { RuntimeStatus } from "./api/runtime.js"; + +/** The only honest value the server-environment rows can render. */ +export const COMING_FROM_BACKEND = "Coming from backend"; + +/** Note every Authentication card carries (server-owned, never the console). */ +export const AUTH_NOTE = + "Owned server-side via WEAVELLM_AUTH — this console never holds or changes the key."; + +/** Display tone choices the Settings cards render (StatusCard / StatusDot). */ +export type SettingsTone = "ok" | "error" | "neutral"; + +/** Authentication probe state: loading → loaded (probe) or error. */ +export type AuthProbeState = + | { status: "loading" } + | { status: "loaded"; authEnabled: boolean } + | { status: "error"; error: string }; + +/** Display view for the Authentication card. */ +export interface AuthCardView { + value: string; + hint: string; + tone: SettingsTone; + statusLabel: string; +} + +/** + * Map the auth probe onto its display view. Placeholders (never guesses): + * loading renders a wait state, an unreachable backend renders the error + * verbatim, and the gate outcome renders Enabled/Disabled — all from the + * runtimeStatus() result, nothing invented. + */ +export function authView(state: AuthProbeState): AuthCardView { + switch (state.status) { + case "loading": + return { value: "…", hint: "Probing the auth gate…", tone: "neutral", statusLabel: "" }; + case "error": + return { value: "—", hint: state.error, tone: "error", statusLabel: "Unavailable" }; + case "loaded": + return state.authEnabled + ? { value: "Enabled", hint: AUTH_NOTE, tone: "ok", statusLabel: "Enabled" } + : { value: "Disabled", hint: AUTH_NOTE, tone: "neutral", statusLabel: "Disabled" }; + } +} + +/** + * Map a runtimeStatus() result onto the probe state. runtimeStatus never + * throws: an unreachable backend is the honest error case, never telemetry. + */ +export function authProbe(result: RuntimeStatus): AuthProbeState { + return result.reachable + ? { status: "loaded", authEnabled: result.authEnabled } + : { status: "error", error: "backend unreachable" }; +} + +/** A static server-environment row: label + the honest placeholder only. */ +export interface ServerEnvRow { + label: string; + value: string; +} + +/** + * Server-owned configuration the public API never exposes. Values stay the + * honest placeholder — the browser cannot observe host/port/app-data/store, + * so any real-looking path here would be a lie. + */ +export const SERVER_ENV_ROWS: readonly ServerEnvRow[] = [ + { label: "Host", value: COMING_FROM_BACKEND }, + { label: "Port", value: COMING_FROM_BACKEND }, + { label: "App data directory", value: COMING_FROM_BACKEND }, + { label: "State store", value: COMING_FROM_BACKEND }, +]; + +/** One-line neutral note for the Server environment card. */ +export const SERVER_ENV_NOTE = + "Server-owned configuration (WEAVELLM_HOST, WEAVELLM_PORT, WEAVELLM_APP_DATA, SQLite store) is not exposed through the public API."; \ No newline at end of file diff --git a/frontend/src/lib/sse.test.ts b/frontend/src/lib/sse.test.ts new file mode 100644 index 0000000..8969a7f --- /dev/null +++ b/frontend/src/lib/sse.test.ts @@ -0,0 +1,101 @@ +/** + * bun:test suite for the pure SSE token-frame decoder (sse.ts). No IO — the + * decoder only sees strings, so every frame shape the server (or the local + * one-shot encoder) can produce is covered directly. + */ + +import { describe, expect, test } from "bun:test"; +import { decodeTokenData, encodeTokenFrame } from "./sse.js"; + +function completionFrame(completion: unknown): string { + return `data: ${JSON.stringify(completion)}\n\n`; +} + +function completion(text: string): unknown { + return { + id: "cmpl-1", + object: "chat.completion", + created: 1, + model: "gateway/demo", + choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], + usage: null, + }; +} + +function chunk(text: string): unknown { + return { + id: "cmpl-1", + object: "chat.completion.chunk", + choices: [{ index: 0, delta: { content: text } }], + }; +} + +describe("decodeTokenData", () => { + test("extracts content from a chat.completion frame", () => { + const result = decodeTokenData(completionFrame(completion("Hello!"))); + expect(result).toEqual({ content: "Hello!", done: false }); + }); + + test("extracts content from a chat.completion.chunk delta frame", () => { + const result = decodeTokenData(completionFrame(chunk("Hel"))); + expect(result).toEqual({ content: "Hel", done: false }); + }); + + test("a [DONE] frame sets the terminal flag", () => { + expect(decodeTokenData("data: [DONE]\n\n")).toEqual({ content: "", done: true }); + }); + + test("content frame then [DONE] in one payload aggregates and terminates", () => { + const data = completionFrame(completion("Hello!")) + "data: [DONE]\n\n"; + expect(decodeTokenData(data)).toEqual({ content: "Hello!", done: true }); + }); + + test("multiple data frames in one event concatenate content in order", () => { + const data = + completionFrame(chunk("The ")) + completionFrame(chunk("answer ")) + completionFrame(chunk("is 42.")); + expect(decodeTokenData(data)).toEqual({ content: "The answer is 42.", done: false }); + }); + + test("CRLF frame separators are tolerated", () => { + const data = "data: {\"choices\":[{\"message\":{\"content\":\"hi\"}}]}\r\n\r\ndata: [DONE]\r\n\r\n"; + expect(decodeTokenData(data)).toEqual({ content: "hi", done: true }); + }); + + test("multi-line data fields join into one JSON payload", () => { + const data = 'data: {"choices":[{"message":{"content":\ndata: "multi"}}]}\n\n'; + expect(decodeTokenData(data)).toEqual({ content: "multi", done: false }); + }); + + test("frames without a data: field are ignored", () => { + const data = ": comment\n\nevent: message\n\n"; + expect(decodeTokenData(data)).toEqual({ content: "", done: false }); + }); + + test("a non-JSON data frame is skipped, not thrown", () => { + expect(decodeTokenData("data: not json at all\n\n")).toEqual({ content: "", done: false }); + }); + + test("an empty payload decodes to nothing", () => { + expect(decodeTokenData("")).toEqual({ content: "", done: false }); + }); + + test("whitespace-only frames are ignored", () => { + expect(decodeTokenData(" \n\n")).toEqual({ content: "", done: false }); + }); +}); + +describe("encodeTokenFrame", () => { + test("round-trips through decodeTokenData", () => { + const decoded = decodeTokenData(encodeTokenFrame("one-shot output")); + expect(decoded).toEqual({ content: "one-shot output", done: false }); + }); + + test("empty content round-trips as an empty (not done) frame", () => { + expect(decodeTokenData(encodeTokenFrame(""))).toEqual({ content: "", done: false }); + }); + + test("frames that carry no completion text yield no content", () => { + const data = completionFrame({ id: "x", object: "chat.completion", choices: [] }); + expect(decodeTokenData(data)).toEqual({ content: "", done: false }); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/sse.ts b/frontend/src/lib/sse.ts new file mode 100644 index 0000000..c191e4c --- /dev/null +++ b/frontend/src/lib/sse.ts @@ -0,0 +1,102 @@ +/** + * Pure SSE-frame decoding for workflow run token events (frontend, U07). + * + * The backend's WS token event carries OpenAI-wire SSE text in `data` + * (src/app/ws.ts): one `data: {JSON}\n\n` frame per token event, ended by + * exactly one `data: [DONE]\n\n`. This module decodes ANY number of frames + * defensively — the completed-run relay sends a chat.completion object, a + * live-streaming relay would send chat.completion.chunk deltas — plus the + * mirror encoder the local one-shot fallback uses so its output feeds the + * same transcript path as a live run. No IO, no Svelte: importable under + * bun:test. + */ + +export interface DecodedTokenData { + /** Concatenated text content of all non-terminal data frames. */ + content: string; + /** True once a `data: [DONE]` frame terminated the stream. */ + done: boolean; +} + +/** SSE event separator (blank line between events; CRLF tolerated). */ +const FRAME_SEPARATOR = /\r?\n\r?\n/; + +/** + * Extract the concatenated `data:` field payloads of a single SSE frame. + * One leading space after the colon is stripped (SSE field-value rule); + * frames with no `data:` field (comments, event/retry fields) are ignored. + * Exported for the /v1 stream client, which parses the same wire frames + * (U11) — the semantics stay in this single module. + */ +export function dataField(frame: string): string | null { + const lines: string[] = []; + for (const line of frame.split(/\r?\n/)) { + const trimmed = line.trimEnd(); + if (trimmed.startsWith("data:")) { + const payload = trimmed.slice("data:".length); + lines.push(payload.startsWith(" ") ? payload.slice(1) : payload); + } + } + return lines.length > 0 ? lines.join("\n") : null; +} + +/** Extract assistant text from one OpenAI-wire data payload, or null. */ +function completionText(payload: string): string | null { + let value: unknown; + try { + value = JSON.parse(payload); + } catch { + return null; // non-JSON data frame → nothing extractable, skip quietly + } + if (typeof value !== "object" || value === null) return null; + const choices = (value as { choices?: unknown }).choices; + if (!Array.isArray(choices) || choices.length === 0) return null; + const choice = choices[0]; + if (typeof choice !== "object" || choice === null) return null; + const { message, delta } = choice as { message?: unknown; delta?: unknown }; + for (const holder of [message, delta]) { + if (typeof holder !== "object" || holder === null) continue; + const content = (holder as { content?: unknown }).content; + if (typeof content === "string") return content; + } + return null; +} + +/** + * Decode one `token.data` payload into aggregate content and the terminal + * flag. Handles multiple frames per event, CRLF separators, multi-line + * `data:` fields and both completion and chunk JSON shapes defensively — + * frames without extractable text are skipped, never thrown. + */ +export function decodeTokenData(data: string): DecodedTokenData { + let content = ""; + let done = false; + for (const frame of data.split(FRAME_SEPARATOR)) { + const payload = dataField(frame); + if (payload === null) continue; + if (payload === "[DONE]") { + done = true; + continue; + } + const text = completionText(payload); + if (text !== null) content += text; + } + return { content, done }; +} + +/** + * OpenAI-wire token frame for one finished completion — the shape the + * backend relays after a run. Local one-shot runs encode their output with + * this so the transcript decodes it identically to a live run. + */ +export function encodeTokenFrame(content: string): string { + const completion = { + id: "cmpl-local", + object: "chat.completion", + created: 0, + model: "workflow", + choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }], + usage: null, + }; + return `data: ${JSON.stringify(completion)}\n\n`; +} \ No newline at end of file diff --git a/frontend/src/lib/ui-state.test.ts b/frontend/src/lib/ui-state.test.ts new file mode 100644 index 0000000..f15ec6c --- /dev/null +++ b/frontend/src/lib/ui-state.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { get } from "svelte/store"; +import { commandPaletteOpen, sidebarDrawerOpen } from "./ui-state.js"; + +describe("ui-state stores", () => { + test("sidebar drawer defaults to closed", () => { + sidebarDrawerOpen.set(false); + expect(get(sidebarDrawerOpen)).toBe(false); + }); + + test("sidebar drawer toggles open and closed", () => { + sidebarDrawerOpen.set(true); + expect(get(sidebarDrawerOpen)).toBe(true); + sidebarDrawerOpen.set(false); + expect(get(sidebarDrawerOpen)).toBe(false); + }); + + test("command palette defaults to closed and opens", () => { + commandPaletteOpen.set(false); + expect(get(commandPaletteOpen)).toBe(false); + commandPaletteOpen.set(true); + expect(get(commandPaletteOpen)).toBe(true); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/ui-state.ts b/frontend/src/lib/ui-state.ts new file mode 100644 index 0000000..d241586 --- /dev/null +++ b/frontend/src/lib/ui-state.ts @@ -0,0 +1,13 @@ +import { writable } from "svelte/store"; + +/** + * Cross-island UI state. The shell renders Sidebar, Topbar, StatusBar and + * CommandPalette as separate Svelte islands; these writable stores let them + * coordinate without prop drilling (same module instance per page). + */ + +/** Mobile drawer visibility — toggled from the Topbar hamburger. */ +export const sidebarDrawerOpen = writable(false); + +/** Command palette (Ctrl/Cmd+K quick navigation) visibility. */ +export const commandPaletteOpen = writable(false); \ No newline at end of file diff --git a/frontend/src/lib/v1-ui.test.ts b/frontend/src/lib/v1-ui.test.ts new file mode 100644 index 0000000..40e88a5 --- /dev/null +++ b/frontend/src/lib/v1-ui.test.ts @@ -0,0 +1,74 @@ +/** + * bun:test suite for the playground's pure rendering helpers (v1-ui.ts). + * highlightJson must be byte-preserving (join(tokens) === source) and must + * classify keys/strings/numbers/bools/null correctly on a serialized body. + */ + +import { describe, expect, test } from "bun:test"; +import { highlightJson, type JsonToken } from "./v1-ui.js"; + +const SAMPLE = JSON.stringify( + { + id: "cmpl-1", + object: "chat.completion", + created: 1710000000, + model: "gateway/summary", + choices: [ + { + index: 0, + message: { role: "assistant", content: "Hello, \"world\"!" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4, sorted: true, extra: null }, + }, + null, + 2, +); + +function kindsOf(tokens: JsonToken[]): JsonToken["kind"][] { + return tokens.map((t) => t.kind); +} + +describe("highlightJson()", () => { + test("round-trips byte-identically (join === source)", () => { + const tokens = highlightJson(SAMPLE); + expect(tokens.map((t) => t.value).join("")).toBe(SAMPLE); + }); + + test("classifies keys, strings, numbers, bools and null", () => { + const kinds = kindsOf(highlightJson(SAMPLE)); + expect(kinds).toContain("key"); + expect(kinds).toContain("string"); + expect(kinds).toContain("number"); + expect(kinds).toContain("bool"); + expect(kinds).toContain("null"); + // punctuation + whitespace are preserved verbatim as punct tokens + expect(kinds).toContain("punct"); + }); + + test("'true' and 'null' become bool/null, not strings", () => { + const tokens = highlightJson('{"sorted": true, "extra": null}'); + expect(tokens.filter((t) => t.value === "true")[0]?.kind).toBe("bool"); + expect(tokens.filter((t) => t.value === "null")[0]?.kind).toBe("null"); + }); + + test("non-key strings are never classified as keys", () => { + const tokens = highlightJson('{"a": "hello", "b": ["x", "y"]}'); + const keys = tokens.filter((t) => t.kind === "key").map((t) => t.value); + expect(keys).toEqual(['"a"', '"b"']); + const strings = tokens.filter((t) => t.kind === "string").map((t) => t.value); + expect(strings).toEqual(['"hello"', '"x"', '"y"']); + }); + + test("escaped quotes stay inside one string token", () => { + const tokens = highlightJson('{"c": "a \\"b\\" c"}'); + const stringTokens = tokens.filter((t) => t.kind === "string"); + expect(stringTokens).toHaveLength(1); + expect(stringTokens[0]?.value).toBe('"a \\"b\\" c"'); + }); + + test("empty input yields no tokens", () => { + expect(highlightJson("")).toEqual([]); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/v1-ui.ts b/frontend/src/lib/v1-ui.ts new file mode 100644 index 0000000..fb89be4 --- /dev/null +++ b/frontend/src/lib/v1-ui.ts @@ -0,0 +1,108 @@ +/** + * Pure rendering helpers for the API playground (U11), kept UI-framework + * free so they stay importable under bun:test. + * + * highlightJson is an honest JSON syntax highlighter: it splits an already + * serialized JSON string into display tokens and NEVER alters or invents + * content — joining the token values always reproduces the source exactly. + * The playground renders the raw wire body through it; no derived metrics. + */ + +export const JSON_TOKEN_KINDS = { + key: "key", + string: "string", + number: "number", + bool: "bool", + null: "null", + punct: "punct", +} as const; + +export type JsonTokenKind = (typeof JSON_TOKEN_KINDS)[keyof typeof JSON_TOKEN_KINDS]; + +export interface JsonToken { + kind: JsonTokenKind; + value: string; +} + +/** Single-character JSON punctuation (plus whitespace, preserved verbatim). */ +const PUNCT_CHARS = new Set([" ", "\t", "\n", "\r", "{", "}", "[", "]", ":", ",", '"']); + +/** + * Tokenize a serialized JSON string for display. Keys are detected only in + * the exact `"key":` position; every other double-quoted span is a string. + * `true`/`false`/`null` become bool/null tokens; numbers are scanned as-is. + * Whitespace is preserved inside `punct` tokens so the rendered text is + * byte-identical to the input (join(tokens) === src). + */ +export function highlightJson(src: string): JsonToken[] { + const tokens: JsonToken[] = []; + const push = (kind: JsonTokenKind, value: string): void => { + if (value.length === 0) return; + tokens.push({ kind, value }); + }; + + let i = 0; + let prevNonSpace = ""; + while (i < src.length) { + const ch = src[i]; + + if (ch === '"') { + // Scan the full string literal, honoring backslash escapes. + let j = i + 1; + while (j < src.length) { + if (src[j] === "\\") { + j += 2; + continue; + } + if (src[j] === '"') break; + j += 1; + } + const literal = src.slice(i, Math.min(j + 1, src.length)); + // A key sits right before ':' after the string — detect via the next + // non-space char (skip a possible second '"' for malformed input). + let k = j + 1; + while (k < src.length && /\s/.test(src[k])) k += 1; + const isKey = src[k] === ":" && prevNonSpace !== '"'; + push(isKey ? "key" : "string", literal); + prevNonSpace = '"'; + i = j + 1; + continue; + } + + if (/\d/.test(ch) || ch === "-") { + let j = i + 1; + while (j < src.length && /[0-9.eE+\-]/.test(src[j])) j += 1; + push("number", src.slice(i, j)); + prevNonSpace = "number"; + i = j; + continue; + } + + if (/[A-Za-z]/.test(ch)) { + let j = i + 1; + while (j < src.length && /[A-Za-z]/.test(src[j])) j += 1; + const word = src.slice(i, j); + if (word === "true" || word === "false") { + push("bool", word); + } else if (word === "null") { + push("null", word); + } else { + push("punct", word); // unknown word — preserved verbatim + } + prevNonSpace = word; + i = j; + continue; + } + + // Punctuation and whitespace: emit one token per char (byte-identical). + if (PUNCT_CHARS.has(ch) || /\s/.test(ch)) { + push("punct", ch); + if (!/\s/.test(ch)) prevNonSpace = ch; + } else { + push("punct", ch); // unexpected byte (e.g. control char) — kept verbatim + } + i += 1; + } + + return tokens; +} \ No newline at end of file diff --git a/frontend/src/lib/workflow-condition.test.ts b/frontend/src/lib/workflow-condition.test.ts new file mode 100644 index 0000000..13c89c8 --- /dev/null +++ b/frontend/src/lib/workflow-condition.test.ts @@ -0,0 +1,179 @@ +/** + * U06 — condition source ⇄ SAFE AST converter tests. + * + * The grammar mirrors `sanitizeAst`'s admitted shapes; the last gate on top + * is the REAL backend `sanitizeAst`, so these tests pin the round-trip and + * the rejection surface (unsafe fields, missing values, trailing junk). + */ +import { describe, expect, test } from "bun:test"; +import { sanitizeAst, type AstExpr } from "../../../src/orchestrator/graph.js"; +import { + isConditionSourceValid, + parseConditionSource, + renderConditionSource, + type ConditionParseResult, +} from "./workflow-condition.js"; + +/** Narrow a parse result to its ok branch (throws with the failure reason). */ +function expectOk(result: ConditionParseResult): AstExpr { + if (!result.ok) throw new Error(`expected ok parse, got: ${result.error}`); + return result.expr; +} + +/** Narrow a parse result to its failure branch (throws when the parse succeeded). */ +function expectFail(result: ConditionParseResult): string { + if (result.ok) throw new Error("expected a parse failure, but the source parsed"); + return result.error; +} + +function render(source: string): string { + const parsed = parseConditionSource(source); + expect(parsed.ok).toBe(true); + return renderConditionSource(expectOk(parsed)); +} + +describe("parseConditionSource", () => { + test("parses exists on a context field", () => { + const parsed = parseConditionSource("exists(lastResponse.content)"); + expect(parsed.ok).toBe(true); + expect(expectOk(parsed)).toEqual({ op: "exists", field: "lastResponse.content" }); + }); + + test("parses not(...) with nested expressions", () => { + const parsed = parseConditionSource("not(exists(error))"); + expect(parsed.ok).toBe(true); + expect(expectOk(parsed)).toEqual({ op: "not", child: { op: "exists", field: "error" } }); + }); + + test("parses all(...) and any(...) with multiple args", () => { + const all = parseConditionSource("all(lastResponse.status >= 200, exists(variables.step))"); + expect(all.ok).toBe(true); + expect(expectOk(all)).toEqual({ + op: "logical", + and: true, + args: [ + { op: "compare", field: "lastResponse.status", op2: ">=", value: 200 }, + { op: "exists", field: "variables.step" }, + ], + }); + + const any = parseConditionSource("any(lastResponse.status == 200, lastResponse.status == 429)"); + expect(any.ok).toBe(true); + expect(expectOk(any)).toEqual({ + op: "logical", + and: false, + args: [ + { op: "compare", field: "lastResponse.status", op2: "==", value: 200 }, + { op: "compare", field: "lastResponse.status", op2: "==", value: 429 }, + ], + }); + }); + + test("parses compare on every operator", () => { + const ops = ["==", "!=", "<", "<=", ">", ">="] as const; + for (const op of ops) { + const parsed = parseConditionSource(`variables.count ${op} 3`); + expect(parsed.ok).toBe(true); + expect(expectOk(parsed)).toEqual({ + op: "compare", + field: "variables.count", + op2: op, + value: 3, + }); + } + }); + + test("parses string, negative, decimal and boolean literals", () => { + const strings = [ + ['lastResponse.content == "ok"', { op: "compare", field: "lastResponse.content", op2: "==", value: "ok" }], + ['lastResponse.content == "a \\"quoted\\" pair"', { op: "compare", field: "lastResponse.content", op2: "==", value: 'a "quoted" pair' }], + ["variables.score < -1.5", { op: "compare", field: "variables.score", op2: "<", value: -1.5 }], + ["error != null", { op: "compare", field: "error", op2: "!=", value: null }], + ["variables.flag == true", { op: "compare", field: "variables.flag", op2: "==", value: true }], + ] as const; + for (const [source, expected] of strings) { + const parsed = parseConditionSource(source); + expect(parsed.ok).toBe(true); + expect(expectOk(parsed)).toEqual(expected); + } + }); + + test("tolerates whitespace and renders canonical form", () => { + expect(render(" all ( lastResponse.status >= 200 , exists( lastResponse.content ) ) ")).toBe( + "all(lastResponse.status >= 200, exists(lastResponse.content))", + ); + }); + + test("round-trips every AST shape through render → parse → sanitizeAst", () => { + const corpus: AstExpr[] = [ + { op: "exists", field: "lastResponse.content" }, + { op: "not", child: { op: "exists", field: "error" } }, + { + op: "logical", + and: true, + args: [ + { op: "compare", field: "lastResponse.status", op2: "==", value: 200 }, + { op: "not", child: { op: "exists", field: "lastResponse.content" } }, + ], + }, + { op: "compare", field: "variables.step", op2: ">=", value: 2 }, + { op: "compare", field: "lastResponse.content", op2: "!=", value: "" }, + { op: "compare", field: "error", op2: "==", value: null }, + // Non-primitive compare value (engine admits anything in `value`). + { op: "compare", field: "variables.tags", op2: "==", value: ["a", "b"] }, + { op: "compare", field: "variables.meta", op2: "==", value: { step: 1 } }, + ]; + for (const expr of corpus) { + const source = renderConditionSource(expr); + const parsed = parseConditionSource(source); + expect(parsed.ok, `parse "${source}"`).toBe(true); + const ok = expectOk(parsed); + expect(sanitizeAst(ok)).not.toBeNull(); + expect(renderConditionSource(ok)).toBe(source); + } + }); + + test("rejects empty input", () => { + const parsed = parseConditionSource(""); + expect(parsed.ok).toBe(false); + expect(expectFail(parsed)).toContain("expected an expression"); + }); + + test("rejects unsafe fields", () => { + for (const source of [ + "exists(https://evil.example/x)", + "exists(lastResponse)", + "file.read == 1", + "variables..x == 1", + ]) { + const parsed = parseConditionSource(source); + expect(parsed.ok, source).toBe(false); + } + }); + + test("rejects malformed expressions with position", () => { + const trailing = parseConditionSource("exists(lastResponse.content) garbage"); + expect(trailing.ok).toBe(false); + expect(expectFail(trailing)).toContain("trailing input"); + + const unclosed = parseConditionSource("any( exists(lastResponse.content) "); + expect(unclosed.ok).toBe(false); + expect(expectFail(unclosed)).toContain("expected"); + + const missingValue = parseConditionSource("lastResponse.status >="); + expect(missingValue.ok).toBe(false); + expect(expectFail(missingValue)).toContain("value"); + }); +}); + +describe("isConditionSourceValid", () => { + test("is true exactly when parse + sanitizeAst admit the source", () => { + expect(isConditionSourceValid("lastResponse.status == 200")).toBe(true); + expect(isConditionSourceValid("variables.attempt < 3")).toBe(true); + // Parses but sanitizeAst rejects the op2? It cannot — the grammar admits + // only allowed op2 — so the two gates agree on shape; unsafe values still + // fail on the grammar side. + expect(isConditionSourceValid("exists(lastResponse.status) && true")).toBe(false); + expect(isConditionSourceValid("")).toBe(false); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/workflow-condition.ts b/frontend/src/lib/workflow-condition.ts new file mode 100644 index 0000000..f4a88a7 --- /dev/null +++ b/frontend/src/lib/workflow-condition.ts @@ -0,0 +1,287 @@ +/** + * Canonical condition source ⇄ SAFE AST converters (U06). + * + * `condition`/`router` nodes carry an AST (`AstExpr`) that the engine treats + * as an opaque, non-editable blob. U06 makes the guarded expression editable + * through a canonical SOURCE STRING whose grammar is a strict mirror of the + * shapes `sanitizeAst` (src/orchestrator/graph.ts) admits — the backend SAFE + * AST is the authoritative contract and the ONLY interpreter: + * + * source := expr + * expr := "exists" "(" field ")" + * | "not" "(" expr ")" + * | ("all" | "any") "(" expr ("," expr)* ")" + * | field op2 literal + * field := "lastResponse.status" | "lastResponse.content" | "error" + * | "variables" | "variables" "." ident ("." ident)* + * ident := [A-Za-z_][A-Za-z0-9_]* + * op2 := "==" | "!=" | "<=" | ">=" | "<" | ">" + * literal := number | "string" | true | false | null | compact JSON value + * + * Values that are not primitives render as compact JSON and re-parse through + * the JSON-literal fallback, so any AST the engine ever admitted round-trips. + * Validation is two-gated: the grammar must parse AND the parsed AST must + * survive the real `sanitizeAst` — the editor can never store an expression + * the runtime would reject. + * + * Pure functions — no DOM, no Svelte, no stores — so this module runs under + * `bun test` and in the Astro build. + */ +import { sanitizeAst, type AstExpr, type CompareOp2 } from "../../../src/orchestrator/graph.js"; + +// ── Rendering (AST → canonical source) ─────────────────────────────────────── + +/** Render a validated SAFE AST as its canonical source string (never throws). */ +export function renderConditionSource(expr: AstExpr): string { + switch (expr.op) { + case "exists": + return `exists(${expr.field})`; + case "not": + return `not(${renderConditionSource(expr.child)})`; + case "logical": + return `${expr.and ? "all" : "any"}(${expr.args.map(renderConditionSource).join(", ")})`; + case "compare": + return `${expr.field} ${expr.op2} ${renderValue(expr.value)}`; + } +} + +function renderValue(value: unknown): string { + if (typeof value === "string") return JSON.stringify(value); + if (typeof value === "number" || typeof value === "boolean" || value === null) { + return String(value); + } + return JSON.stringify(value); +} + +// ── Parsing (source → AST) ──────────────────────────────────────────────────── + +export type ConditionParseResult = + | { ok: true; expr: AstExpr } + | { ok: false; error: string }; + +interface Cursor { + src: string; + pos: number; +} + +function fail(cursor: Cursor, message: string): ConditionParseResult { + return { ok: false, error: `Invalid condition at offset ${cursor.pos}: ${message}` }; +} + +function peek(cursor: Cursor): string { + return cursor.src[cursor.pos] ?? ""; +} + +function skipWs(cursor: Cursor): void { + while (cursor.pos < cursor.src.length && /\s/.test(cursor.src[cursor.pos]!)) cursor.pos += 1; +} + +/** Match a literal keyword or operator, advancing on success. */ +function eat(cursor: Cursor, token: string): boolean { + if (cursor.src.startsWith(token, cursor.pos)) { + cursor.pos += token.length; + return true; + } + return false; +} + +function eatIdent(cursor: Cursor): string | null { + const match = /^[A-Za-z_][A-Za-z0-9_]*/.exec(cursor.src.slice(cursor.pos)); + if (match === null) return null; + cursor.pos += match[0].length; + return match[0]; +} + +/** + * Parse a dotted field reference into a SAFE context field. Strict mirror of + * `isSafeField()`: only the three context fields or `variables`-prefixed + * names are admitted. + */ +function parseField(cursor: Cursor): string | null { + const first = eatIdent(cursor); + if (first === null) return null; + let field = first; + while (eat(cursor, ".")) { + const part = eatIdent(cursor); + if (part === null) return null; + field = `${field}.${part}`; + } + if (field === "lastResponse.status" || field === "lastResponse.content" || field === "error") { + return field; + } + if (field === "variables" || field.startsWith("variables.")) return field; + return null; +} + +/** Comparison operators, longest match first (<= / >= before < / >). */ +const OP2_TOKENS: readonly CompareOp2[] = ["==", "!=", "<=", ">=", "<", ">"]; + +function parseCompareOp2(cursor: Cursor): CompareOp2 | null { + for (const op of OP2_TOKENS) { + if (eat(cursor, op)) return op; + } + return null; +} + +function parseString(cursor: Cursor): string | undefined { + if (peek(cursor) !== '"') return undefined; + let out = ""; + cursor.pos += 1; + while (cursor.pos < cursor.src.length) { + const ch = cursor.src[cursor.pos]; + cursor.pos += 1; + if (ch === '"') return out; + if (ch !== "\\") { + out += ch; + continue; + } + const esc = cursor.src[cursor.pos]; + if (esc === undefined) return undefined; + cursor.pos += 1; + switch (esc) { + case '"': + out += '"'; + break; + case "\\": + out += "\\"; + break; + case "n": + out += "\n"; + break; + case "t": + out += "\t"; + break; + case "r": + out += "\r"; + break; + default: + out += esc; + } + } + return undefined; +} + +/** + * Attempt a compact JSON value occupying the ENTIRE remaining source. Only + * canonical form qualifies (`JSON.stringify(parsed) === rest`), mirroring + * `renderValue` — re-parsing rendered output always succeeds. + */ +function tryParseJsonLiteral(cursor: Cursor): { ok: true; value: unknown } | { ok: false } { + const rest = cursor.src.slice(cursor.pos); + try { + const value: unknown = JSON.parse(rest); + if (JSON.stringify(value) === rest) { + cursor.pos = cursor.src.length; + return { ok: true, value }; + } + return { ok: false }; + } catch { + return { ok: false }; + } +} + +function parseLiteral(cursor: Cursor): { ok: true; value: unknown } | { ok: false } { + skipWs(cursor); + const ch = peek(cursor); + if (ch === '"') { + const value = parseString(cursor); + return value === undefined ? { ok: false } : { ok: true, value }; + } + if (ch === "t" && eat(cursor, "true")) return { ok: true, value: true }; + if (ch === "f" && eat(cursor, "false")) return { ok: true, value: false }; + if (ch === "n" && eat(cursor, "null")) return { ok: true, value: null }; + if (ch === "-" || (ch !== "" && /[0-9]/.test(ch))) { + const match = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(cursor.src.slice(cursor.pos)); + if (match !== null) { + cursor.pos += match[0].length; + const value = Number(match[0]); + if (Number.isFinite(value)) return { ok: true, value }; + } + return { ok: false }; + } + // Fallback: any other literal is a compact JSON value (arrays/objects a + // saved workflow may carry — the engine admits them in `compare`). + const json = tryParseJsonLiteral(cursor); + if (json.ok) return json; + return { ok: false }; +} + +function parseCompareExpr(cursor: Cursor): { ok: true; expr: AstExpr } | { ok: false; error: string } { + const field = parseField(cursor); + if (field === null) return fail(cursor, "comparison needs a supported field"); + skipWs(cursor); + const op2 = parseCompareOp2(cursor); + if (op2 === null) return fail(cursor, 'comparison needs an operator (==, !=, <, <=, >, >=)'); + const literal = parseLiteral(cursor); + if (!literal.ok) return fail(cursor, "comparison needs a value (number, \"string\", true/false/null, or JSON)"); + return { ok: true, expr: { op: "compare", field, op2, value: literal.value } }; +} + +function parseExpr(cursor: Cursor): { ok: true; expr: AstExpr } | { ok: false; error: string } { + skipWs(cursor); + if (peek(cursor) === "") return fail(cursor, "expected an expression"); + if (eat(cursor, "exists")) { + skipWs(cursor); + if (!eat(cursor, "(")) return fail(cursor, 'expected "(" after exists'); + skipWs(cursor); + const field = parseField(cursor); + if (field === null) return fail(cursor, "unsupported field in exists(...)"); + skipWs(cursor); + if (!eat(cursor, ")")) return fail(cursor, 'expected ")" to close exists('); + return { ok: true, expr: { op: "exists", field } }; + } + if (eat(cursor, "not")) { + skipWs(cursor); + if (!eat(cursor, "(")) return fail(cursor, 'expected "(" after not'); + const child = parseExpr(cursor); + if (!child.ok) return child; + skipWs(cursor); + if (!eat(cursor, ")")) return fail(cursor, 'expected ")" to close not('); + return { ok: true, expr: { op: "not", child: child.expr } }; + } + let and: boolean; + let logical = false; + if (eat(cursor, "all")) { + and = true; + logical = true; + } else if (eat(cursor, "any")) { + and = false; + logical = true; + } + if (logical) { + skipWs(cursor); + if (!eat(cursor, "(")) return fail(cursor, 'expected "(" after logical'); + const args: AstExpr[] = []; + for (;;) { + skipWs(cursor); + const arg = parseExpr(cursor); + if (!arg.ok) return arg; + args.push(arg.expr); + skipWs(cursor); + if (eat(cursor, ")")) break; + if (!eat(cursor, ",")) { + return fail(cursor, 'expected "," or ")" inside logical'); + } + } + return { ok: true, expr: { op: "logical", and: and!, args } }; + } + return parseCompareExpr(cursor); +} + +/** Parse a canonical condition source string into a SAFE AST. */ +export function parseConditionSource(source: string): ConditionParseResult { + const cursor: Cursor = { src: source, pos: 0 }; + const expr = parseExpr(cursor); + if (!expr.ok) return expr; + skipWs(cursor); + if (cursor.pos !== source.length) { + return fail(cursor, `unexpected trailing input "${source.slice(cursor.pos)}"`); + } + return expr; +} + +/** The backend authority: does this source express a runtime-admissible AST? */ +export function isConditionSourceValid(source: string): boolean { + const parsed = parseConditionSource(source); + return parsed.ok && sanitizeAst(parsed.expr) !== null; +} \ No newline at end of file diff --git a/frontend/src/lib/workflow-flow.test.ts b/frontend/src/lib/workflow-flow.test.ts new file mode 100644 index 0000000..2d1f30e --- /dev/null +++ b/frontend/src/lib/workflow-flow.test.ts @@ -0,0 +1,542 @@ +import { describe, expect, test } from "bun:test"; +import { + NODE_GROUPS, + astSummary, + flowToGraph, + graphToFlow, + nodeGroupOf, + nodeSummaryLines, + parseWorkflowYamlToGraph, + patchNodeData, + serializeGraphToYaml, + uniqueEdgeId, + type FlowNode, +} from "./workflow-flow.js"; +import { NODE_TYPES } from "./workflow-nodes.js"; +import type { GraphNode, GraphPipeline } from "./workflow-nodes.js"; + +/** Minimal valid engine graph: start → llm → end (store-contract shape). */ +function demoGraph(): GraphPipeline { + return { + id: "demo", + name: "demo", + nodes: [ + { id: "start", type: "start" }, + { id: "llm", type: "llm_call", model: "SmolLM3", mode: "generate" }, + { id: "end", type: "end" }, + ], + edges: [ + { from: "start", to: "llm" }, + { from: "llm", to: "end" }, + ], + }; +} + +describe("nodeGroupOf", () => { + test("maps all 14 engine types to the 6 groups", () => { + const byGroup = new Map(); + for (const id of NODE_TYPES) { + const group = nodeGroupOf(id); + expect(NODE_GROUPS).toContain(group); + byGroup.set(group, (byGroup.get(group) ?? 0) + 1); + } + expect(byGroup.get("wf-start-end")).toBe(2); // start, end + expect(byGroup.get("wf-llm")).toBe(1); // llm_call + expect(byGroup.get("wf-branch")).toBe(5); // condition, loop, fan, join, router + expect(byGroup.get("wf-data")).toBe(4); // rag_local, data.code, memory, embeddings + expect(byGroup.get("wf-pipeline")).toBe(1); // pipeline + expect(byGroup.get("wf-output")).toBe(1); // output + }); +}); + +describe("graphToFlow", () => { + test("converts nodes with group type and whitelisted values", () => { + const { nodes } = graphToFlow(demoGraph()); + expect(nodes.map((node) => node.id)).toEqual(["start", "llm", "end"]); + const llm = nodes.find((node) => node.id === "llm"); + expect(llm?.type).toBe("wf-llm"); + expect(llm?.data.wNodeType).toBe("llm_call"); + expect(llm?.data.values).toEqual({ model: "SmolLM3", mode: "generate" }); + }); + + test("positions are deterministic for the same graph", () => { + const first = graphToFlow(demoGraph()); + const second = graphToFlow(demoGraph()); + expect(second.nodes).toEqual(first.nodes); + expect(second.edges).toEqual(first.edges); + }); + + test("layers advance along edges from start nodes", () => { + const { nodes } = graphToFlow(demoGraph()); + const start = nodes.find((node) => node.id === "start")!; + const llm = nodes.find((node) => node.id === "llm")!; + const end = nodes.find((node) => node.id === "end")!; + expect(start.position.x).toBeLessThan(llm.position.x); + expect(llm.position.x).toBeLessThan(end.position.x); + expect(start.position.y).toBe(llm.position.y); // single chain: same row + }); + + test("siblings stack on the same layer column", () => { + const graph: GraphPipeline = { + id: "fan-out", + name: "fan-out", + nodes: [ + { id: "start", type: "start" }, + { id: "fan", type: "fan", parallel: true }, + { id: "a", type: "llm_call", model: "m1", mode: "generate" }, + { id: "b", type: "llm_call", model: "m2", mode: "generate" }, + { id: "join", type: "join" }, + { id: "end", type: "end" }, + ], + edges: [ + { from: "start", to: "fan" }, + { from: "fan", to: "a" }, + { from: "fan", to: "b" }, + { from: "a", to: "join" }, + { from: "b", to: "join" }, + { from: "join", to: "end" }, + ], + }; + const { nodes } = graphToFlow(graph); + const a = nodes.find((node) => node.id === "a")!; + const b = nodes.find((node) => node.id === "b")!; + expect(a.position.x).toBe(b.position.x); // same layer + expect(a.position.y).not.toBe(b.position.y); // stacked + }); + + test("cycles do not hang and stay deterministic", () => { + const cyclic: GraphPipeline = { + id: "c", + name: "c", + nodes: [ + { id: "a", type: "start" }, + { id: "b", type: "fan" }, + { id: "c", type: "end" }, + ], + edges: [ + { from: "a", to: "b" }, + { from: "b", to: "a" }, // back edge + { from: "b", to: "c" }, + ], + }; + const first = graphToFlow(cyclic); + const second = graphToFlow(cyclic); + expect(second.nodes).toEqual(first.nodes); + expect(second.edges).toEqual(first.edges); + }); + + test("duplicate edges get unique deterministic ids, guards are preserved", () => { + const graph: GraphPipeline = { + id: "dup", + name: "dup", + nodes: [ + { id: "start", type: "start" }, + { id: "cond", type: "condition", condition: { op: "exists", field: "lastResponse.content" } }, + { id: "end", type: "end" }, + ], + edges: [ + { from: "start", to: "cond" }, + { from: "cond", to: "end", guard: "true" }, + { from: "cond", to: "end", guard: "false" }, + ], + }; + const { edges } = graphToFlow(graph); + expect(edges.map((edge) => edge.id)).toEqual(["start->cond", "cond->end", "cond->end#2"]); + expect(edges[1].guard).toBe("true"); + expect(edges[2].guard).toBe("false"); + }); +}); + +describe("uniqueEdgeId", () => { + test("canonical form then #n suffixes", () => { + const taken = new Set(); + expect(uniqueEdgeId("a", "b", taken)).toBe("a->b"); + taken.add("a->b"); + expect(uniqueEdgeId("a", "b", taken)).toBe("a->b#2"); + taken.add("a->b#2"); + expect(uniqueEdgeId("a", "b", taken)).toBe("a->b#3"); + }); +}); + +describe("flowToGraph", () => { + test("admits only whitelisted fields — unknown values are dropped", () => { + const nodes: FlowNode[] = [ + { + id: "llm", + type: "wf-llm", + position: { x: 0, y: 0 }, + data: { + wNodeType: "llm_call", + label: "LLM call", + values: { model: "SmolLM3", mode: "generate", bogus: "nope" }, + }, + }, + ]; + const graph = flowToGraph(nodes, []); + expect(graph.nodes[0]).toEqual({ id: "llm", type: "llm_call", model: "SmolLM3", mode: "generate" }); + }); + + test("preserves the universal parallel flag", () => { + const graph = flowToGraph( + [ + { + id: "fan", + type: "wf-branch", + position: { x: 0, y: 0 }, + data: { wNodeType: "fan", label: "Fan", values: { parallel: true } }, + }, + ], + [], + ); + expect(graph.nodes[0]).toEqual({ id: "fan", type: "fan", parallel: true }); + }); + + test("round-trips condition/router AST through the opaque slot", () => { + const condition: GraphNode = { + id: "cond", + type: "condition", + condition: { op: "exists", field: "lastResponse.content" }, + }; + const graph: GraphPipeline = { + id: "rt", + name: "rt", + nodes: [{ id: "start", type: "start" }, condition, { id: "end", type: "end" }], + edges: [ + { from: "start", to: "cond" }, + { from: "cond", to: "end", guard: "true" }, + ], + }; + // flowToGraph emits a working-graph placeholder id — nodes/edges must match. + const converted = graphToFlow(graph); + const roundTripped = flowToGraph(converted.nodes, converted.edges); + expect(roundTripped).toEqual({ id: "workflow", nodes: graph.nodes, edges: graph.edges }); + }); + + test("round-trips pipeline params through the opaque slot", () => { + const graph: GraphPipeline = { + id: "pipe", + name: "pipe", + nodes: [ + { id: "start", type: "start" }, + { id: "sub", type: "pipeline", pipeline: "summary", params: { topic: "io" } }, + { id: "end", type: "end" }, + ], + edges: [ + { from: "start", to: "sub" }, + { from: "sub", to: "end" }, + ], + }; + const converted = graphToFlow(graph); + const roundTripped = flowToGraph(converted.nodes, converted.edges); + expect(roundTripped).toEqual({ id: "workflow", nodes: graph.nodes, edges: graph.edges }); + }); + + test("full canvas round-trip is stable (nodes + edges + guards)", () => { + const graph = demoGraph(); + const converted = graphToFlow(graph); + const back = flowToGraph(converted.nodes, converted.edges); + expect(back).toEqual({ id: "workflow", nodes: graph.nodes, edges: graph.edges }); + // And converting the round-tripped graph reproduces the same canvas. + expect(graphToFlow(back)).toEqual(converted); + }); +}); + +describe("workflow YAML helpers", () => { + const DEMO_YAML = [ + "name: demo", + "nodes:", + " - id: start", + " type: start", + " - id: llm", + " type: llm_call", + " model: SmolLM3", + " mode: generate", + " - id: end", + " type: end", + "edges:", + " - from: start", + " to: llm", + " - from: llm", + " to: end", + "", + ].join("\n"); + + test("parses valid YAML into the engine graph", () => { + const result = parseWorkflowYamlToGraph(DEMO_YAML); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.graph.id).toBe("demo"); + expect(result.graph.nodes.map((node) => node.id)).toEqual(["start", "llm", "end"]); + expect(result.graph.edges).toEqual([ + { from: "start", to: "llm" }, + { from: "llm", to: "end" }, + ]); + }); + + test("returns a friendly error envelope on malformed YAML", () => { + const result = parseWorkflowYamlToGraph("name: broken\nnodes: 42\n"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.length).toBeGreaterThan(0); + }); + + test("unknown node types and edge targets are rejected by the backend parser", () => { + const unknownType = parseWorkflowYamlToGraph( + "name: x\nnodes:\n - id: start\n type: nope\nedges: []\n", + ); + expect(unknownType.ok).toBe(false); + if (!unknownType.ok) expect(unknownType.error).toMatch(/unknown type|nope/i); + + const unknownTarget = parseWorkflowYamlToGraph( + "name: x\nnodes:\n - id: start\n type: start\nedges:\n - from: start\n to: ghost\n", + ); + expect(unknownTarget.ok).toBe(false); + if (!unknownTarget.ok) expect(unknownTarget.error).toMatch(/ghost/i); + }); + + test("serialize → parse round-trip deep-equals the graph", () => { + const graph = demoGraph(); + const yaml = serializeGraphToYaml(graph); + const reparsed = parseWorkflowYamlToGraph(yaml); + expect(reparsed.ok).toBe(true); + if (!reparsed.ok) return; + expect(reparsed.graph).toEqual(graph); + }); + + test("serialized field order follows the engine NODE_FIELDS contract", () => { + const graph: GraphPipeline = { + id: "order", + nodes: [ + { + id: "llm", + type: "llm_call", + model: "SmolLM3", + mode: "generate", + system: "Be brief", + on_429: "end", + provider: "openai", + }, + ], + edges: [], + }; + const yaml = serializeGraphToYaml(graph); + const idx = (needle: string): number => yaml.indexOf(needle); + // NODE_FIELDS order: id, type, parallel, model, provider, …, mode, …, system, …, on_429, … + expect(idx("type:")).not.toBe(-1); + expect(idx("type:")).toBeLessThan(idx("model:")); + expect(idx("model:")).toBeLessThan(idx("provider:")); + expect(idx("provider:")).toBeLessThan(idx("mode:")); + expect(idx("mode:")).toBeLessThan(idx("system:")); + expect(idx("system:")).toBeLessThan(idx("on_429:")); + }); + + test("round-trips guard expressions and duplicated edges through YAML", () => { + const graph: GraphPipeline = { + id: "guards", + name: "guards", + nodes: [ + { id: "start", type: "start" }, + { id: "cond", type: "condition", condition: { op: "exists", field: "lastResponse.content" } }, + { id: "end", type: "end" }, + ], + edges: [ + { from: "start", to: "cond" }, + { from: "cond", to: "end", guard: "true" }, + { from: "cond", to: "end", guard: "false" }, + ], + }; + const yaml = serializeGraphToYaml(graph); + const reparsed = parseWorkflowYamlToGraph(yaml); + expect(reparsed.ok).toBe(true); + if (!reparsed.ok) return; + expect(reparsed.graph).toEqual(graph); + }); +}); + +describe("display helpers", () => { + test("astSummary renders every SAFE AST shape", () => { + expect(astSummary({ op: "exists", field: "lastResponse.content" })).toBe( + "has lastResponse.content", + ); + expect( + astSummary({ op: "compare", field: "lastResponse.status", op2: "==", value: 200 }), + ).toBe('lastResponse.status == 200'); + expect(astSummary({ op: "not", child: { op: "exists", field: "x" } })).toBe("not (has x)"); + expect( + astSummary({ + op: "logical", + and: false, + args: [ + { op: "exists", field: "a" }, + { op: "exists", field: "b" }, + ], + }), + ).toBe("any(has a, has b)"); + }); + + test("nodeSummaryLines is honest per type", () => { + expect( + nodeSummaryLines({ + wNodeType: "llm_call", + label: "LLM call", + values: { model: "SmolLM3", mode: "generate" }, + }), + ).toEqual([ + { key: "model", value: "SmolLM3" }, + { key: "mode", value: "generate" }, + ]); + + expect( + nodeSummaryLines({ + wNodeType: "llm_call", + label: "LLM call", + values: {}, + }), + ).toEqual([{ key: "model", value: "unset" }]); + + expect( + nodeSummaryLines({ + wNodeType: "condition", + label: "Condition", + values: {}, + condition: { op: "exists", field: "lastResponse.content" }, + }), + ).toEqual([{ key: "condition", value: "has lastResponse.content" }]); + + expect( + nodeSummaryLines({ wNodeType: "loop", label: "Loop", values: { body: ["a", "b"], iterations: 2 } }), + ).toEqual([ + { key: "iterations", value: "2" }, + { key: "body", value: "2 nodes" }, + ]); + + expect(nodeSummaryLines({ wNodeType: "start", label: "Start", values: {} })).toEqual([]); + }); +}); + +describe("U06 — patchNodeData + full-corpus round-trip", () => { + /** + * The backend YAML corpus (workflow-yaml.test.ts) exercised every node type + * and field; this is its canvas twin — graphToFlow → flowToGraph must + * deep-equal the graph (up to the flowToGraph id placeholder), proving the + * inspector's edits never lose a field the engine would keep. + */ + const corpus: GraphPipeline = { + id: "corpus", + name: "corpus", + nodes: [ + { id: "start", type: "start" }, + { + id: "llm", + type: "llm_call", + model: "SmolLM3", + provider: "openai", + mode: "refine", + ctx: 4096, + system: "You are brief.", + assistant: "Understood.", + user: "Reserved.", + on_429: "fallback", + tool_calls_route: "router", + parallel: true, + }, + { + id: "cond", + type: "condition", + condition: { + op: "logical", + and: true, + args: [ + { op: "compare", field: "lastResponse.status", op2: "==", value: 200 }, + { op: "exists", field: "lastResponse.content" }, + ], + }, + }, + { + id: "router", + type: "router", + condition: { op: "compare", field: "variables.attempt", op2: "<", value: 3 }, + }, + { id: "fan", type: "fan", parallel: true }, + { id: "join", type: "join" }, + { id: "loop", type: "loop", body: ["fan", "llm"], iterations: 3 }, + { id: "fallback", type: "llm_call", model: "tiny" }, + { + id: "pipeline", + type: "pipeline", + pipeline: "summary", + params: { topic: "io", depth: "2" }, + }, + { id: "rag", type: "rag_local", k: 4 }, + { id: "code", type: "data.code", code: "return ctx.last" }, + { id: "mem", type: "memory", convId: "chat-1" }, + { id: "emb", type: "embeddings", text: "explicit text" }, + { id: "out", type: "output" }, + { id: "end", type: "end" }, + ], + edges: [ + { from: "start", to: "llm" }, + { from: "llm", to: "cond" }, + { from: "cond", to: "router", guard: "true" }, + { from: "cond", to: "fan", guard: "false" }, + { from: "router", to: "fan" }, + { from: "fan", to: "join" }, + { from: "join", to: "loop" }, + { from: "loop", to: "pipeline" }, + { from: "pipeline", to: "rag" }, + { from: "rag", to: "code" }, + { from: "code", to: "mem" }, + { from: "mem", to: "emb" }, + { from: "emb", to: "out" }, + { from: "out", to: "end" }, + ], + }; + + test("full 14-type corpus round-trips through canvas and back", () => { + const converted = graphToFlow(corpus); + expect(converted.nodes).toHaveLength(corpus.nodes.length); + const back = flowToGraph(converted.nodes, converted.edges); + expect(back).toEqual({ id: "workflow", nodes: corpus.nodes, edges: corpus.edges }); + // Reproducing the canvas from the round-tripped graph is stable too. + expect(graphToFlow(back)).toEqual(converted); + }); + + test("full corpus survives the YAML serializer (parse(serialize) deep-equals)", () => { + const yaml = serializeGraphToYaml(corpus); + const reparsed = parseWorkflowYamlToGraph(yaml); + expect(reparsed.ok).toBe(true); + if (!reparsed.ok) return; + expect(reparsed.graph).toEqual(corpus); + }); + + test("patchNodeData immutably patches only the targeted node's data", () => { + const { nodes } = graphToFlow(corpus); + const patched = patchNodeData(nodes, "llm", { values: { ...nodes[1]!.data.values, mode: "generate" } }); + + expect(patched).not.toBe(nodes); + const target = patched.find((n) => n.id === "llm")!; + expect(target.data.values.mode).toBe("generate"); + expect(target.position).toEqual(nodes.find((n) => n.id === "llm")!.position); + expect(target.type).toBe("wf-llm"); + // Untouched nodes are the same object references (structural sharing). + expect(patched.find((n) => n.id === "end")).toBe(nodes.find((n) => n.id === "end")); + // The source array is unchanged. + expect(nodes.find((n) => n.id === "llm")!.data.values.mode).toBe("refine"); + }); + + test("patchNodeData can write opaque slots (condition AST)", () => { + const { nodes } = graphToFlow(corpus); + const expr = { op: "exists", field: "error" } as const; + const patched = patchNodeData(nodes, "cond", { condition: expr }); + expect(patched.find((n) => n.id === "cond")!.data.condition).toEqual(expr); + // And the patched canvas serializes through flowToGraph with that slot. + const back = flowToGraph(patched, []); + expect(back.nodes.find((n) => n.id === "cond")!.condition).toEqual(expr); + }); + + test("patchNodeData on an unknown id returns an equal array", () => { + const { nodes } = graphToFlow(corpus); + const patched = patchNodeData(nodes, "ghost", { label: "x" }); + expect(patched).toEqual(nodes); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/workflow-flow.ts b/frontend/src/lib/workflow-flow.ts new file mode 100644 index 0000000..d500396 --- /dev/null +++ b/frontend/src/lib/workflow-flow.ts @@ -0,0 +1,480 @@ +/** + * Graph ⇄ canvas conversion + workflow YAML helpers (U05). + * + * The canvas (SvelteFlow) and the engine (`GraphPipeline`) are two shapes of + * the same graph: + * + * - `graphToFlow` converts engine → canvas, synthesizing deterministic + * positions (layers from `start` nodes, BFS over edges, cycle-safe) so + * any stored workflow has a stable, readable layout with no geometry of + * its own to store. + * - `flowToGraph` converts canvas → engine, admitting ONLY taxonomy-whitelist + * fields (via `sanitizeNodeFields`) plus the universal `parallel` flag and + * the opaque `condition` / `params` slots — the editor can never emit a + * field the engine would drop. + * + * YAML goes through the backend parser/serializer rather than a second + * implementation: `parse(serialize(graph))` deep-equals `graph` round-trip is + * guaranteed there, so this module only wraps it in a friendly result type. + * + * Pure functions — no DOM, no Svelte, no stores — so everything here runs + * under `bun test` and in the Astro build. + */ +import { + serializeWorkflowGraph, + parseWorkflowGraph, +} from "../../../src/orchestrator/workflow-yaml.js"; +import { + nodeTypeDef, + sanitizeNodeFields, + type FieldKind, + type NodeFieldValues, + type NodeType, +} from "./workflow-nodes.js"; +import type { + AstExpr, + GraphEdge, + GraphNode, + GraphPipeline, +} from "./workflow-nodes.js"; + +// ── Canvas shapes ──────────────────────────────────────────────────────────── + +/** SvelteFlow node type keys — one custom node component per group. */ +export type NodeGroup = + | "wf-start-end" + | "wf-llm" + | "wf-branch" + | "wf-data" + | "wf-pipeline" + | "wf-output"; + +export const NODE_GROUPS: readonly NodeGroup[] = [ + "wf-start-end", + "wf-llm", + "wf-branch", + "wf-data", + "wf-pipeline", + "wf-output", +]; + +/** Map each engine node type to its custom-node group. */ +export function nodeGroupOf(type: NodeType): NodeGroup { + switch (type) { + case "start": + case "end": + return "wf-start-end"; + case "llm_call": + return "wf-llm"; + case "condition": + case "loop": + case "fan": + case "join": + case "router": + return "wf-branch"; + case "rag_local": + case "data.code": + case "memory": + case "embeddings": + return "wf-data"; + case "pipeline": + return "wf-pipeline"; + case "output": + return "wf-output"; + } +} + +/** Per-node data the canvas carries; positions live on the node object. */ +export interface FlowNodeData { + /** The engine node type this canvas node represents (group discriminator). */ + wNodeType: NodeType; + /** Display label (taxonomy label at creation; edited in U06). */ + label: string; + /** Primitive GraphNode fields present on this node (whitelisted). */ + values: NodeFieldValues; + /** SAFE AST for condition/router nodes — opaque slot (round-trip fidelity). */ + condition?: AstExpr; + /** Input parameters for pipeline composition nodes — opaque slot. */ + params?: Record; +} + +export interface FlowNode { + id: string; + /** Custom-node group key (one SvelteFlow node type per group). */ + type: NodeGroup; + position: FlowPosition; + data: FlowNodeData; +} + +export interface FlowEdge { + id: string; + source: string; + target: string; + /** Engine guard ("true"/"false") preserved for U06 — not rendered yet. */ + guard?: "true" | "false"; +} + +/** A point on the canvas in flow coordinates. */ +export type FlowPosition = { x: number; y: number }; + +/** + * Minimal seam over the SvelteFlow instance the island needs (U05). The real + * instance exists only inside the SvelteFlow provider context, so a child + * component exposes exactly these two capabilities to the island: fit the + * viewport, and translate a client (screen) point into flow coordinates — + * used for click-to-add at the canvas center. + */ +export interface CanvasApi { + fitView: (options?: { padding?: number }) => void; + screenToFlowPosition: (position: FlowPosition, snapToGrid?: boolean) => FlowPosition; +} + +// ── Layout ─────────────────────────────────────────────────────────────────── + +const LAYER_GAP_X = 280; +const ROW_GAP_Y = 150; +const MARGIN = 40; + +/** + * Deterministic layered layout: BFS from `start` nodes over edges assigns a + * layer (column), nodes are stacked by array order within their layer, and + * any node unreached from a start (no start, disconnected islands) is appended + * after the BFS layers in array order. Pure function of the graph — the same + * input always yields the same positions, so round-trips are stable. + */ +function layoutFor(graph: GraphPipeline): Map { + const byId = new Map(graph.nodes.map((n) => [n.id, n])); + const out = new Map(); + for (const edge of graph.edges) { + if (!byId.has(edge.from) || !byId.has(edge.to)) continue; + const targets = out.get(edge.from) ?? []; + targets.push(edge.to); + out.set(edge.from, targets); + } + + const layerOf = new Map(); + const queue: string[] = []; + for (const node of graph.nodes) { + if (node.type === "start") { + layerOf.set(node.id, 0); + queue.push(node.id); + } + } + let head = 0; + while (head < queue.length) { + const id = queue[head]; + head += 1; + const layer = layerOf.get(id) ?? 0; + for (const next of out.get(id) ?? []) { + if (layerOf.has(next)) continue; // cycle-safe: first visit wins + layerOf.set(next, layer + 1); + queue.push(next); + } + } + + let fallback = 0; + for (const layer of layerOf.values()) fallback = Math.max(fallback, layer); + for (const node of graph.nodes) { + if (!layerOf.has(node.id)) { + fallback += 1; + layerOf.set(node.id, fallback); + } + } + + const rowIn = new Map(); + const positions = new Map(); + for (const node of graph.nodes) { + const layer = layerOf.get(node.id) ?? 0; + const row = rowIn.get(layer) ?? 0; + rowIn.set(layer, row + 1); + positions.set(node.id, { x: MARGIN + layer * LAYER_GAP_X, y: MARGIN + row * ROW_GAP_Y }); + } + return positions; +} + +// ── Conversion ─────────────────────────────────────────────────────────────── + +/** + * Unique edge id for a connection — the canonical form is `${from}->${to}` + * with a `#n` suffix when a parallel edge already used it (the engine allows + * duplicate edges; the canvas needs unique ids). Deterministic for a given + * ordered set. + */ +export function uniqueEdgeId( + from: string, + to: string, + existing: ReadonlySet, +): string { + let id = `${from}->${to}`; + let suffix = 2; + while (existing.has(id)) { + id = `${from}->${to}#${suffix}`; + suffix += 1; + } + return id; +} + +function edgeFromGraph(edge: GraphEdge, taken: Set): FlowEdge { + const id = uniqueEdgeId(edge.from, edge.to, taken); + taken.add(id); + const out: FlowEdge = { id, source: edge.from, target: edge.to }; + if (edge.guard !== undefined) out.guard = edge.guard; + return out; +} + +/** Engine graph → canvas nodes/edges with synthesized layered positions. */ +export function graphToFlow(graph: GraphPipeline): { + nodes: FlowNode[]; + edges: FlowEdge[]; +} { + const positions = layoutFor(graph); + const nodes: FlowNode[] = graph.nodes.map((node) => { + const def = nodeTypeDef(node.type); + const data: FlowNodeData = { + wNodeType: node.type, + label: def.label, + values: pickNodeFieldValues(node), + }; + if (node.condition !== undefined && (node.type === "condition" || node.type === "router")) { + data.condition = node.condition; + } + if (node.params !== undefined && node.type === "pipeline") { + data.params = node.params; + } + return { + id: node.id, + type: nodeGroupOf(node.type), + position: positions.get(node.id) ?? { x: MARGIN, y: MARGIN }, + data, + }; + }); + + const taken = new Set(); + const edges: FlowEdge[] = graph.edges.map((edge) => edgeFromGraph(edge, taken)); + return { nodes, edges }; +} + +/** Pick the primitive, whitelisted fields a node carries onto the canvas. */ +function pickNodeFieldValues(node: GraphNode): NodeFieldValues { + const out: NodeFieldValues = {}; + if (node.parallel !== undefined) out.parallel = node.parallel; + const def = nodeTypeDef(node.type); + for (const field of def.fields) { + if (field.key === "condition") continue; // opaque AST slot + const raw = (node as unknown as Record)[field.key]; + if (raw === undefined) continue; + const cleaned = sanitizeFieldValueFor(field.kind, field.key, raw); + if (cleaned !== undefined) out[field.key] = cleaned; + } + return out; +} + +function sanitizeFieldValueFor( + kind: FieldKind, + key: string, + raw: unknown, +): string | number | string[] | boolean | undefined { + switch (kind) { + case "toggle": + return typeof raw === "boolean" ? raw : undefined; + case "number": + return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; + case "tokens": + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + return typeof raw === "string" && raw.trim() !== "" ? raw : undefined; + case "select": + return typeof raw === "string" ? raw : undefined; + case "textarea": + if (Array.isArray(raw)) { + return key === "body" + ? raw.filter((item): item is string => typeof item === "string") + : undefined; + } + return typeof raw === "string" ? raw : undefined; + case "text": + case "code": + return typeof raw === "string" ? raw : undefined; + } +} + +/** Shape guard mirroring the backend's SAFE AST admission (structural only). */ +function isAstExpr(value: unknown): value is AstExpr { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + const candidate = value as Record; + if (typeof candidate.op !== "string") return false; + return ( + candidate.op === "exists" || + candidate.op === "not" || + candidate.op === "logical" || + candidate.op === "compare" + ); +} + +function isStringRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + return Object.values(value).every((item) => typeof item === "string"); +} + +/** Canvas nodes/edges → engine graph (whitelist admission, no casts). */ +export function flowToGraph(nodes: FlowNode[], edges: FlowEdge[]): GraphPipeline { + const graphNodes: GraphNode[] = nodes.map((node) => { + const type = node.data.wNodeType; + const values = sanitizeNodeFields(type, node.data.values); + const graphNode: GraphNode = { id: node.id, type }; + Object.assign(graphNode, values); + if ((type === "condition" || type === "router") && isAstExpr(node.data.condition)) { + graphNode.condition = node.data.condition; + } + if (type === "pipeline" && isStringRecord(node.data.params)) { + graphNode.params = node.data.params; + } + return graphNode; + }); + + const graphEdges: GraphEdge[] = edges.map((edge) => { + const out: GraphEdge = { from: edge.source, to: edge.target }; + if (edge.guard !== undefined) out.guard = edge.guard; + return out; + }); + return { id: "workflow", nodes: graphNodes, edges: graphEdges }; +} + +// ── YAML helpers ───────────────────────────────────────────────────────────── + +export type WorkflowYamlResult = + | { ok: true; graph: GraphPipeline } + | { ok: false; error: string }; + +/** + * Parse workflow YAML through the backend parser, wrapped in a friendly + * result type (`{ ok: false, error }` on malformed input). The backend names + * the offending node/edge slot in its error messages, which the editor shows + * verbatim. + */ +export function parseWorkflowYamlToGraph(yamlText: string): WorkflowYamlResult { + try { + const doc = parseWorkflowGraph(yamlText); + return { ok: true, graph: doc.graph }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : "Invalid workflow YAML", + }; + } +} + +/** Serialize an engine graph to workflow YAML via the backend serializer. */ +export function serializeGraphToYaml(graph: GraphPipeline): string { + return serializeWorkflowGraph(graph, null); +} + +// ── Inspector seam (U06) ───────────────────────────────────────────────────── + +/** + * Immutably patch one canvas node's DATA (fields, opaque slots) while + * preserving its id, group type and position. Returns a new array — the + * inspector's write seam into `bind:nodes` — so a Select→Reset or Undo never + * has to restore geometry. + */ +export function patchNodeData( + nodes: readonly FlowNode[], + id: string, + patch: Partial, +): FlowNode[] { + return nodes.map((node) => + node.id === id ? { ...node, data: { ...node.data, ...patch } } : node, + ); +} + +// ── Display helpers (shared by node components and the inspector) ──────────── + +/** Human-readable one-line rendering of a SAFE AST expression. */ +export function astSummary(expr: AstExpr): string { + switch (expr.op) { + case "exists": + return `has ${expr.field}`; + case "not": + return `not (${astSummary(expr.child)})`; + case "logical": + return `${expr.and ? "all" : "any"}(${expr.args.map(astSummary).join(", ")})`; + case "compare": + return `${expr.field} ${expr.op2} ${JSON.stringify(expr.value)}`; + } +} + +/** Compact per-node summary lines for the canvas cards (best effort). */ +export function nodeSummaryLines(data: FlowNodeData): { key: string; value: string }[] { + const lines: { key: string; value: string }[] = []; + switch (data.wNodeType) { + case "llm_call": { + const model = data.values.model; + const mode = data.values.mode; + if (typeof model === "string" && model !== "") lines.push({ key: "model", value: model }); + else lines.push({ key: "model", value: "unset" }); + if (typeof mode === "string") lines.push({ key: "mode", value: mode }); + if (typeof data.values.provider === "string" && data.values.provider !== "") { + lines.push({ key: "provider", value: data.values.provider }); + } + break; + } + case "loop": { + lines.push({ key: "iterations", value: String(data.values.iterations ?? 1) }); + const count = Array.isArray(data.values.body) ? data.values.body.length : 0; + lines.push({ key: "body", value: count > 0 ? `${count} ${count === 1 ? "node" : "nodes"}` : "unset" }); + break; + } + case "condition": + case "router": { + lines.push({ + key: "condition", + value: data.condition !== undefined ? astSummary(data.condition) : "unset", + }); + break; + } + case "pipeline": { + const name = typeof data.values.pipeline === "string" ? data.values.pipeline : ""; + lines.push({ key: "pipeline", value: name !== "" ? name : "unset" }); + if (data.params !== undefined && Object.keys(data.params).length > 0) { + lines.push({ key: "params", value: String(Object.keys(data.params).length) }); + } + break; + } + case "rag_local": { + if (typeof data.values.k === "number") { + lines.push({ key: "top-k", value: String(data.values.k) }); + } + break; + } + case "data.code": { + const code = typeof data.values.code === "string" ? data.values.code : ""; + const count = code === "" ? 0 : code.split("\n").length; + lines.push({ key: "code", value: count > 0 ? `${count} ${count === 1 ? "line" : "lines"}` : "unset" }); + break; + } + case "memory": { + if (typeof data.values.convId === "string" && data.values.convId !== "") { + lines.push({ key: "convId", value: data.values.convId }); + } + break; + } + case "embeddings": { + const text = typeof data.values.text === "string" ? data.values.text : ""; + lines.push({ key: "text", value: text !== "" ? truncate(text, 28) : "unset" }); + break; + } + case "fan": { + if (data.values.parallel === true) lines.push({ key: "parallel", value: "on" }); + break; + } + case "start": + case "end": + case "join": + case "output": + break; + } + return lines; +} + +function truncate(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1)}…`; +} \ No newline at end of file diff --git a/frontend/src/lib/workflow-nodes.test.ts b/frontend/src/lib/workflow-nodes.test.ts index 4c30d55..eff515a 100644 --- a/frontend/src/lib/workflow-nodes.test.ts +++ b/frontend/src/lib/workflow-nodes.test.ts @@ -1,124 +1,232 @@ -/** - * Editor pure-helper tests (Task 6.7, workflow-editor spec): palette - * completeness, cycle rejection, validation errors, and YAML round-trips. - */ import { describe, expect, test } from "bun:test"; -import type { GraphNode } from "../../../src/orchestrator/graph.js"; import { - editorToYaml, - flowEdgeFrom, - graphEdgeFrom, - makeEditorNode, - paletteCoversTaxonomy, - validateEditorGraph, - wouldCreateCycle, - yamlToEditor, + NODE_TYPES, + WORKFLOW_NODE_TYPES, + hasSourceHandle, + hasTargetHandle, + makeNodeId, + nodeCategory, + nodeLabel, + nodeTypeDef, + nodeTypeExists, + sanitizeNodeFields, } from "./workflow-nodes.js"; +import type { FieldKind } from "./workflow-nodes.js"; -const basic = (): { nodes: GraphNode[]; edges: ReturnType[] } => ({ - nodes: [ - { id: "start", type: "start" }, - { id: "a", type: "llm_call", model: "llama" }, - { id: "end", type: "end" }, - ], - edges: [ - { from: "start", to: "a" }, - { from: "a", to: "end" }, - ], +/** Runtime mirror of GraphNode fields (kept in sync with src/orchestrator/graph.ts). */ +const GRAPH_NODE_KEYS = new Set([ + "id", + "type", + "parallel", + "model", + "provider", + "condition", + "body", + "pipeline", + "params", + "mode", + "ctx", + "system", + "assistant", + "user", + "on_429", + "tool_calls_route", + "code", + "k", + "text", + "convId", + "iterations", +]); + +describe("WORKFLOW_NODE_TYPES taxonomy", () => { + test("covers every engine node type exactly once", () => { + const ids = WORKFLOW_NODE_TYPES.map((entry) => entry.id); + expect(ids).toEqual([...NODE_TYPES]); + expect(new Set(ids).size).toBe(NODE_TYPES.length); + }); + + test("palette order matches the contract (start → … → output)", () => { + const ids = WORKFLOW_NODE_TYPES.map((entry) => entry.id); + expect(ids).toEqual([ + "start", + "end", + "llm_call", + "condition", + "loop", + "fan", + "join", + "pipeline", + "rag_local", + "data.code", + "memory", + "embeddings", + "router", + "output", + ]); + }); + + test("every entry carries label, category, color and description", () => { + for (const entry of WORKFLOW_NODE_TYPES) { + expect(entry.label.length).toBeGreaterThan(0); + expect(["flow", "llm", "data", "integration", "output"]).toContain(entry.category); + expect(["accent", "secondary", "success"]).toContain(entry.color); + expect(entry.description.length).toBeGreaterThan(0); + } + }); + + test("field definitions bind only real GraphNode keys", () => { + for (const entry of WORKFLOW_NODE_TYPES) { + expect(Array.isArray(entry.fields), `${entry.id} fields is an array`).toBe(true); + for (const field of entry.fields) { + expect(GRAPH_NODE_KEYS.has(field.key), `${entry.id}.${field.key} is a GraphNode key`).toBe(true); + expect(field.label.length).toBeGreaterThan(0); + } + } + }); + + test("llm_call exposes the engine's llm semantics only", () => { + const llm = nodeTypeDef("llm_call"); + const keys = llm.fields.map((field) => field.key); + expect(keys).toEqual([ + "model", + "mode", + "provider", + "ctx", + "system", + "assistant", + "user", + "on_429", + "tool_calls_route", + ]); + const modeField = llm.fields.find((field) => field.key === "mode"); + expect(modeField?.kind).toBe("select"); + expect(modeField?.options).toEqual(["generate", "refine", "passthrough"]); + expect(llm.defaults).toEqual({ mode: "generate" }); + }); + + test("rag_local/embeddings do NOT invent a model field", () => { + // GraphNode has no `model` on these types — the runtime default applies. + expect(nodeTypeDef("rag_local").fields.map((f) => f.key)).toEqual(["k"]); + expect(nodeTypeDef("embeddings").fields.map((f) => f.key)).toEqual(["text"]); + }); + + test("condition/router default to valid SAFE AST shapes", () => { + expect(nodeTypeDef("condition").defaults).toEqual({ + condition: { op: "exists", field: "lastResponse.content" }, + }); + expect(nodeTypeDef("router").defaults).toEqual({ + condition: { op: "compare", field: "lastResponse.status", op2: "==", value: 200 }, + }); + }); + + test("loop/fan defaults are engine-valid", () => { + expect(nodeTypeDef("loop").defaults).toEqual({ body: [], iterations: 1 }); + expect(nodeTypeDef("fan").defaults).toEqual({ parallel: true }); + }); }); -describe("editor palette (6.7)", () => { - test("palette covers the full node taxonomy (>= 10 types)", () => { - expect(paletteCoversTaxonomy()).toBe(true); - expect(makeEditorNode("start", new Map(), { x: 0, y: 0 }).type).toBe("start"); +describe("nodeTypeExists / nodeTypeDef / nodeLabel / nodeCategory", () => { + test("accepts only real engine types", () => { + for (const id of NODE_TYPES) expect(nodeTypeExists(id)).toBe(true); + expect(nodeTypeExists("llm")).toBe(false); + expect(nodeTypeExists("")).toBe(false); }); - test("makeEditorNode applies palette defaults and increments ids per type", () => { - const counter = new Map(); - const first = makeEditorNode("llm_call", counter, { x: 10, y: 20 }); - const second = makeEditorNode("llm_call", counter, { x: 30, y: 40 }); - expect(first).toMatchObject({ id: "llm_call-1", model: "llama", mode: "generate" }); - expect(first.position).toEqual({ x: 10, y: 20 }); - expect(second.id).toBe("llm_call-2"); + test("nodeTypeDef throws on unknown ids (invariant helper)", () => { + expect(() => nodeTypeDef("nope" as never)).toThrow(/unknown node type/); + }); + + test("labels and categories resolve", () => { + expect(nodeLabel("llm_call")).toBe("LLM call"); + expect(nodeLabel("data.code")).toBe("Data (code)"); + expect(nodeCategory("start")).toBe("flow"); + expect(nodeCategory("pipeline")).toBe("integration"); + expect(nodeCategory("output")).toBe("output"); }); }); -describe("cycle rejection (6.7)", () => { - test("back edge closes a cycle; a forward edge does not", () => { - const graph = basic(); - expect(wouldCreateCycle(graph, "end", "start")).toBe(true); - expect(wouldCreateCycle(graph, "start", "end")).toBe(false); - }); - - test("self edge is a cycle", () => { - expect(wouldCreateCycle(basic(), "a", "a")).toBe(true); - }); - - test("transitive loop (c -> a when a -> b -> c) is detected", () => { - const graph = { - nodes: [ - { id: "a", type: "start" }, - { id: "b", type: "llm_call", model: "llama" }, - { id: "c", type: "end" }, - ], - edges: [ - { from: "a", to: "b" }, - { from: "b", to: "c" }, - ], - }; - expect(wouldCreateCycle(graph, "c", "a")).toBe(true); - expect(wouldCreateCycle(graph, "c", "b")).toBe(true); - expect(wouldCreateCycle(graph, "a", "c")).toBe(false); +describe("canvas handle semantics", () => { + test("start has no target; end has no source; everything else has both", () => { + for (const id of NODE_TYPES) { + expect(hasTargetHandle(id), `${id} target`).toBe(id !== "start"); + expect(hasSourceHandle(id), `${id} source`).toBe(id !== "end"); + } }); }); -describe("validation (6.7)", () => { - test("a valid acyclic graph has no errors", () => { - const graph = basic(); - expect(validateEditorGraph(graph.nodes, graph.edges)).toEqual([]); +describe("makeNodeId", () => { + test("kebab base forms", () => { + expect(makeNodeId("start", new Set())).toBe("start"); + expect(makeNodeId("llm_call", new Set())).toBe("llm-call"); + expect(makeNodeId("data.code", new Set())).toBe("data-code"); + }); + + test("dedupes against taken ids with numeric suffixes", () => { + expect(makeNodeId("llm_call", new Set(["llm-call"]))).toBe("llm-call-2"); + expect(makeNodeId("llm_call", new Set(["llm-call", "llm-call-2"]))).toBe("llm-call-3"); + }); + + test("deterministic for the same input set", () => { + const taken = new Set(["start", "llm-call"]); + expect(makeNodeId("llm_call", taken)).toBe("llm-call-2"); + expect(makeNodeId("llm_call", taken)).toBe("llm-call-2"); + }); +}); + +describe("sanitizeNodeFields", () => { + test("keeps only whitelisted keys and drops unknowns", () => { + const out = sanitizeNodeFields("llm_call", { + model: "SmolLM3", + mode: "generate", + bogus: "nope", + }); + expect(out).toEqual({ model: "SmolLM3", mode: "generate" }); + expect("bogus" in out).toBe(false); + }); + + test("admits the universal parallel flag on every type", () => { + expect(sanitizeNodeFields("start", { parallel: true })).toEqual({ parallel: true }); + expect(sanitizeNodeFields("llm_call", { parallel: true, model: "m" })).toEqual({ + parallel: true, + model: "m", + }); + }); + + test("rejects invalid values per kind", () => { + expect(sanitizeNodeFields("llm_call", { mode: "not-a-mode" })).toEqual({}); + expect(sanitizeNodeFields("llm_call", { ctx: 4096 })).toEqual({ ctx: 4096 }); + // ctx is number | string in GraphNode — the engine accepts percentage + // strings, so only empty strings are dropped. + expect(sanitizeNodeFields("llm_call", { ctx: "50%" })).toEqual({ ctx: "50%" }); + expect(sanitizeNodeFields("llm_call", { ctx: "" })).toEqual({}); + expect(sanitizeNodeFields("rag_local", { k: "four" })).toEqual({}); + expect(sanitizeNodeFields("rag_local", { k: 4 })).toEqual({ k: 4 }); + expect(sanitizeNodeFields("fan", { parallel: "yes" })).toEqual({}); + }); + + test("body is the only textarea-shaped string[] field", () => { + expect(sanitizeNodeFields("loop", { body: ["a", "b"], iterations: 2 })).toEqual({ + body: ["a", "b"], + iterations: 2, + }); + expect(sanitizeNodeFields("llm_call", { system: ["should-not-survive"] })).toEqual({}); }); - test("llm_call without a model is flagged by node", () => { - const graph = basic(); - graph.nodes[1] = { id: "a", type: "llm_call" }; - const errors = validateEditorGraph(graph.nodes, graph.edges); - expect(errors.some((e) => e.includes("a") && e.includes("model"))).toBe(true); + test("condition key is opaque and never admitted into primitives", () => { + const out = sanitizeNodeFields("condition", { + condition: { op: "exists", field: "lastResponse.content" }, + }); + expect(out).toEqual({}); }); }); -describe("YAML import/export (6.7)", () => { - const GOOD_YAML = - "name: Demo\n" + - "nodes:\n" + - " - {id: start, type: start}\n" + - " - {id: a, type: llm_call, model: llama}\n" + - " - {id: end, type: end}\n" + - "edges:\n" + - " - {from: start, to: a}\n" + - " - {from: a, to: end}\n"; - - test("import → export round-trips the graph", () => { - const imported = yamlToEditor(GOOD_YAML); - if (!imported.ok) throw new Error(imported.error); - expect(imported.nodes.map((n) => n.id)).toEqual(["start", "a", "end"]); - expect(imported.edges).toHaveLength(2); - const exported = editorToYaml(imported.nodes, imported.edges, imported.name); - expect(exported).toContain("name: Demo"); - expect(exported).toContain("type: llm_call"); - }); - - test("invalid YAML is rejected with a message naming the node", () => { - const result = yamlToEditor( - "name: broken\nnodes:\n - {id: start, type: start}\n - {id: x2, type: wat}\nedges: []\n", - ); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("wat"); - }); - - test("flow edge conversion carries the guard", () => { - const flow = flowEdgeFrom({ from: "a", to: "b", guard: "true" }); - expect(flow).toEqual({ id: "a->b", source: "a", target: "b", guard: "true" }); - expect(graphEdgeFrom(flow)).toEqual({ from: "a", to: "b", guard: "true" }); - expect(graphEdgeFrom(flowEdgeFrom({ from: "a", to: "b" }))).toEqual({ from: "a", to: "b" }); +describe("field kind sanity (compile-time surface)", () => { + test("every field kind used by the taxonomy is real", () => { + const kinds: FieldKind[] = ["text", "select", "number", "textarea", "code", "tokens", "toggle"]; + for (const entry of WORKFLOW_NODE_TYPES) { + for (const field of entry.fields) { + expect(kinds).toContain(field.kind); + } + } }); }); \ No newline at end of file diff --git a/frontend/src/lib/workflow-nodes.ts b/frontend/src/lib/workflow-nodes.ts index a933ae9..ff99b8c 100644 --- a/frontend/src/lib/workflow-nodes.ts +++ b/frontend/src/lib/workflow-nodes.ts @@ -1,257 +1,384 @@ /** - * Editor node palette + pure graph helpers (Task 6.7, workflow-editor spec). + * Single frontend node taxonomy (U05) — the mirror of `src/orchestrator/graph.ts`. * - * Pure functions only — no DOM, no stores — so the Svelte canvas stays thin - * and the palette/cycle/validation logic is unit-tested with `bun test` - * (relative imports, no Vite alias, so the same file runs in both runners). + * `graph.ts` is the authoritative contract: this module re-exports its + * `NodeType` union and `NODE_TYPES` registry (one source of truth) and adds + * the UI-facing surface the visual editor needs: + * + * - `WORKFLOW_NODE_TYPES` — 14 palette entries (ordered for the palette) + * with label, category, description, a token-key color and the per-type + * field definitions. Fields ONLY come from `GraphNode` semantics — no + * invented node fields. + * - value sanitization (`sanitizeNodeFields`) — enforces the "unknown + * fields are dropped" rule the backend parser applies, so the editor can + * never serialize a field the engine would drop. + * - id / handle helpers used by the canvas and the palette. + * + * Pure functions only — no DOM, no Svelte, no stores — so the same module + * runs under `bun test` and in the Astro build. */ -import type { GraphEdge, GraphNode, GraphPipeline, NodeType } from "../../../src/orchestrator/graph.js"; -import { NODE_TYPES, validateGraph } from "../../../src/orchestrator/graph.js"; -import { parseWorkflowGraph, serializeWorkflowGraph } from "../../../src/orchestrator/workflow-yaml.js"; +import { + NODE_TYPES, + type GraphNode, + type NodeType, +} from "../../../src/orchestrator/graph.js"; -/** A node on the canvas: the engine node + canvas position + display label. */ -export interface EditorNode extends GraphNode { - position: { x: number; y: number }; - data: { label: string }; -} +// The taxonomy IDs ARE the engine registry — re-export verbatim instead of +// duplicating a list that could drift. +export { NODE_TYPES } from "../../../src/orchestrator/graph.js"; +export type { + AstExpr, + GraphEdge, + GraphNode, + GraphPipeline, + NodeType, +} from "../../../src/orchestrator/graph.js"; -/** An edge on the canvas (xyflow shape), carrying the engine `guard`. */ -export interface FlowEdge { - id: string; - source: string; - target: string; - guard?: "true" | "false"; -} +/** Palette category grouping (palette order: flow → llm → data → integration → output). */ +export type NodeCategory = "flow" | "llm" | "data" | "integration" | "output"; + +export const CATEGORY_LABELS: Record = { + flow: "Flow", + llm: "LLM", + data: "Data", + integration: "Integration", + output: "Output", +}; + +/** Palette group order (flow → llm → data → integration → output). */ +export const CATEGORY_ORDER: readonly NodeCategory[] = [ + "flow", + "llm", + "data", + "integration", + "output", +]; + +/** Field input kinds the inspector forms (U06) will render. */ +export type FieldKind = + | "text" + | "select" + | "number" + | "textarea" + | "code" + | "tokens" + | "toggle"; + +/** Token-key color for a node type chip — resolved to `var(--)` in CSS. */ +export type ColorTokenKey = "accent" | "secondary" | "success"; -/** Canvas node with the engine's edge shape (for cycle/validation checks). */ -export interface EditorGraph { - nodes: GraphNode[]; - edges: GraphEdge[]; +/** One editable field of a node type, bound to a `GraphNode` field key. */ +export interface NodeFieldDef { + /** The GraphNode field key this input reads/writes. */ + key: string; + label: string; + kind: FieldKind; + /** Valid selections when kind === "select". */ + options?: readonly string[]; + placeholder?: string; } -export interface PaletteEntry { - type: NodeType; +/** Full taxonomy entry for one node type. */ +export interface WorkflowNodeTypeDef { + id: NodeType; label: string; + category: NodeCategory; description: string; - defaults: Omit; + /** Token key for the node color chip (CSS `var(--)`). */ + color: ColorTokenKey; + /** UI field surface — a subset of GraphNode semantics for this type. */ + fields: readonly NodeFieldDef[]; + /** Canvas defaults for click-to-add (GraphNode fields only). */ + defaults: Partial; } -/** The full node taxonomy as a palette (≥ 10 types, per the spec). */ -export const PALETTE: readonly PaletteEntry[] = [ +/** + * Per-type UI field definitions. `condition` and `params` are GraphNode + * fields of non-primitive shape (SAFE AST / param map); they travel in the + * opaque `FlowNodeData.condition` / `.params` slots rather than in + * `NodeFieldValues`, and their `kind` here ("code") only documents that U06 + * renders them as structured editors. `rag_local`/`embeddings` expose no + * `model` — GraphNode defines no such field (the runtime default applies), + * so adding one would invent a field the backend parser drops. + */ +export const WORKFLOW_NODE_TYPES: readonly WorkflowNodeTypeDef[] = [ { - type: "start", + id: "start", label: "Start", + category: "flow", description: "Graph entry — exactly one per workflow", + color: "success", + fields: [], defaults: {}, }, { - type: "end", + id: "end", label: "End", + category: "flow", description: "Graph exit — the last response becomes the completion", + color: "secondary", + fields: [], defaults: {}, }, { - type: "llm_call", + id: "llm_call", label: "LLM call", + category: "llm", description: "Generate with a model (mode: generate/refine/passthrough)", - defaults: { model: "llama", mode: "generate" }, + color: "accent", + fields: [ + { key: "model", label: "Model", kind: "text", placeholder: "Model id" }, + { key: "mode", label: "Mode", kind: "select", options: ["generate", "refine", "passthrough"] }, + { key: "provider", label: "Provider", kind: "text", placeholder: "Optional override" }, + { key: "ctx", label: "Context", kind: "tokens", placeholder: "Token limit" }, + { key: "system", label: "System", kind: "textarea", placeholder: "System scaffold" }, + { key: "assistant", label: "Assistant", kind: "textarea", placeholder: "Assistant scaffold" }, + { key: "user", label: "User", kind: "textarea", placeholder: "Reserved — user scaffold" }, + { key: "on_429", label: "On 429", kind: "text", placeholder: "Target node id" }, + { key: "tool_calls_route", label: "Tool-calls route", kind: "text", placeholder: "Target node id" }, + ], + defaults: { mode: "generate" }, }, { - type: "condition", + id: "condition", label: "Condition", + category: "flow", description: "Guard expression selecting true/false branches", + color: "accent", + fields: [{ key: "condition", label: "Condition", kind: "code", placeholder: "Safe AST expression" }], defaults: { condition: { op: "exists", field: "lastResponse.content" } }, }, { - type: "loop", + id: "loop", label: "Loop", + category: "flow", description: "Repeat a body of nodes", + color: "accent", + fields: [ + { key: "body", label: "Body", kind: "textarea", placeholder: "Body node ids, one per line" }, + { key: "iterations", label: "Iterations", kind: "number" }, + ], defaults: { body: [], iterations: 1 }, }, { - type: "fan", + id: "fan", label: "Fan", + category: "flow", description: "Fan out branches in parallel", + color: "accent", + fields: [], defaults: { parallel: true }, }, { - type: "join", + id: "join", label: "Join", + category: "flow", description: "Merge branch results before continuing", + color: "accent", + fields: [], defaults: {}, }, { - type: "pipeline", + id: "pipeline", label: "Pipeline", + category: "integration", description: "Compose another stored workflow", + color: "accent", + fields: [{ key: "pipeline", label: "Pipeline", kind: "text", placeholder: "Stored workflow name" }], defaults: { pipeline: "" }, }, { - type: "rag_local", + id: "rag_local", label: "RAG (local)", + category: "data", description: "Local retrieval over chunked documents", + color: "secondary", + fields: [{ key: "k", label: "Top-k", kind: "number", placeholder: "4" }], defaults: { k: 4 }, }, { - type: "data.code", + id: "data.code", label: "Data (code)", + category: "data", description: "Sandboxed script step", - defaults: { code: "// sandboxed step\n" }, + color: "secondary", + fields: [{ key: "code", label: "Code", kind: "code", placeholder: "Sandboxed script" }], + defaults: { code: "" }, }, { - type: "memory", + id: "memory", label: "Memory", + category: "data", description: "Conversation memory scope", + color: "secondary", + fields: [{ key: "convId", label: "Conversation id", kind: "text", placeholder: "Optional scope id" }], defaults: {}, }, { - type: "embeddings", + id: "embeddings", label: "Embeddings", + category: "data", description: "Embed the last response (or an explicit text)", + color: "secondary", + fields: [{ key: "text", label: "Text", kind: "textarea", placeholder: "Defaults to last response" }], defaults: {}, }, { - type: "router", + id: "router", label: "Router", + category: "flow", description: "Route by a comparison (e.g. status or content)", - defaults: { - condition: { op: "compare", field: "lastResponse.status", op2: "==", value: 200 }, - }, + color: "accent", + fields: [{ key: "condition", label: "Condition", kind: "code", placeholder: "Safe AST expression" }], + defaults: { condition: { op: "compare", field: "lastResponse.status", op2: "==", value: 200 } }, }, { - type: "output", + id: "output", label: "Output", + category: "output", description: "Emit an explicit output from the graph", + color: "secondary", + fields: [], defaults: {}, }, ]; -/** The palette covers the taxonomy exactly (palette completeness gate). */ -export function paletteCoversTaxonomy(): boolean { - return ( - PALETTE.length >= 10 && - PALETTE.every((entry) => NODE_TYPES.includes(entry.type)) && - NODE_TYPES.every((type) => PALETTE.some((entry) => entry.type === type)) - ); +/** Guard: is this string a real engine node type? */ +export function nodeTypeExists(id: string): id is NodeType { + return (NODE_TYPES as readonly string[]).includes(id); } -export function paletteFor(type: NodeType): PaletteEntry { - const entry = PALETTE.find((e) => e.type === type); - if (entry === undefined) throw new Error(`no palette entry for node type "${type}"`); +/** Taxonomy entry for a type (throws on unknown ids — invariant helper). */ +export function nodeTypeDef(type: NodeType): WorkflowNodeTypeDef { + const entry = WORKFLOW_NODE_TYPES.find((e) => e.id === type); + if (entry === undefined) throw new Error(`unknown node type "${type}"`); return entry; } -/** Build one canvas node from the palette; ids increment per type. */ -export function makeEditorNode( - type: NodeType, - counter: Map, - position: { x: number; y: number }, -): EditorNode { - const entry = paletteFor(type); - const n = (counter.get(type) ?? 0) + 1; - counter.set(type, n); - const base = entry.defaults as GraphNode; - return { - id: `${type}-${n}`, - type, - ...base, - position, - data: { label: entry.label }, - }; +/** Palette category of a node type. */ +export function nodeCategory(type: NodeType): NodeCategory { + return nodeTypeDef(type).category; } -export function flowEdgeFrom(edge: GraphEdge): FlowEdge { - return { - id: `${edge.from}->${edge.to}`, - source: edge.from, - target: edge.to, - ...(edge.guard !== undefined ? { guard: edge.guard } : {}), - }; +/** Human label of a node type. */ +export function nodeLabel(type: NodeType): string { + return nodeTypeDef(type).label; } -export function graphEdgeFrom(edge: FlowEdge): GraphEdge { - return { - from: edge.source, - to: edge.target, - ...(edge.guard !== undefined ? { guard: edge.guard } : {}), - }; +/** Canvas defaults of a node type (click-to-add seeds from here). */ +export function nodeDefaults(type: NodeType): Partial { + return nodeTypeDef(type).defaults; } -/** True when adding the edge `from → to` would close a cycle. */ -export function wouldCreateCycle( - graph: { nodes: GraphNode[]; edges: GraphEdge[] }, - from: string, - to: string, -): boolean { - if (from === to) return true; - const out = new Map(); - for (const edge of graph.edges) { - const list = out.get(edge.from) ?? []; - list.push(edge.to); - out.set(edge.from, list); - } - // BFS from the target; reaching the source means the new edge closes a loop. - const seen = new Set([to]); - const queue = [to]; - while (queue.length > 0) { - const current = queue.shift() as string; - for (const next of out.get(current) ?? []) { - if (next === from) return true; - if (!seen.has(next)) { - seen.add(next); - queue.push(next); +/** + * Canvas handle semantics, matching the backend edge model: an edge flows + * node → node, so `start` has no incoming (target) handle and `end` has no + * outgoing (source) handle; every other type gets both. + */ +export function hasTargetHandle(type: NodeType): boolean { + return type !== "start"; +} + +export function hasSourceHandle(type: NodeType): boolean { + return type !== "end"; +} + +/** + * Sparse per-node field values on the canvas — primitives only, keyed by + * GraphNode field names. `condition`/`params` live in the opaque + * `FlowNodeData` slots instead (see workflow-flow.ts). + */ +export type NodeFieldValues = Record< + string, + string | number | string[] | boolean | undefined +>; + +/** Keep only the primitive part of a field value that fits its kind. */ +function sanitizeFieldValue( + field: NodeFieldDef, + raw: unknown, +): string | number | string[] | boolean | undefined { + switch (field.kind) { + case "toggle": + return typeof raw === "boolean" ? raw : undefined; + case "number": + return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined; + case "tokens": + // GraphNode.ctx is number | string (the engine passes it through). + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + return typeof raw === "string" && raw.trim() !== "" ? raw : undefined; + case "select": + return typeof raw === "string" && field.options?.includes(raw) ? raw : undefined; + case "textarea": + // `body` is the one textarea-shaped field whose value IS a string[]. + if (Array.isArray(raw)) { + return field.key === "body" + ? raw.filter((item): item is string => typeof item === "string") + : undefined; } - } + return typeof raw === "string" ? raw : undefined; + case "text": + case "code": + return typeof raw === "string" ? raw : undefined; } - return false; } -/** Engine validation over the current canvas (missing fields, structure). */ -export function validateEditorGraph(nodes: GraphNode[], edges: GraphEdge[]): string[] { - const graph: GraphPipeline = { id: "workflow", nodes, edges }; - return validateGraph(graph, {}).errors; +/** + * Whitelist a raw field-values record against the taxonomy + the universal + * `parallel` flag (a real GraphNode field the backend keeps on every node), + * dropping unknown keys and type-invalid values — the same "unknown fields + * are dropped, known fields type-checked" admission the backend parser runs. + * Input is deliberately `Record` (admission boundary); + * the OUTPUT is always a clean `NodeFieldValues`. + */ +export function sanitizeNodeFields( + type: NodeType, + values: Record, +): NodeFieldValues { + const def = nodeTypeDef(type); + const out: NodeFieldValues = {}; + if (values.parallel !== undefined) { + if (typeof values.parallel === "boolean") out.parallel = values.parallel; + } + for (const field of def.fields) { + if (field.key === "condition") continue; // opaque AST slot, not a primitive + const raw = values[field.key]; + if (raw === undefined) continue; + const cleaned = sanitizeFieldValue(field, raw); + if (cleaned !== undefined) out[field.key] = cleaned; + } + return out; } -/** Export the canvas to the workflow YAML doc (positions/labels stripped). */ -export function editorToYaml( - nodes: EditorNode[], - edges: FlowEdge[], - name: string, -): string { - const cleanNodes = nodes.map(({ position: _position, data: _data, ...rest }) => rest as GraphNode); - return serializeWorkflowGraph( - { - id: "workflow", - name, - nodes: cleanNodes, - edges: edges.map(graphEdgeFrom), - }, - null, - ); +/** + * Fresh canvas id for a type: kebab form of the type id plus a numeric + * suffix when the base is taken (deduped against `taken`). Stable for a + * given input set — no randomness. + */ +export function makeNodeId(type: NodeType, taken: ReadonlySet): string { + const base = type.replace("_", "-").replace(".", "-"); + let candidate = base; + let suffix = 2; + while (taken.has(candidate)) { + candidate = `${base}-${suffix}`; + suffix += 1; + } + return candidate; } -export type YAMLImportResult = - | { ok: true; name: string; nodes: EditorNode[]; edges: FlowEdge[] } - | { ok: false; error: string }; - -/** Import a workflow YAML doc onto the canvas (flow-down layout). */ -export function yamlToEditor(source: string): YAMLImportResult { - let doc: ReturnType; - try { - doc = parseWorkflowGraph(source); - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; +/** Render a primitive field value for display (never throws on odd shapes). */ +export function formatValue( + kind: FieldKind, + value: unknown, +): string { + if (value === undefined || value === null || value === "") return "—"; + if (kind === "number" || kind === "tokens") { + return typeof value === "number" ? String(value) : String(value); + } + if (kind === "toggle") return value === true ? "on" : "off"; + if (Array.isArray(value)) { + return value.length === 0 ? "—" : value.join(", "); } - const graph = doc.graph; - const nodes: EditorNode[] = graph.nodes.map((node, index) => ({ - ...node, - position: { x: 40 + index * 240, y: 120 }, - data: { label: paletteFor(node.type).label }, - })); - return { - ok: true, - name: graph.name ?? graph.id, - nodes, - edges: graph.edges.map(flowEdgeFrom), - }; + if (typeof value === "string") return value; + return String(value); } \ No newline at end of file diff --git a/frontend/src/lib/workflow-validate.test.ts b/frontend/src/lib/workflow-validate.test.ts new file mode 100644 index 0000000..243f324 --- /dev/null +++ b/frontend/src/lib/workflow-validate.test.ts @@ -0,0 +1,173 @@ +/** + * U06 — structural pre-validation mirror tests. + * + * Mirrors the `validateGraph` cases the task mandates structurally: zero + * start, zero end, ghost edges, missing per-type fields, missing loop body + * member (engine-runner admission — a documented superset), and the + * on_429 / tool_calls_route target checks. + */ +import { describe, expect, test } from "bun:test"; +import type { GraphEdge, GraphNode, GraphPipeline } from "../../../src/orchestrator/graph.js"; +import { validateWorkflow } from "./workflow-validate.js"; + +function graph(overrides: { nodes?: GraphNode[]; edges?: GraphEdge[]; id?: string } = {}): GraphPipeline { + return { + id: overrides.id ?? "test", + nodes: + overrides.nodes ?? + [ + { id: "start", type: "start" }, + { id: "end", type: "end" }, + ], + edges: overrides.edges ?? [], + }; +} + +const valid = graph(); + +describe("validateWorkflow — structure", () => { + test("accepts a minimal valid graph (start + end)", () => { + const result = validateWorkflow(valid); + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + test("zero start nodes", () => { + const result = validateWorkflow( + graph({ + nodes: [ + { id: "llm", type: "llm_call", model: "smol" }, + { id: "end", type: "end" }, + ], + }), + ); + expect(result.valid).toBe(false); + expect(result.errors).toContain('graph "test" must have exactly one start node (found 0)'); + }); + + test("multiple start nodes", () => { + const result = validateWorkflow( + graph({ + nodes: [ + { id: "s1", type: "start" }, + { id: "s2", type: "start" }, + { id: "end", type: "end" }, + ], + }), + ); + expect(result.valid).toBe(false); + expect(result.errors[0]).toBe('graph "test" must have exactly one start node (found 2)'); + }); + + test("zero end nodes", () => { + const result = validateWorkflow( + graph({ + nodes: [ + { id: "start", type: "start" }, + { id: "llm", type: "llm_call", model: "smol" }, + ], + }), + ); + expect(result.valid).toBe(false); + expect(result.errors).toContain('graph "test" must have at least one end node'); + }); + + test("ghost edge from side", () => { + const result = validateWorkflow( + graph({ edges: [{ from: "nope", to: "end" }] }), + ); + expect(result.valid).toBe(false); + expect(result.errors).toContain('edge from "nope" references an unknown node'); + }); + + test("ghost edge to side", () => { + const result = validateWorkflow( + graph({ edges: [{ from: "start", to: "nope" }] }), + ); + expect(result.valid).toBe(false); + expect(result.errors).toContain('edge to "nope" references an unknown node'); + }); + + test("missing llm_call model", () => { + const result = validateWorkflow( + graph({ + nodes: [ + { id: "start", type: "start" }, + { id: "llm", type: "llm_call" }, + { id: "end", type: "end" }, + ], + }), + ); + expect(result.valid).toBe(false); + expect(result.errors).toContain('node LLM_CALL (llm) is missing its required "model" field'); + }); + + test("missing required per-type fields", () => { + const result = validateWorkflow( + graph({ + nodes: [ + { id: "start", type: "start" }, + { id: "cond", type: "condition" }, + { id: "loop", type: "loop" }, + { id: "pipe", type: "pipeline" }, + { id: "code", type: "data.code", code: " " }, + { id: "router", type: "router" }, + { id: "end", type: "end" }, + ], + }), + ); + expect(result.valid).toBe(false); + expect(result.errors).toEqual([ + 'node CONDITION (cond) is missing its required "condition" field', + 'node LOOP (loop) is missing its required "body" field', + 'node PIPELINE (pipe) is missing its required "pipeline" field', + 'node DATA.CODE (code) is missing its required "code" field', + 'node ROUTER (router) is missing its required "condition" field', + ]); + }); + + test("missing loop body member (engine-runtime admission)", () => { + const result = validateWorkflow( + graph({ + nodes: [ + { id: "start", type: "start" }, + { id: "loop", type: "loop", body: ["ghost"] }, + { id: "end", type: "end" }, + ], + }), + ); + expect(result.valid).toBe(false); + expect(result.errors).toContain('node LOOP (loop) body references an unknown node "ghost"'); + }); + + test("on_429 and tool_calls_route targets must exist", () => { + const result = validateWorkflow( + graph({ + nodes: [ + { id: "start", type: "start" }, + { id: "llm", type: "llm_call", model: "smol", on_429: "ghost", tool_calls_route: "void" }, + { id: "end", type: "end" }, + ], + }), + ); + expect(result.valid).toBe(false); + expect(result.errors).toContain('node LLM_CALL model smol (llm) references an unknown on_429 target ("ghost")'); + expect(result.errors).toContain( + 'node LLM_CALL model smol (llm) references an unknown tool_calls_route target ("void")', + ); + }); + + test("known model existence is checked only when a set is provided", () => { + const nodes: GraphNode[] = [ + { id: "start", type: "start" }, + { id: "llm", type: "llm_call", model: "nope" }, + { id: "end", type: "end" }, + ]; + expect(validateWorkflow(graph({ nodes })).valid).toBe(true); + const withModels = validateWorkflow(graph({ nodes }), { knownModels: ["smol"] }); + expect(withModels.valid).toBe(false); + expect(withModels.errors).toContain('node LLM_CALL (llm) references an unknown model "nope"'); + // Empty set behaves like absent (backend parity: no model check). + expect(validateWorkflow(graph({ nodes }), { knownModels: [] }).valid).toBe(true); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/workflow-validate.ts b/frontend/src/lib/workflow-validate.ts new file mode 100644 index 0000000..0bb4d3b --- /dev/null +++ b/frontend/src/lib/workflow-validate.ts @@ -0,0 +1,139 @@ +/** + * Client-side structural pre-validation for the workflow editor (U06). + * + * The backend is the authority (`validateGraph`, src/orchestrator/graph.ts): + * the editor runs this mirror BEFORE sending a save so obvious problems stop + * the round-trip early, but the same graph still hits the real validator on + * PUT. This module mirrors the STRUCTURAL subset of `validateGraph` with + * verbatim error wording: + * + * - exactly one start / at least one end + * - edges (and on_429 / tool_calls_route targets) reference real nodes + * - required per-type fields (llm_call.model, condition.condition, + * loop.body, pipeline.pipeline, data.code.code, router.condition) + * - loop body members exist — the engine RUNNER enforces this at runtime + * ("loop body references unknown node"), graph.ts does not; the task + * requires the client to flag it, so this is a documented superset + * - model existence when a known-model set is provided (same as the backend + * `{ knownModels }` option — empty set means "skip") + * + * NOT mirrored (backend-only, deliberate): cycle legality, connectivity / + * reachability, and stale loop-body edges — those need the engine's effective + * edge-set synthesis and are decided by the real validator on save. + * + * Pure functions — no DOM, no Svelte, no stores — runnable under `bun test`. + */ +import type { GraphNode, GraphPipeline } from "./workflow-nodes.js"; + +export interface WorkflowValidation { + /** True when no errors were found (mirror of `GraphValidation.ok`). */ + valid: boolean; + /** Error messages mirroring `validateGraph` wording verbatim. */ + errors: string[]; +} + +/** + * Human-readable node label matching graph.ts's `nodeLabel`: `LLM_CALL model + * SmolLM3 (n3)` when an llm_call carries a model, else `CONDITION (c2)`. + */ +function nodeLabel(n: GraphNode, includeModel = true): string { + const type = n.type.toUpperCase(); + if (includeModel && n.type === "llm_call" && n.model) { + return `LLM_CALL model ${n.model} (${n.id})`; + } + return `${type} (${n.id})`; +} + +/** + * Structural mirror of `validateGraph` (see header for the exact subset). + * + * @param graph engine graph — pass `flowToGraph(nodes, edges)` output + * @param opts `knownModels` — only when non-empty (backend parity) + */ +export function validateWorkflow( + graph: GraphPipeline, + opts: { knownModels?: string[] } = {}, +): WorkflowValidation { + const errors: string[] = []; + const known = new Set(opts.knownModels ?? []); + + const byId = new Map(); + for (const n of graph.nodes) byId.set(n.id, n); + + // Exactly one start. + const starts = graph.nodes.filter((n) => n.type === "start"); + if (starts.length !== 1) { + errors.push(`graph "${graph.id}" must have exactly one start node (found ${starts.length})`); + } + + // At least one end. + const ends = graph.nodes.filter((n) => n.type === "end"); + if (ends.length < 1) { + errors.push(`graph "${graph.id}" must have at least one end node`); + } + + // Edges reference real nodes. + for (const edge of graph.edges) { + if (!byId.has(edge.from)) { + errors.push(`edge from "${edge.from}" references an unknown node`); + } + if (!byId.has(edge.to)) { + errors.push(`edge to "${edge.to}" references an unknown node`); + } + } + + // Required per-type fields. + for (const n of graph.nodes) { + if (n.type === "llm_call" && !n.model) { + errors.push(`node ${nodeLabel(n)} is missing its required "model" field`); + } + if (n.type === "condition" && !n.condition) { + errors.push(`node ${nodeLabel(n)} is missing its required "condition" field`); + } + if (n.type === "loop" && (!n.body || n.body.length === 0)) { + errors.push(`node ${nodeLabel(n)} is missing its required "body" field`); + } + if (n.type === "pipeline" && !n.pipeline) { + errors.push(`node ${nodeLabel(n)} is missing its required "pipeline" field`); + } + if (n.type === "data.code" && (!n.code || n.code.trim() === "")) { + errors.push(`node ${nodeLabel(n)} is missing its required "code" field`); + } + if (n.type === "router" && !n.condition) { + errors.push(`node ${nodeLabel(n)} is missing its required "condition" field`); + } + } + + // Loop body members exist — engine-runner admission (super-set, see header). + for (const n of graph.nodes) { + if (n.type !== "loop" || !Array.isArray(n.body)) continue; + for (const memberId of n.body) { + if (!byId.has(memberId)) { + errors.push(`node ${nodeLabel(n)} body references an unknown node "${memberId}"`); + } + } + } + + // Broken fallback/tool-calls routes (graph.ts wording). + for (const n of graph.nodes) { + if (n.on_429 && !byId.has(n.on_429)) { + errors.push(`node ${nodeLabel(n)} references an unknown on_429 target ("${n.on_429}")`); + } + if (n.tool_calls_route && !byId.has(n.tool_calls_route)) { + errors.push( + `node ${nodeLabel(n)} references an unknown tool_calls_route target ("${n.tool_calls_route}")`, + ); + } + } + + // Model existence (only when a known-model set is provided). + if (opts.knownModels && opts.knownModels.length > 0) { + for (const n of graph.nodes) { + if (n.type === "llm_call" && n.model && !known.has(n.model)) { + errors.push(`node ${nodeLabel(n, false)} references an unknown model "${n.model}"`); + } + } + } + + return { valid: errors.length === 0, errors }; +} \ No newline at end of file diff --git a/frontend/src/lib/workflows-ui.test.ts b/frontend/src/lib/workflows-ui.test.ts new file mode 100644 index 0000000..234bac0 --- /dev/null +++ b/frontend/src/lib/workflows-ui.test.ts @@ -0,0 +1,143 @@ +/** + * Unit tests for the workflow library display helpers (workflows-ui.ts). + * No IO: "now" is injected, and absolute-timestamp expectations are built + * through the same Date API the helpers use, so assertions are timezone-robust. + */ + +import { describe, expect, test } from "bun:test"; +import { + formatLocalDateTime, + formatMs, + formatUpdatedAt, + runOutcomeContent, + workflowRow, +} from "./workflows-ui.js"; +import type { RunWorkflowResult, WorkflowRecord } from "./api/types.js"; + +const NOW = 1_700_000_000_000; // 2023-11-14T22:13:20Z +const MINUTE_MS = 60_000; +const DAY_MS = 86_400_000; + +function isoAgo(msAgo: number): string { + return new Date(NOW - msAgo).toISOString(); +} + +describe("formatUpdatedAt", () => { + test("invalid timestamps render em dash", () => { + expect(formatUpdatedAt("not-a-date", NOW)).toBe("—"); + expect(formatUpdatedAt("", NOW)).toBe("—"); + }); + + test("the present and near-future render just now", () => { + expect(formatUpdatedAt(isoAgo(0), NOW)).toBe("just now"); + expect(formatUpdatedAt(isoAgo(-5 * MINUTE_MS), NOW)).toBe("just now"); + }); + + test("under an hour renders minutes ago", () => { + expect(formatUpdatedAt(isoAgo(MINUTE_MS), NOW)).toBe("1 min ago"); + expect(formatUpdatedAt(isoAgo(59 * MINUTE_MS), NOW)).toBe("59 min ago"); + }); + + test("under a day renders hours ago", () => { + expect(formatUpdatedAt(isoAgo(2 * 60 * MINUTE_MS), NOW)).toBe("2 h ago"); + expect(formatUpdatedAt(isoAgo(23 * 60 * MINUTE_MS), NOW)).toBe("23 h ago"); + }); + + test("under seven days renders days ago", () => { + expect(formatUpdatedAt(isoAgo(DAY_MS), NOW)).toBe("1 d ago"); + expect(formatUpdatedAt(isoAgo(6 * DAY_MS + 12 * 60 * MINUTE_MS), NOW)).toBe("6 d ago"); + }); + + test("seven days or older renders an absolute local timestamp", () => { + const date = new Date(2026, 0, 15, 9, 30); // local wall clock + const formatted = formatUpdatedAt(date.toISOString(), date.getTime() + 7 * DAY_MS); + expect(formatted).toBe("2026-01-15 09:30"); + }); +}); + +describe("formatLocalDateTime", () => { + test("zero-pads month, day, hours and minutes", () => { + expect(formatLocalDateTime(new Date(2026, 0, 5, 7, 5))).toBe("2026-01-05 07:05"); + expect(formatLocalDateTime(new Date(2026, 11, 31, 23, 59))).toBe("2026-12-31 23:59"); + }); +}); + +describe("formatMs", () => { + test("null and invalid durations render em dash", () => { + expect(formatMs(null)).toBe("—"); + expect(formatMs(-5)).toBe("—"); + expect(formatMs(Number.NaN)).toBe("—"); + }); + + test("sub-second durations render milliseconds", () => { + expect(formatMs(0)).toBe("0ms"); + expect(formatMs(384)).toBe("384ms"); + expect(formatMs(999)).toBe("999ms"); + }); + + test("one second and beyond render seconds with one decimal", () => { + expect(formatMs(1000)).toBe("1.0s"); + expect(formatMs(1200)).toBe("1.2s"); + expect(formatMs(1500)).toBe("1.5s"); + expect(formatMs(123_456)).toBe("123.5s"); + }); +}); + +function sampleRecord(version = 2): WorkflowRecord { + return { name: "summary", version, updatedAt: "2026-09-20T10:00:00Z" }; +} + +describe("workflowRow", () => { + test("passes the record through untouched without a version hint", () => { + expect(workflowRow(sampleRecord(), null)).toEqual(sampleRecord()); + }); + + test("a version hint overrides the version column", () => { + expect(workflowRow(sampleRecord(2), 5).version).toBe(5); + }); + + test("an explicit null hint keeps the record version", () => { + expect(workflowRow(sampleRecord(3), null).version).toBe(3); + }); +}); + +function sampleCompletion(): RunWorkflowResult { + return { + id: "cmpl-1", + object: "chat.completion", + created: 1, + model: "gateway/summary", + choices: [ + { index: 0, message: { role: "assistant", content: "Hello!" }, finish_reason: "stop" }, + ], + usage: null, + }; +} + +describe("runOutcomeContent", () => { + test("extracts the first assistant completion text", () => { + expect(runOutcomeContent(sampleCompletion())).toBe("Hello!"); + }); + + test("an empty choices list yields null", () => { + expect(runOutcomeContent({ ...sampleCompletion(), choices: [] })).toBeNull(); + }); + + test("an empty completion text is preserved, not nulled", () => { + const empty: RunWorkflowResult = { + ...sampleCompletion(), + choices: [ + { index: 0, message: { role: "assistant", content: "" }, finish_reason: "stop" }, + ], + }; + expect(runOutcomeContent(empty)).toBe(""); + }); + + test("a missing content field yields null (defensive)", () => { + const malformed = { + ...sampleCompletion(), + choices: [{ index: 0, message: { role: "assistant" }, finish_reason: "stop" }], + } as unknown as RunWorkflowResult; + expect(runOutcomeContent(malformed)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/workflows-ui.ts b/frontend/src/lib/workflows-ui.ts new file mode 100644 index 0000000..3f36c34 --- /dev/null +++ b/frontend/src/lib/workflows-ui.ts @@ -0,0 +1,87 @@ +/** + * Pure display helpers for the workflow library surface (U04). + * + * Every function here is deterministic and unit-tested in + * workflows-ui.test.ts. "Now" is injected (default Date.now()) so relative + * timestamps stay pure — the same seam buildOverviewSnapshot uses for its + * fetch-time input. No IO, no Svelte imports: importable under bun:test. + */ + +import type { RunWorkflowResult, WorkflowRecord } from "./api/types.js"; + +const MINUTE_MS = 60_000; +const DAY_MS = 86_400_000; +/** Relative timestamps switch to absolute beyond this window (7 days). */ +const RELATIVE_WINDOW_MS = 7 * DAY_MS; + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +/** + * Local wall-clock "YYYY-MM-DD HH:mm" (24h), locale-neutral. Exported so + * tests can assert it with locally-constructed Date objects (timezone-robust). + */ +export function formatLocalDateTime(date: Date): string { + return ( + `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` + + `${pad2(date.getHours())}:${pad2(date.getMinutes())}` + ); +} + +/** + * Relative "x min ago" / "x h ago" / "x d ago" under 7 days, absolute local + * "YYYY-MM-DD HH:mm" beyond that. The present (and near-future timestamps) + * render "just now"; invalid timestamps render "—". + */ +export function formatUpdatedAt(iso: string, now: number = Date.now()): string { + const time = new Date(iso).getTime(); + if (!Number.isFinite(time)) return "—"; + const diff = now - time; + if (diff < MINUTE_MS) return "just now"; + if (diff < DAY_MS) { + const minutes = Math.floor(diff / MINUTE_MS); + if (minutes < 60) return `${minutes} min ago`; + return `${Math.floor(minutes / 60)} h ago`; + } + if (diff < RELATIVE_WINDOW_MS) { + return `${Math.floor(diff / DAY_MS)} d ago`; + } + return formatLocalDateTime(new Date(time)); +} + +/** + * Duration label: "1.2s" at one second and beyond, "384ms" below. Null or + * otherwise invalid durations render "—". + */ +export function formatMs(ms: number | null): string { + if (ms === null || !Number.isFinite(ms) || ms < 0) return "—"; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * Display row for the library list. The backend record is the source of + * truth; `yamlVersionHint` overrides the version column only when the caller + * holds a fresher revision (e.g. right after a save). The list itself passes + * null and always treats the API list as truth. + */ +export function workflowRow( + record: WorkflowRecord, + yamlVersionHint: number | null = null, +): WorkflowRecord { + return { + name: record.name, + version: yamlVersionHint ?? record.version, + updatedAt: record.updatedAt, + }; +} + +/** + * The assistant completion text of a run response, or null when the shape + * carries no extractable content (defensive — never throws). + */ +export function runOutcomeContent(result: RunWorkflowResult): string | null { + const content = result.choices?.[0]?.message?.content; + return typeof content === "string" ? content : null; +} \ No newline at end of file diff --git a/frontend/src/lib/ws.test.ts b/frontend/src/lib/ws.test.ts new file mode 100644 index 0000000..b9ff596 --- /dev/null +++ b/frontend/src/lib/ws.test.ts @@ -0,0 +1,249 @@ +/** + * bun:test suite for the workflow-run socket client (ws.ts). A controllable + * FakeWebSocket stands in for the browser global — every drive helper fires + * handlers synchronously, so event order is deterministic under bun:test. + */ + +import { describe, expect, test } from "bun:test"; +import { getWsOrigin } from "./api/config.js"; +import { connectWorkflowSocket } from "./ws.js"; +import type { WorkflowChatMessage, WorkflowSocket, WorkflowWsEvent } from "./ws.js"; + +/** Minimal browser-WebSocket-shaped stand-in with test drive helpers. */ +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + + readonly sent: string[] = []; + url: string; + readyState: number; + onopen: ((event: unknown) => void) | null = null; + onmessage: ((event: { data: unknown }) => void) | null = null; + onclose: ((event: { code?: number; reason?: string }) => void) | null = null; + + constructor(url: string) { + this.url = url; + this.readyState = 0; // CONNECTING + FakeWebSocket.instances.push(this); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + if (this.readyState === 3) return; + this.readyState = 3; // CLOSED + this.onclose?.({ code: 1000, reason: "closed" }); + } + + // ── test drive helpers ──────────────────────────────────────────────── + + open(): void { + this.readyState = 1; // OPEN + this.onopen?.({}); + } + + receive(data: string): void { + this.onmessage?.({ data }); + } + + serverClose(): void { + if (this.readyState === 3) return; + this.readyState = 3; + this.onclose?.({ code: 1006, reason: "connection dropped" }); + } +} + +function fakeCtor(): typeof WebSocket { + return FakeWebSocket as unknown as typeof WebSocket; +} + +const BIND = (workflow: string): string => JSON.stringify({ type: "bind", workflow }); +const RUN = (messages: WorkflowChatMessage[]): string => + JSON.stringify({ type: "run", messages }); + +const USER: WorkflowChatMessage[] = [{ role: "user", content: "hi" }]; + +function connect( + workflow = "demo", +): { socket: WorkflowSocket; fake: FakeWebSocket; events: WorkflowWsEvent[]; closes: number[] } { + const events: WorkflowWsEvent[] = []; + const closes: number[] = []; + const socket = connectWorkflowSocket({ + workflow, + ws: fakeCtor(), + onEvent: (event) => events.push(event), + onClose: () => { + closes.push(1); + }, + }); + const fake = FakeWebSocket.instances.at(-1) as FakeWebSocket; + return { socket, fake, events, closes }; +} + +async function openAndBind( + workflow = "demo", +): Promise<{ + socket: WorkflowSocket; + fake: FakeWebSocket; + events: WorkflowWsEvent[]; + closes: number[]; +}> { + const state = connect(workflow); + state.fake.open(); + expect(state.fake.sent).toEqual([BIND(workflow)]); + state.fake.receive(JSON.stringify({ type: "status", workflow, state: "bound" })); + expect(await state.socket.ready).toBe(true); + return state; +} + +describe("connectWorkflowSocket", () => { + test("default URL is the ws origin plus /ws", () => { + connect("demo"); + const fake = FakeWebSocket.instances.at(-1) as FakeWebSocket; + expect(fake.url).toBe(`${getWsOrigin()}/ws`); + }); + + test("binds on open and forwards events verbatim", async () => { + const { fake, events } = await openAndBind("demo"); + const started = JSON.stringify({ + type: "step_started", + workflow: "demo", + nodeId: "llm-1", + nodeType: "llm_call", + }); + fake.receive(started); + expect(events).toHaveLength(2); // bound + step_started + expect(events[0]).toEqual({ type: "status", workflow: "demo", state: "bound" }); + expect(events[1]).toEqual({ + type: "step_started", + workflow: "demo", + nodeId: "llm-1", + nodeType: "llm_call", + }); + }); + + test("a run sent after binding goes out with the run envelope", async () => { + const { socket, fake } = await openAndBind("demo"); + socket.sendRun(USER); + expect(fake.sent.at(-1)).toBe(RUN(USER)); + }); + + test("a run sent before binding is queued and flushed on bound", async () => { + const { socket, fake, events } = connect("demo"); + socket.sendRun(USER); + expect(fake.sent).toEqual([]); // nothing sent before bound + fake.open(); + expect(fake.sent).toEqual([BIND("demo")]); // still only the bind + expect(events).toEqual([]); + fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "bound" })); + expect(await socket.ready).toBe(true); + expect(fake.sent).toContain(RUN(USER)); // flushed after bound + }); + + test("a queued run surfaces an error when the bind fails", async () => { + const { socket, events } = connect("demo"); + socket.sendRun(USER); + socket.ready.then(() => undefined); // keep the promise observed + // Bind-phase protocol error → ready false + the queued run is reported. + const fake = FakeWebSocket.instances.at(-1) as FakeWebSocket; + fake.open(); + fake.receive( + JSON.stringify({ type: "error", workflow: null, error: 'unknown workflow "ghost"' }), + ); + expect(await socket.ready).toBe(false); + expect(events.some((e) => e.type === "error" && e.error.includes("run requested before"))).toBe( + true, + ); + // The server's bind error is forwarded too. + expect(events.some((e) => e.type === "error" && e.error.includes("unknown workflow"))).toBe( + true, + ); + }); + + test("an unknown server message surfaces as a local error event", async () => { + const { fake, events } = await openAndBind("demo"); + fake.receive(JSON.stringify({ type: "telemetry", workflow: "demo", watts: 12 })); + const err = events.at(-1); + expect(err?.type).toBe("error"); + expect((err as { error: string }).error).toBe("received an unrecognized message from the server"); + expect(events.filter((e) => e.type === "error")).toHaveLength(1); + }); + + test("a non-JSON server frame surfaces as a local error event", async () => { + const { fake, events } = await openAndBind("demo"); + fake.receive("this is not json {"); + const err = events.at(-1); + expect(err?.type).toBe("error"); + expect((err as { error: string }).error).toBe("received a message that is not valid JSON"); + }); + + test("an unexpected close during a run emits an honest error (no reconnect)", async () => { + const { socket, fake, events, closes } = await openAndBind("demo"); + socket.sendRun(USER); + fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "running" })); + fake.serverClose(); + const err = events.at(-1); + expect(err?.type).toBe("error"); + expect((err as { error: string }).error).toBe("connection closed during run"); + expect(closes).toHaveLength(1); + // No further frames were sent — no silent reconnect. + expect(fake.sent.at(-1)).toBe(RUN(USER)); + }); + + test("a caller-initiated close does not emit a mid-run error", async () => { + const { socket, fake, events, closes } = await openAndBind("demo"); + socket.sendRun(USER); + fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "running" })); + socket.close(); + expect(events.some((e) => e.type === "error")).toBe(false); + expect(closes).toHaveLength(1); + }); + + test("a socket that never opens resolves ready false", async () => { + const { socket, fake } = connect("demo"); + socket.ready.then(() => undefined); + fake.serverClose(); + expect(await socket.ready).toBe(false); + }); + + test("a full ok run forwards running → token → ok unchanged", async () => { + const { socket, fake, events } = await openAndBind("demo"); + socket.sendRun(USER); + fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "running" })); + fake.receive( + JSON.stringify({ + type: "token", + workflow: "demo", + data: 'data: {"choices":[{"message":{"content":"Hello!"}}]}\n\n', + }), + ); + fake.receive(JSON.stringify({ type: "token", workflow: "demo", data: "data: [DONE]\n\n" })); + fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "ok" })); + expect(events.map((e) => e.type)).toEqual([ + "status", // bound + "status", // running + "token", + "token", + "status", // ok + ]); + const tokens = events.filter((e) => e.type === "token"); + expect(tokens.some((e) => e.type === "token" && e.data.includes("[DONE]"))).toBe(true); + }); + + test("a run-phase error (bound) is forwarded without resolving ready", async () => { + const { socket, fake, events } = await openAndBind("demo"); + socket.sendRun(USER); + fake.receive(JSON.stringify({ type: "status", workflow: "demo", state: "running" })); + fake.receive( + JSON.stringify({ type: "error", workflow: "demo", nodeId: "llm-1", error: "boom" }), + ); + expect(events.at(-1)).toEqual({ + type: "error", + workflow: "demo", + nodeId: "llm-1", + error: "boom", + }); + expect(await socket.ready).toBe(true); // already settled at bind + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts new file mode 100644 index 0000000..4e70397 --- /dev/null +++ b/frontend/src/lib/ws.ts @@ -0,0 +1,230 @@ +/** + * Typed workflow-run socket client (frontend, U07). + * + * Mirrors the REAL server wire protocol in src/app/ws.ts field-for-field: + * + * client → {type:"bind", workflow} then {type:"run", messages} + * server → status | step_started | step_completed | token | error + * + * Responsibilities: + * - binds on open; + * - queues run requests made before binding, flushes them once bound, and + * surfaces an error if the bind fails instead of dropping the run; + * - surfaces every server frame as a typed event; client-side parse + * failures become local error events — never swallowed; + * - on an unexpected close during a run emits "connection closed during + * run" — no silent auto-reconnect mid-run. + * + * The browser's standard WebSocket is used; tests inject a stand-in via + * `ws`. The default URL is `${getWsOrigin()}/ws` (api/config.ts). + */ + +import { getWsOrigin } from "./api/config.js"; + +/** Server → client events (same field names as src/app/ws.ts WsEvent). */ +export type WorkflowWsEvent = + | { + type: "status"; + workflow: string | null; + state: "bound" | "running" | "ok" | "error"; + error?: string; + } + | { type: "step_started"; workflow: string; nodeId: string; nodeType: string } + | { type: "step_completed"; workflow: string; nodeId: string; ms: number } + | { type: "token"; workflow: string; data: string } + | { type: "error"; workflow: string | null; nodeId?: string; error: string }; + +export interface WorkflowChatMessage { + role: string; + content: string; +} + +export interface WorkflowSocketOptions { + workflow: string; + /** Injectable in tests; defaults to the browser global WebSocket. */ + ws?: typeof WebSocket; + /** Full socket URL; defaults to `${getWsOrigin()}/ws`. */ + url?: string; + /** Every decoded server event (and local protocol errors). */ + onEvent: (event: WorkflowWsEvent) => void; + /** Fired on every socket close (caller-initiated or not). */ + onClose?: () => void; +} + +export interface WorkflowSocket { + /** Send a run; queued until the socket is bound, then flushed. */ + sendRun(messages: WorkflowChatMessage[]): void; + /** Graceful close; caller-initiated closes never emit mid-run errors. */ + close(): void; + /** Resolves true on status:"bound", false when binding fails. */ + ready: Promise; +} + +/** + * Shape-check a decoded server frame into the typed event union. Null for + * anything that is not a well-formed server envelope (surfaced by the caller + * as a local error event instead of being swallowed). + */ +function toWorkflowWsEvent(value: unknown): WorkflowWsEvent | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + const msg = value as Record; + switch (msg.type) { + case "status": { + const workflow = msg.workflow; + const state = msg.state; + if (workflow !== null && typeof workflow !== "string") return null; + if (state !== "bound" && state !== "running" && state !== "ok" && state !== "error") { + return null; + } + const error = typeof msg.error === "string" ? msg.error : undefined; + return { type: "status", workflow, state, error }; + } + case "step_started": { + const workflow = msg.workflow; + const nodeId = msg.nodeId; + const nodeType = msg.nodeType; + if ( + typeof workflow !== "string" || + typeof nodeId !== "string" || + typeof nodeType !== "string" + ) { + return null; + } + return { type: "step_started", workflow, nodeId, nodeType }; + } + case "step_completed": { + const workflow = msg.workflow; + const nodeId = msg.nodeId; + const ms = msg.ms; + if (typeof workflow !== "string" || typeof nodeId !== "string" || typeof ms !== "number") { + return null; + } + return { type: "step_completed", workflow, nodeId, ms }; + } + case "token": { + const workflow = msg.workflow; + const data = msg.data; + if (typeof workflow !== "string" || typeof data !== "string") return null; + return { type: "token", workflow, data }; + } + case "error": { + const workflow = msg.workflow; + const error = msg.error; + const nodeId = msg.nodeId; + if (workflow !== null && typeof workflow !== "string") return null; + if (typeof error !== "string") return null; + if (nodeId !== undefined && typeof nodeId !== "string") return null; + return { type: "error", workflow, error, nodeId }; + } + default: + return null; + } +} + +export function connectWorkflowSocket(options: WorkflowSocketOptions): WorkflowSocket { + const Ctor = options.ws ?? WebSocket; + const url = options.url ?? `${getWsOrigin()}/ws`; + const socket = new Ctor(url); + + let bound = false; + let runInFlight = false; + let closedByCaller = false; + let settled = false; + let resolveReady: (ok: boolean) => void = () => undefined; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const queued: WorkflowChatMessage[][] = []; + + function emit(event: WorkflowWsEvent): void { + options.onEvent(event); + } + + function emitError(error: string): void { + emit({ type: "error", workflow: options.workflow, error }); + } + + function settle(ok: boolean): void { + if (settled) return; + settled = true; + resolveReady(ok); + // A queued run that can never be flushed is an error the caller must see. + if (!ok && !closedByCaller && queued.length > 0) { + emitError("run requested before the socket bound; the run was not sent"); + } + } + + function sendRunFrame(messages: WorkflowChatMessage[]): void { + runInFlight = true; + socket.send(JSON.stringify({ type: "run", messages })); + } + + function handle(event: WorkflowWsEvent): void { + switch (event.type) { + case "status": + if (event.state === "bound") { + bound = true; + settle(true); + for (const messages of queued.splice(0)) sendRunFrame(messages); + } else if (event.state === "running") { + runInFlight = true; + } else { + runInFlight = false; // ok | error ends the in-flight run + } + break; + case "error": + // A protocol-level error before binding means the bind failed. + if (!bound) settle(false); + break; + default: + break; + } + emit(event); + } + + socket.onopen = () => { + socket.send(JSON.stringify({ type: "bind", workflow: options.workflow })); + }; + + socket.onmessage = (event) => { + let parsed: unknown; + try { + parsed = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)); + } catch { + emitError("received a message that is not valid JSON"); + return; + } + const typed = toWorkflowWsEvent(parsed); + if (typed === null) { + emitError("received an unrecognized message from the server"); + return; + } + handle(typed); + }; + + socket.onclose = () => { + const wasInFlight = runInFlight; + runInFlight = false; + if (!closedByCaller && wasInFlight) { + emitError("connection closed during run"); + } + if (!settled) settle(false); + options.onClose?.(); + }; + + function sendRun(messages: WorkflowChatMessage[]): void { + if (bound) { + sendRunFrame(messages); + return; + } + queued.push(messages); + } + + function close(): void { + if (closedByCaller) return; + closedByCaller = true; + socket.close(); + } + + return { sendRun, close, ready }; +} \ No newline at end of file diff --git a/frontend/src/pages/about.astro b/frontend/src/pages/about.astro new file mode 100644 index 0000000..ffeed66 --- /dev/null +++ b/frontend/src/pages/about.astro @@ -0,0 +1,182 @@ +--- +import AppLayout from "../layouts/AppLayout.astro"; +import pkg from "../../../package.json"; + +/** + * About — static build-time facts, nothing invented. Package metadata + * (name/version/license/homepage) comes from the root package.json at + * build time; the stack lines are the actual runtime facts of the project. + * The console is admin-only and stores no credentials. + */ + +const packageRows = [ + { label: "Package name", value: pkg.name }, + { label: "Version", value: `v${pkg.version}` }, + { label: "License", value: pkg.license }, +]; + +const stackRows = [ + { + label: "Runtime", + value: "Bun ≥ 1.4", + note: "Server, build tooling and tests run on Bun — not Node.", + }, + { + label: "Language", + value: "TypeScript strict", + note: "No implicit any; ESM with explicit .js imports.", + }, + { + label: "Frontend", + value: "Astro 7 static + Svelte 5 islands", + note: "Static shell with hydrated islands; no SPA router.", + }, + { + label: "Backend", + value: "llama.cpp via llama-server", + note: "Managed local backend behind an OpenAI-compatible proxy.", + }, +]; +--- + + +
+

About

+

+ WeaveLLM — a local AI desktop runtime: managed llama.cpp backend, + visual workflow editor, model management and an OpenAI-compatible + proxy. Everything on this page is build-time package data or the + actual stack — nothing is simulated. +

+
+ +
+
+

Package

+
+ {packageRows.map((row) => ( +
+

{row.label}

+

{row.value}

+
+ ))} +
+

Homepage

+ + {pkg.homepage} + +
+
+
+ +
+

Stack

+
+ {stackRows.map((row) => ( +
+

{row.label}

+

{row.value}

+

{row.note}

+
+ ))} +
+
+ +
+

Admin console

+

+ This console is admin-only and stores no credentials. The auth gate + (WEAVELLM_AUTH) and provider keys live server-side; the console only + probes the real backend surface and never simulates data. +

+
+
+
+ + \ No newline at end of file diff --git a/frontend/src/pages/api.astro b/frontend/src/pages/api.astro new file mode 100644 index 0000000..bc6253d --- /dev/null +++ b/frontend/src/pages/api.astro @@ -0,0 +1,40 @@ +--- +import AppLayout from "../layouts/AppLayout.astro"; +import Playground from "../svelte/playground/Playground.svelte"; + +/** + * /api — legacy alias of the API playground (U11): the canonical route is + * /playground (nav points there); this URL keeps working so existing + * bookmarks and deep links never 404. Same island, same wire surface. + */ +--- + + +
+

API playground

+

+ Exercise POST /v1/chat/completions against the real wire. The canonical + route is /playground. +

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/executions/index.astro b/frontend/src/pages/executions/index.astro new file mode 100644 index 0000000..f5cef4a --- /dev/null +++ b/frontend/src/pages/executions/index.astro @@ -0,0 +1,39 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import ExecutionsList from "../../svelte/executions/ExecutionsList.svelte"; + +/** + * Executions — per-workflow run history. The island owns all data: it talks + * to the real /api/workflows and /api/workflows/:name/logs surface through + * the typed client (no server-side fetch here) and reports loading/error/ + * empty states honestly. There is no global executions endpoint, so the page + * aggregates per-workflow logs it actually fetches — never invented totals. + */ +--- + + +
+

Executions

+

+ Per-workflow run history straight from the backend logs — latest run up + front, full history on expand. No auto-refresh: a completed run is + immutable, so data refreshes on demand. +

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/index.astro b/frontend/src/pages/index.astro index 25064fa..51585f1 100644 --- a/frontend/src/pages/index.astro +++ b/frontend/src/pages/index.astro @@ -1,37 +1,37 @@ --- -// WeaveLLM shell: static SPA hosting Svelte islands. -import ModelBadge from "../svelte/ModelBadge.svelte"; -import WorkflowEditor from "../svelte/WorkflowEditor.svelte"; -import "../styles/global.css"; +import AppLayout from "../layouts/AppLayout.astro"; +import Overview from "../svelte/overview/Overview.svelte"; + +/** + * Overview — console landing page. The live dashboard island gathers real + * values through the typed API clients (runtime health, model registry, + * workflow store) and refreshes every 10 seconds. It never invents metrics: + * unavailable values render as "—"/"Unavailable" with an honest reason. + */ --- - - - - - - WeaveLLM - - -
- -

WeaveLLM

- Local model runtime -
-
-
-

Local models

- -
-
-

Workflow editor

- -
-
- - \ No newline at end of file + +
+

Overview

+

+ Live runtime summary — backend endpoint, model registry state and recent + workflows, refreshed automatically from the /api surface. +

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/models/[id].astro b/frontend/src/pages/models/[id].astro new file mode 100644 index 0000000..1aefc68 --- /dev/null +++ b/frontend/src/pages/models/[id].astro @@ -0,0 +1,24 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import EmptyState from "../../svelte/common/EmptyState.svelte"; + +/** + * Per-model details. The route is dynamic; the static build emits no pages + * until model ids can come from the real backend (getStaticPaths stays empty + * in this phase — no fabricated models). Dev mode serves any :id on demand. + */ +export function getStaticPaths() { + return []; +} + +const { id } = Astro.params; +const modelId = id ?? "unknown"; +--- + + + + \ No newline at end of file diff --git a/frontend/src/pages/models/catalog.astro b/frontend/src/pages/models/catalog.astro new file mode 100644 index 0000000..597f515 --- /dev/null +++ b/frontend/src/pages/models/catalog.astro @@ -0,0 +1,32 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import EmptyState from "../../svelte/common/EmptyState.svelte"; + +/** + * Model catalog. Honest state: there is NO catalog endpoint in the public + * /api surface (verified against src/routes/api.ts + src/app/server.ts — the + * backend exposes only /api/health, /api/models and /api/workflows). The + * catalog source (src/catalog/) is backend-internal, so this section shows + * the "Coming from backend" state instead of inventing an endpoint or data. + */ +--- + + +
+

Model catalog

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/models/downloads.astro b/frontend/src/pages/models/downloads.astro new file mode 100644 index 0000000..514711c --- /dev/null +++ b/frontend/src/pages/models/downloads.astro @@ -0,0 +1,32 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import EmptyState from "../../svelte/common/EmptyState.svelte"; + +/** + * Model downloads. Honest state: there is NO downloads endpoint in the + * public /api surface (verified against src/routes/api.ts + + * src/app/server.ts). The gosh download engine (src/downloads/) is + * backend-internal, so this section shows the "Coming from backend" state + * instead of inventing an endpoint or data. + */ +--- + + +
+

Downloads

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/models/index.astro b/frontend/src/pages/models/index.astro new file mode 100644 index 0000000..fd00a3f --- /dev/null +++ b/frontend/src/pages/models/index.astro @@ -0,0 +1,37 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import ModelsRegistry from "../../svelte/models/ModelsRegistry.svelte"; + +/** + * Models registry — the island owns all data: it talks to the real + * /api/models surface through the typed client (auth-gated on the backend), + * and reports loading/error/empty states honestly. This page renders only + * the heading shell. + */ +--- + + +
+

Models

+

+ The managed llama-server registry — activation state, PID and port come + directly from the backend models API. +

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/playground/index.astro b/frontend/src/pages/playground/index.astro new file mode 100644 index 0000000..030c2ea --- /dev/null +++ b/frontend/src/pages/playground/index.astro @@ -0,0 +1,45 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import Playground from "../../svelte/playground/Playground.svelte"; + +/** + * API playground (U11) — exercise POST /v1/chat/completions against the real + * wire. The island owns all data: typed v1 client (JSON + OpenAI-wire SSE), + * model suggestions from the real /api/models + /api/workflows surfaces + * (non-authoritative — external adapter ids resolve server-side). The console + * never sends credentials; when WEAVELLM_AUTH is on the server answers /v1 + * with its 401 envelope and the playground shows it verbatim. + */ +--- + + +
+

API playground

+

+ POST /v1/chat/completions against the real wire — JSON for + non-streaming, OpenAI-wire SSE with a terminal [DONE] for + stream. Responses render verbatim; no derived metrics. +

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/providers/[kind].astro b/frontend/src/pages/providers/[kind].astro new file mode 100644 index 0000000..b2c2033 --- /dev/null +++ b/frontend/src/pages/providers/[kind].astro @@ -0,0 +1,22 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import EmptyState from "../../svelte/common/EmptyState.svelte"; + +/** + * Provider kind details for the three contract kinds (openai | anthropic | + * openrouter). These are real kinds from the backend provider registry — + * static pages render the honest "Coming from backend" state because the + * public API does not expose provider rows. Unknown slugs 404 in production. + */ +export function getStaticPaths() { + const kinds = ["openai", "anthropic", "openrouter"]; + return kinds.map((kind) => ({ params: { kind } })); +} + +const { kind } = Astro.params; +const providerKind = kind ?? "unknown"; +--- + + + + \ No newline at end of file diff --git a/frontend/src/pages/providers/index.astro b/frontend/src/pages/providers/index.astro new file mode 100644 index 0000000..2bf80d4 --- /dev/null +++ b/frontend/src/pages/providers/index.astro @@ -0,0 +1,8 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import EmptyState from "../../svelte/common/EmptyState.svelte"; +--- + + + + \ No newline at end of file diff --git a/frontend/src/pages/runtime/index.astro b/frontend/src/pages/runtime/index.astro new file mode 100644 index 0000000..b017565 --- /dev/null +++ b/frontend/src/pages/runtime/index.astro @@ -0,0 +1,41 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import RuntimePanel from "../../svelte/runtime/RuntimePanel.svelte"; +import pkg from "../../../../package.json"; + +/** + * Runtime — backend health, endpoints, auth gate, websocket handshake and + * version. The island owns all live data through the typed API client (no + * server-side fetch here): GET /api/health (timed), the runtime auth probe + * and a one-shot WS /ws connection probe. Values the browser cannot observe + * (server env like the listening port) are never invented — the page shows + * the client-side origin and marks the rest as coming from the backend. + * Version comes from the root package.json at build time. + */ +--- + + +
+

Runtime

+

+ Backend health, the endpoints this console talks to, the auth gate and + a live WebSocket handshake probe — all against the real surface. +

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/settings/index.astro b/frontend/src/pages/settings/index.astro new file mode 100644 index 0000000..b758b19 --- /dev/null +++ b/frontend/src/pages/settings/index.astro @@ -0,0 +1,39 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import SettingsPanel from "../../svelte/settings/Settings.svelte"; + +/** + * Settings — read-only console settings. There is no settings API on the + * backend (server config is environment-driven: WEAVELLM_HOST/PORT/AUTH/ + * APP_DATA), so this page never mutates anything and shows no forms. The + * island probes the auth gate live through the typed API client and marks + * server-owned configuration (host/port/app-data/store) honestly as coming + * from the backend — the public API does not expose those values. + */ +--- + + +
+

Settings

+

+ Read-only: the auth gate is probed live, and server-owned configuration + is shown honestly. Nothing here mutates the server. +

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/pages/workflows/[name].astro b/frontend/src/pages/workflows/[name].astro new file mode 100644 index 0000000..03ca09b --- /dev/null +++ b/frontend/src/pages/workflows/[name].astro @@ -0,0 +1,83 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import Icon from "../../svelte/common/Icon.svelte"; +import FlowEditor from "../../svelte/workflow-editor/FlowEditor.svelte"; + +/** + * Workflow editor route (U05). The island loads the named workflow through + * the typed API client and renders the SvelteFlow surface — demo is the only + * statically prerendered name; other stored names hydrate client-side the + * same way once the real store surfaces them. + */ +export function getStaticPaths() { + return [{ params: { name: "demo" } }]; +} + +const { name } = Astro.params; +const workflowName = name ?? "demo"; +--- + + +
+
+ + + Workflows + +

{workflowName}

+
+ +
+ +
+
+
+ + \ No newline at end of file diff --git a/frontend/src/pages/workflows/index.astro b/frontend/src/pages/workflows/index.astro new file mode 100644 index 0000000..ed000d4 --- /dev/null +++ b/frontend/src/pages/workflows/index.astro @@ -0,0 +1,37 @@ +--- +import AppLayout from "../../layouts/AppLayout.astro"; +import WorkflowsList from "../../svelte/workflows/WorkflowsList.svelte"; + +/** + * Workflow library — the list island owns all data: it talks to the real + * /api/workflows surface through the typed client (no server-side fetch + * here), and reports loading/error/empty states honestly. The count lives in + * the island, so this page renders only the heading shell. + */ +--- + + +
+

Workflows

+

+ Stored workflow definitions with run, log and delete controls — powered + by the backend workflow API. +

+
+ + +
+ + \ No newline at end of file diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 7865fe0..619ee0b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -1,75 +1,97 @@ -:root { - --bg: #09090b; - --bg-card: #111113; - --bg-code: #18181c; - --border: #ffffff12; - --border-hover: #F095C859; - --text: #fafafa; - --text-muted: #a1a1aa; - --text-tertiary: #52525b; - --accent: #F095C8; - --accent-hover: #FFB1DD; - --accent-soft: rgba(240, 149, 200, 0.15); - --success: #B4E7C7; - --danger: #FF718F; - --surface: var(--bg-card); /* legacy alias for card surfaces */ - --muted: var(--text-tertiary); /* legacy alias for tertiary text */ -} +@import "./tokens.css"; + +/* --------------------------------------------------------------------------- + * Base reset + typography. Layout/component styles live next to their + * components; this file only sets the document foundation and token-driven + * global utilities. + */ -* { +*, +*::before, +*::after { box-sizing: border-box; } +html { + background: var(--bg); + color-scheme: dark; +} + body { margin: 0; + min-height: 100vh; background: var(--bg); color: var(--text); - font-family: Inter, system-ui, -apple-system, sans-serif; + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; } -.app-header { - display: flex; - align-items: center; - gap: 12px; - padding: 16px 24px; - border-bottom: 1px solid var(--border); +h1, +h2, +h3, +h4 { + margin: 0 0 var(--space-2); + line-height: 1.25; + font-weight: 600; } -.brand-mark { - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--accent); +p { + margin: 0 0 var(--space-3); } -.app-header h1 { - font-size: 1.25rem; - margin: 0; - color: var(--text); +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + color: var(--accent-hover); } -.tagline { - color: var(--text-muted); - font-size: 0.85rem; +code, +kbd, +pre { + font-family: var(--font-mono); } -.app-main { - padding: 24px; - display: grid; - gap: 24px; +/* Visible keyboard focus everywhere, without outlines on mouse users */ +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +::selection { + background: var(--accent-subtle); + color: var(--text); } -.panel { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: 16px; - padding: 16px 20px; +/* --------------------------------------------------------------------------- + * SvelteFlow ships light-theme defaults; remap its accent --xy-* variables + * to the Gentle-AI tokens so every canvas blends with the shell. Components + * may refine node/control styling locally (see workflow-editor/FlowEditor.svelte). + */ +.svelte-flow { + --xy-handle-background-color: var(--accent); + --xy-handle-border-color: var(--card); + --xy-edge-stroke-selected: var(--accent); + --xy-connectionline-stroke: var(--accent); + --xy-selection-background-color: var(--accent-subtle); + --xy-minimap-background-color: var(--code); + --xy-minimap-mask-background-color: var(--bg); } -.panel h2 { - margin-top: 0; - font-size: 0.95rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-muted); +/* --------------------------------------------------------------------------- + * Motion — reduced-motion users get instant state changes. + */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } } \ No newline at end of file diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..4ef5119 --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,57 @@ +/** + * WeaveLLM design tokens — Gentle-AI palette (console spec v2). + * Single source of truth for surfaces, borders, text, accent, radii, + * spacing and fonts. Components consume these tokens only — no raw hex. + */ +:root { + /* Surfaces */ + --bg: #09090b; + --card: #111113; + --code: #18181c; + + /* Borders — subtle by design, never hard lines */ + --border: #ffffff12; + --border-hover: #ea188959; + + /* Text */ + --text: #fafafa; + --secondary: #a1a1aa; + --muted: #52525b; + + /* Accent — exactly one chromatic accent */ + --accent: #ea1889; + --accent-hover: #ff6db0; + --accent-subtle: #ea188926; + + /* Status colors (semantic, pre-existing) */ + --success: #b4e7c7; + --danger: #ff718f; + + /* Radii — 8px controls / 16px cards, nothing in between */ + --radius-control: 8px; + --radius-card: 16px; + --radius-full: 999px; + + /* Spacing scale — multiples of 4 */ + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + + /* Fonts — Inter for UI, ui-monospace for code */ + --font-sans: Inter, system-ui, -apple-system, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + + /* Legacy aliases kept so existing components keep working unchanged */ + --bg-card: var(--card); + --bg-code: var(--code); + --text-muted: var(--secondary); + --text-tertiary: var(--muted); + --accent-soft: var(--accent-subtle); + --surface: var(--card); +} \ No newline at end of file diff --git a/frontend/src/svelte/WorkflowEditor.svelte b/frontend/src/svelte/WorkflowEditor.svelte deleted file mode 100644 index 2331034..0000000 --- a/frontend/src/svelte/WorkflowEditor.svelte +++ /dev/null @@ -1,284 +0,0 @@ - - -
- - -
- -
- - -
- - \ No newline at end of file diff --git a/frontend/src/svelte/common/Button.svelte b/frontend/src/svelte/common/Button.svelte new file mode 100644 index 0000000..f1a766f --- /dev/null +++ b/frontend/src/svelte/common/Button.svelte @@ -0,0 +1,95 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/svelte/common/EmptyState.svelte b/frontend/src/svelte/common/EmptyState.svelte new file mode 100644 index 0000000..1711531 --- /dev/null +++ b/frontend/src/svelte/common/EmptyState.svelte @@ -0,0 +1,68 @@ + + +
+ +

{title}

+

{description}

+ {#if children} + {@render children()} + {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/svelte/common/Icon.svelte b/frontend/src/svelte/common/Icon.svelte new file mode 100644 index 0000000..c9757e1 --- /dev/null +++ b/frontend/src/svelte/common/Icon.svelte @@ -0,0 +1,145 @@ + + + + {#each ICON_GEOMETRY[name] as part, i (i)} + + {/each} + + + \ No newline at end of file diff --git a/frontend/src/svelte/common/IconButton.svelte b/frontend/src/svelte/common/IconButton.svelte new file mode 100644 index 0000000..4359f71 --- /dev/null +++ b/frontend/src/svelte/common/IconButton.svelte @@ -0,0 +1,61 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/svelte/common/StatusDot.svelte b/frontend/src/svelte/common/StatusDot.svelte new file mode 100644 index 0000000..39ea8cc --- /dev/null +++ b/frontend/src/svelte/common/StatusDot.svelte @@ -0,0 +1,64 @@ + + + + + {#if label !== ""} + {label} + {/if} + + + \ No newline at end of file diff --git a/frontend/src/svelte/executions/ExecutionsList.svelte b/frontend/src/svelte/executions/ExecutionsList.svelte new file mode 100644 index 0000000..f475b90 --- /dev/null +++ b/frontend/src/svelte/executions/ExecutionsList.svelte @@ -0,0 +1,740 @@ + + +
+ {#if rows === null} + {#if error !== null} + + + + {:else} +
+ {#each skeletons as _skel, i (i)} + + {/each} +
+

Loading executions…

+ {/if} + {:else if rows.length === 0} + + Go to workflows + + {:else} + {@const totals = strip()} + +
+

+ Per-workflow run history, newest first — from the backend workflow logs. +

+ +
+ +
+
+ {totals.total} + Workflows +
+
+ {totals.withRuns} + With runs +
+
+ {totals.ok} + OK runs +
+
+ {totals.error} + Error runs +
+
+

+ Counts cover the logs fetched for {totals.loaded} of {totals.total} workflows + loaded below — per-workflow history, not a global aggregate. +

+ + {#if error !== null} + + {/if} + +
+ {#each rows as wf (wf.name)} + {@const isOpen = expanded[wf.name] === true} + {@const cell = cells[wf.name]} + {@const id = runsId(wf.name)} +
+
+ + +
+ + {wf.name} + + + + v{wf.version} + + {formatUpdatedAt(wf.updatedAt)} + +
+ +
+ {#if cell === undefined || cell.status === "loading"} + Loading… + {:else if cell.status === "error"} + {cell.error} + + {:else if cell.rows.length === 0} + No runs yet + {:else} + {@const latest = latestRun(cell.rows)} + {#if latest !== null} + {@const view = executionRowView(latest)} + + {view.startedAt} + {view.duration} + {#if view.error !== null} + {view.errorCell} + {/if} + {/if} + {/if} +
+
+ + {#if isOpen} +
+ {#if cell === undefined || cell.status === "loading"} +

Loading runs…

+ {:else if cell.status === "error"} + + {:else if cell.rows.length === 0} +

No runs yet for this workflow.

+ {:else} +
    + {#each cell.rows as run (run.id)} + {@const v = executionRowView(run)} +
  • + + {v.startedAt} + {v.duration} + {#if v.error !== null} + {v.error} + {/if} +
  • + {/each} +
+ {/if} +
+ {/if} +
+ {/each} +
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/svelte/models/ModelDetail.svelte b/frontend/src/svelte/models/ModelDetail.svelte new file mode 100644 index 0000000..9e2f04c --- /dev/null +++ b/frontend/src/svelte/models/ModelDetail.svelte @@ -0,0 +1,242 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/svelte/models/ModelsRegistry.svelte b/frontend/src/svelte/models/ModelsRegistry.svelte new file mode 100644 index 0000000..b57b6ab --- /dev/null +++ b/frontend/src/svelte/models/ModelsRegistry.svelte @@ -0,0 +1,566 @@ + + +
+
+

+ {#if rows !== null && error === null && fetchedAt !== null} + {rows.length} model{rows.length === 1 ? "" : "s"} · + Last updated {formatClock(fetchedAt)} + {:else if error !== null} + Registry unavailable + {:else} + — + {/if} +

+ +
+ + {#if rows === null} + {#if error !== null} + + {#if unauthorized} +

+ Authorization required — configure WEAVELLM_AUTH properly on the + backend to unlock this surface. +

+ {/if} + +
+ {:else} +
+ {#each skeletons as _skel, i (i)} + + {/each} +
+

Loading models…

+ {/if} + {:else if rows.length === 0} + + {:else} + {@const counts = summarizeModels(rows)} + +
+
+ {counts.total} + Total +
+
+ {counts.active} + Active +
+
+ {counts.disabled} + Disabled +
+
+ {counts.error} + Error +
+
+ + {#if error !== null} + + {/if} + +
+ + + {#each modelRows(rows) as row (row.id)} + {@const chip = stateChip(row.state)} + {@const kind = busy[row.id]} +
+ {row.id} + + {formatPid(row.pid)} + {formatPort(row.port)} + + {#if row.state === "error"} + {errorCell(row.error)} + {:else} + — + {/if} + + + {#if row.state === "disabled"} + + {:else if row.state === "active"} + + {/if} + + + {#if rowActionError[row.id] !== undefined && rowActionError[row.id] !== ""} + {rowActionError[row.id]} + {/if} +
+ {/each} +
+ {/if} +
+ +{#if detailId !== null} + +{/if} + + diff --git a/frontend/src/svelte/navigation/CommandPalette.svelte b/frontend/src/svelte/navigation/CommandPalette.svelte new file mode 100644 index 0000000..548d324 --- /dev/null +++ b/frontend/src/svelte/navigation/CommandPalette.svelte @@ -0,0 +1,275 @@ + + +{#if open} +
+ +
+{/if} + + \ No newline at end of file diff --git a/frontend/src/svelte/navigation/Sidebar.svelte b/frontend/src/svelte/navigation/Sidebar.svelte new file mode 100644 index 0000000..85d0d01 --- /dev/null +++ b/frontend/src/svelte/navigation/Sidebar.svelte @@ -0,0 +1,279 @@ + + + + + + + + \ No newline at end of file diff --git a/frontend/src/svelte/navigation/StatusBar.svelte b/frontend/src/svelte/navigation/StatusBar.svelte new file mode 100644 index 0000000..7b2f26a --- /dev/null +++ b/frontend/src/svelte/navigation/StatusBar.svelte @@ -0,0 +1,60 @@ + + +
+ WeaveLLM v{version} +
+ + {origin} + Ctrl K to jump +
+
+ + \ No newline at end of file diff --git a/frontend/src/svelte/navigation/Topbar.svelte b/frontend/src/svelte/navigation/Topbar.svelte new file mode 100644 index 0000000..a83db08 --- /dev/null +++ b/frontend/src/svelte/navigation/Topbar.svelte @@ -0,0 +1,215 @@ + + +
+
+ sidebarDrawerOpen.set(true)} + /> + + +
+ +
+
+ + {origin} +
+ + + + + + +
+
+ + \ No newline at end of file diff --git a/frontend/src/svelte/overview/Overview.svelte b/frontend/src/svelte/overview/Overview.svelte new file mode 100644 index 0000000..f9e07a3 --- /dev/null +++ b/frontend/src/svelte/overview/Overview.svelte @@ -0,0 +1,424 @@ + + +
+
+ {#if snapshot !== null} +

Last updated {formatTime(snapshot.fetchedAt)}

+ {:else} + + {/if} + +
+ +
+ {#if snapshot === null} + + + + + {:else} + + {#if runtimeError !== null} + + {/if} + + + + {#if modelsError !== null} + + {:else if activeModelIdsLabel(models) !== ""} +

Active: {activeModelIdsLabel(models)}

+ {/if} +
+ + + {#if workflowsError !== null} + + {:else if snapshot.workflows.recent.length > 0} +
    + {#each snapshot.workflows.recent as wf (wf.name)} +
  • + + {wf.name} + + + v{wf.version} · {formatWorkflowDate(wf.updatedAt)} + +
  • + {/each} +
+ {/if} +
+ {/if} +
+ +
+

Quick actions

+ +
+
+ + \ No newline at end of file diff --git a/frontend/src/svelte/overview/StatusCard.svelte b/frontend/src/svelte/overview/StatusCard.svelte new file mode 100644 index 0000000..07871a9 --- /dev/null +++ b/frontend/src/svelte/overview/StatusCard.svelte @@ -0,0 +1,111 @@ + + +
+
+

{label}

+ {#if statusLabel !== ""} + + {/if} +
+ +

{value}

+ + {#if hint !== ""} +

{hint}

+ {/if} + + {#if children} +
+ {@render children()} +
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/svelte/playground/Playground.svelte b/frontend/src/svelte/playground/Playground.svelte new file mode 100644 index 0000000..f80627d --- /dev/null +++ b/frontend/src/svelte/playground/Playground.svelte @@ -0,0 +1,895 @@ + + +
+
+
+
+

Request

+ POST /v1/chat/completions +
+ +
+ + + + {#each suggestions.local as id (id)} + +

+ Suggestions — local registry ids and gateway/<workflow> names. + External adapter ids resolve server-side; the server answers unknown + models with a 404 envelope. +

+
+ +
+ Messages + + {#each messages as row, i (row.id)} +
+ + + removeMessage(row.id)} + /> +
+ {/each} +
+ +
+
+ +
+
+ + +
+ +
+ +
+

+ Requests leave this console unauthenticated — the server owns auth + (WEAVELLM_AUTH) and credentials never live here. +

+ {#if isBusy()} + + {/if} + +
+
+ +
+
+

Response

+
+ + {#if status === STATUS.STREAMING} + + {frameCount} {frameCount === 1 ? "frame" : "frames"} + + {/if} +
+
+ + {#if status === STATUS.IDLE && error === null && completion === null && rawSse === ""} +

+ The response renders verbatim here — no derived metrics. Press Send + to run the request. +

+ {/if} + + {#if status === STATUS.CANCELLED} +

+ Request cancelled. Closing the connection makes the server abort the + upstream call (relay contract). +

+ {/if} + + {#if error !== null && status === STATUS.ERROR} + + {/if} + + {#if completion !== null && status === STATUS.DONE} +
+
+ {#if completion.choices[0]} + finish_reason: {completion.choices[0].finish_reason ?? "null"} + {/if} + {#if completion.usage !== null && completion.usage !== undefined} + + usage: {completion.usage.prompt_tokens} prompt / {completion.usage.completion_tokens} completion / {completion.usage.total_tokens} total + + {/if} + — wire values only +
+
{highlightJson(JSON.stringify(completion, null, 2))}
+
+ {/if} + + {#if streamEnabled && (rawSse !== "" || streamContent !== "" || streamTruncated)} +
+ {#if streamContent !== ""} +
+

Final content (verbatim)

+
{streamContent}
+
+ {/if} +
+ {#if streamFinalDone} + + data: [DONE] terminal marker received + + {:else} + + Stream ended without the terminal [DONE] marker + + {/if} + {#if streamTruncated} + truncated + {/if} + {frameCount} {frameCount === 1 ? "frame" : "frames"} +
+
+ Raw SSE transcript +
{rawSse}
+
+
+ {/if} + + {#if status === STATUS.CONNECTING} +

Connecting to /v1/chat/completions…

+ {/if} +
+
+
+ + \ No newline at end of file diff --git a/frontend/src/svelte/runtime/RuntimePanel.svelte b/frontend/src/svelte/runtime/RuntimePanel.svelte new file mode 100644 index 0000000..523828e --- /dev/null +++ b/frontend/src/svelte/runtime/RuntimePanel.svelte @@ -0,0 +1,517 @@ + + +
+
+

+ Status from GET /api/health, the auth probe and a WS /ws handshake probe — + values the browser cannot observe come from the backend. +

+ +
+ +
+ + {#if healthCell.status === "error"} + + + {/if} + + + + {#if authCell.status === "error"} + + + {/if} + + +
+
+

Server address

+
+
+

API origin

+ {apiOrigin} +
+
+

WebSocket origin

+ {wsOrigin} +
+

+ The endpoints this console talks to (client config). In dev, the Astro + proxy forwards them to the local backend. +

+
+ +
+
+

WebSocket

+ +
+

{wsCard.value}

+

{wsCard.announce}

+ {#if ws.phase === "error"} + + {/if} +
+ +
+

{wsCard.hint}

+
+ + +
+
+ + \ No newline at end of file diff --git a/frontend/src/svelte/settings/Settings.svelte b/frontend/src/svelte/settings/Settings.svelte new file mode 100644 index 0000000..0006abd --- /dev/null +++ b/frontend/src/svelte/settings/Settings.svelte @@ -0,0 +1,203 @@ + + +
+
+

+ Read-only settings. No settings API exists on the backend: the auth + gate is probed live, and server-owned configuration is marked honestly. +

+ +
+ +
+ + {#if probe.status === "error"} + + + {/if} + + +
+
+

Server environment

+
+
+ {#each SERVER_ENV_ROWS as row (row.label)} +
+

{row.label}

+ {row.value} +
+ {/each} +
+

{SERVER_ENV_NOTE}

+
+
+
+ + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/CanvasCommands.svelte b/frontend/src/svelte/workflow-editor/CanvasCommands.svelte new file mode 100644 index 0000000..e837dce --- /dev/null +++ b/frontend/src/svelte/workflow-editor/CanvasCommands.svelte @@ -0,0 +1,44 @@ + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/FlowEditor.svelte b/frontend/src/svelte/workflow-editor/FlowEditor.svelte new file mode 100644 index 0000000..eb20ec6 --- /dev/null +++ b/frontend/src/svelte/workflow-editor/FlowEditor.svelte @@ -0,0 +1,661 @@ + + +
+ {#if status === "error"} +
+ + + +
+ {:else} +
+
+ {workflowName ?? "Unnamed workflow"} + v{savedVersion ?? "—"} + {#if dirty} + Unsaved changes + {/if} + {#if saveFlash !== null} + {saveFlash} + {/if} +
+
+ + +
+
+ + {#if errorsPanel !== null} + + {/if} + +
+ {#if loading} + +

Loading workflow…

+ {:else} + + + + + { + canvasApi = api; + }} + /> + + {#if emptyCanvas} +
Empty canvas — add nodes from the palette.
+ {/if} + {/if} +
+ + {#if runOpen && workflowName !== null} + { + runOpen = false; + }} + /> + {/if} + {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/Inspector.svelte b/frontend/src/svelte/workflow-editor/Inspector.svelte new file mode 100644 index 0000000..06c7439 --- /dev/null +++ b/frontend/src/svelte/workflow-editor/Inspector.svelte @@ -0,0 +1,736 @@ + + + + + + {#each knownModelIds as id (id)} + + {/each} + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/NodePalette.svelte b/frontend/src/svelte/workflow-editor/NodePalette.svelte new file mode 100644 index 0000000..6ccd1b0 --- /dev/null +++ b/frontend/src/svelte/workflow-editor/NodePalette.svelte @@ -0,0 +1,144 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/RunPanel.svelte b/frontend/src/svelte/workflow-editor/RunPanel.svelte new file mode 100644 index 0000000..eaa1df1 --- /dev/null +++ b/frontend/src/svelte/workflow-editor/RunPanel.svelte @@ -0,0 +1,750 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeBranch.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeBranch.svelte new file mode 100644 index 0000000..e797fe9 --- /dev/null +++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeBranch.svelte @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeData.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeData.svelte new file mode 100644 index 0000000..f0624cc --- /dev/null +++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeData.svelte @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeLlm.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeLlm.svelte new file mode 100644 index 0000000..07e07dc --- /dev/null +++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeLlm.svelte @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeOutput.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeOutput.svelte new file mode 100644 index 0000000..c011693 --- /dev/null +++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeOutput.svelte @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodePipeline.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodePipeline.svelte new file mode 100644 index 0000000..4deba44 --- /dev/null +++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodePipeline.svelte @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/nodes/FlowNodeStartEnd.svelte b/frontend/src/svelte/workflow-editor/nodes/FlowNodeStartEnd.svelte new file mode 100644 index 0000000..cccdfd6 --- /dev/null +++ b/frontend/src/svelte/workflow-editor/nodes/FlowNodeStartEnd.svelte @@ -0,0 +1,18 @@ + + + \ No newline at end of file diff --git a/frontend/src/svelte/workflow-editor/nodes/NodeShell.svelte b/frontend/src/svelte/workflow-editor/nodes/NodeShell.svelte new file mode 100644 index 0000000..bd3a39a --- /dev/null +++ b/frontend/src/svelte/workflow-editor/nodes/NodeShell.svelte @@ -0,0 +1,144 @@ + + +
+ {#if hasTargetHandle(entry.id)} + + {/if} + +
+ + {entry.label} +
+ +
+ {#if lines.length > 0} + {#each lines as line (entry.id + line.key)} +
+ {line.key} + {line.value} +
+ {/each} + {:else} +

{entry.description}

+ {/if} +
+ +
{nodeId}
+ + {#if hasSourceHandle(entry.id)} + + {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/svelte/workflows/NewWorkflowModal.svelte b/frontend/src/svelte/workflows/NewWorkflowModal.svelte new file mode 100644 index 0000000..ffa6ec9 --- /dev/null +++ b/frontend/src/svelte/workflows/NewWorkflowModal.svelte @@ -0,0 +1,313 @@ + + +
+ +
+ + \ No newline at end of file diff --git a/frontend/src/svelte/workflows/RunWorkflowModal.svelte b/frontend/src/svelte/workflows/RunWorkflowModal.svelte new file mode 100644 index 0000000..d6f07f0 --- /dev/null +++ b/frontend/src/svelte/workflows/RunWorkflowModal.svelte @@ -0,0 +1,251 @@ + + +
+ +
+ + \ No newline at end of file diff --git a/frontend/src/svelte/workflows/WorkflowLogsModal.svelte b/frontend/src/svelte/workflows/WorkflowLogsModal.svelte new file mode 100644 index 0000000..e500409 --- /dev/null +++ b/frontend/src/svelte/workflows/WorkflowLogsModal.svelte @@ -0,0 +1,318 @@ + + +
+ +
+ + \ No newline at end of file diff --git a/frontend/src/svelte/workflows/WorkflowsList.svelte b/frontend/src/svelte/workflows/WorkflowsList.svelte new file mode 100644 index 0000000..5bd585d --- /dev/null +++ b/frontend/src/svelte/workflows/WorkflowsList.svelte @@ -0,0 +1,664 @@ + + +
+ {#if rows === null} + {#if error !== null} + + + + {:else} +
+ {#each skeletons as _skel, i (i)} + + {/each} +
+

Loading workflows…

+ {/if} + {:else if rows.length === 0} + + + + {:else} +
+

{rows.length} workflow{rows.length === 1 ? "" : "s"}

+ +
+ + {#if error !== null} + + {/if} + +
+ + + {#each rows as wf (wf.name)} + {@const row = workflowRow(wf)} +
+ + {row.name} + + v{row.version} + {formatUpdatedAt(row.updatedAt)} + + + + + +
+ {/each} +
+ {/if} +
+ +{#snippet actionGlyph(paths: readonly MiniGlyph[])} + +{/snippet} + +{#if modal === MODAL_KINDS.run && modalName !== ""} + +{/if} +{#if modal === MODAL_KINDS.logs && modalName !== ""} + +{/if} +{#if modal === MODAL_KINDS.new} + +{/if} +{#if modal === MODAL_KINDS.delete && modalName !== ""} +
+ +
+{/if} + + \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..ea61264 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "src/**/*", "astro.config.ts"], + "exclude": ["dist", "frontend", "node_modules"] +} \ No newline at end of file diff --git a/odd/tasks/ui-console.md b/odd/tasks/ui-console.md new file mode 100644 index 0000000..e08fcab --- /dev/null +++ b/odd/tasks/ui-console.md @@ -0,0 +1,46 @@ +# Feature: ui-console — Consola de administración WeaveLLM (spec v2, PHASE 1–14) + +**Objective:** transformar el frontend actual (única página `index.astro` + ModelBadge/WorkflowEditor) en la consola completa de administración según la spec revisada v2 (~secciones 0–303, entregada 2026-09-20): Overview, Workflows (+editor visual), Models (registry/catalog/downloads), Providers, Executions, API Playground, Runtime, Settings, About. + +**Problem:** la spec de diseño anterior estaba basada en una versión VIEJA de WeaveLLM e inventaba endpoints (`/api/ui/*`, `/api/pipelines/*`, `/api/executions/*`) que no existen. El usuario pidió roll-back explícito ("eliminar el worktree y empezar de nuevo") y la spec v2. **Regla central (§2): la UI representa la superficie backend REAL; está prohibido inventar endpoints, telemetría o datos.** + +**Why:** pedido explícito del usuario + desbloqueo del trabajo pendiente de la consola sobre el runtime real (el backend ya expone /api, /v1, /ws; falta la UI). + +**Roll-back ejecutado (2026-09-20):** rama `feat/dashboard-api-surface` eliminada (sin commits propios); descartados `src/orchestrator/graph.ts`/`graph.test.ts` (mods), `src/app/events.ts`/`events.test.ts`, `odd/tasks/dashboard-api-surface.md`; issue #43 cerrada como `not planned` (superseded por spec v2). Working tree limpio sobre `master` → rama nueva `feat/ui-console`. `docs/` preexistente NO se tocó. + +**Scope (autorizado):** `frontend/` completo (Astro 7 static + Svelte 5 islands + @xyflow/svelte), `frontend/astro.config.ts` (mantener proxy `/api,/v1,/ws` → 127.0.0.1:4317, solo dev), alias `@core` → `../src/` para reusar tipos de dominio. **No** tocar `src/` backend (contratos reales) ni agregar dependencias nuevas. Copia de UI: inglés consistente, tono técnico/concise, sin mezclar idiomas (§281). + +**Constraints:** +- Superficie backend real (fuente de verdad): `GET /api/health`; `GET /api/models`, `GET /api/models/:id`, `POST .../activate`, `POST .../deactivate`; `GET/PUT/DELETE /api/workflows...`, `POST /api/workflows/:name/run`, `GET /api/workflows/:name/logs`; `WS /ws`; `GET /v1/models`, `POST /v1/chat/completions|completions|embeddings`. Gaps → "Coming from backend"/"Unavailable"; nunca simular. +- **Tokens Gentle-AI exactos** (sustituyen `#F095C8`): bg `#09090b`; card `#111113`; code `#18181c`; border `#ffffff12`, hover `#ea188959`; texto `#fafafa`; secondary `#a1a1aa`; muted `#52525b`; accent `#ea1889`, hover `#ff6db0`, subtle `#ea188926`. Radii controles 8px / cards 16px (nunca 5/11/13/22). Spacing 4/8/12/16/20/24/32/40/48. Inter + ui-monospace; máx 3 pesos por vista. +- Stack: Astro 7 static + Svelte 5 moderno ($state/$derived/$effect/$props) + @xyflow/svelte. NO React/SvelteKit/Bootstrap/MUI/Chakra/Ant. Iconos SVG inline. Sin i18n, sin `any`, sin eslint disables inline, sin `fetch()` arbitrario en componentes (API client tipado), tests `bun:test` al lado del código. Editor: Single Graph Model (Visual|YAML), guardar = serializeWorkflowGraph → PUT YAML canónico, validateGraph() backend = autoridad. Ejecución = WS /ws (nunca reinterpretar como SSE); executions = logs de workflow. +- Convenciones repo: Bun ≥ 1.4, ESM `.js` explícitos, Conventional Commits, ramas `^(feat|fix|...)\/[a-z0-9._-]+$`. + +**Checklist (PHASE 1–14 de la spec §302; verificación por fase):** + +- [x] **U01 (PHASE 1)** Design tokens + App shell + Navegación + Routing. `styles/tokens.css` + refactor `global.css` (quitar #F095C8) + `layouts/AppLayout.astro` (Sidebar/Topbar/Main/StatusBar) + `svelte/navigation/*` (Sidebar, Topbar, StatusBar, CommandPalette Ctrl/Cmd+K) + rutas placeholder (Overview), `/workflows[/name]`, `/models[/id|catalog|downloads]`, `/providers[/kind]`, `/executions`, `/api`, `/runtime`, `/settings`, `/about`; `lib/api/config.ts` mínimo (`getApiOrigin`/`getWsOrigin`); mount temporal de WorkflowEditor existente en `/workflows/[name]` (sin romper su funcionalidad; index deja de hardcodear qwen2.5-coder-3b-instruct/Q4_K_M/32768). ✅ commit `16b0871` — gate verde (typecheck ✓, lint ✓, 462 tests ✓, build:frontend ✓ 12 páginas). Extras: `lib/navigation.ts` (registro de nav + isRouteActive), `lib/ui-state.ts`, `lib/api/config.test.ts`, helpers `svelte/common/*` (Button/Icon/IconButton/StatusDot/EmptyState). Fixes post-agente: imports relativos en `executions/index.astro`, path de `package.json` raíz en `AppLayout.astro` y `about.astro`. +- [x] **U02 (PHASE 2)** API layer tipado + básicos. ✅ commit `120c66d` — `lib/api/{http,types,health,models,workflows,runtime,index}.ts` + tests (26 pass) sobre la superficie real. `ApiError {status,code,message,details?}` + `request()` con fetch inyectable; `ErrorEnvelope` del backend mapeado verbatim (`unauthorized`, `model_not_found`, etc.). **No existe endpoint de providers en el backend real → NO se creó cliente de providers** (U09 mostrará "Coming from backend"). `runtime.status()` = health + sonda 401 ⇒ authEnabled. Hallazgos del writer: `bun test` root no colecta frontend (correr `bun test ./frontend/src/lib/api/*.test.ts`); typecheck root no cubre frontend (verificación TS = lint + tests path-scoped + astro build). Fix post-entrega: `ExecutionLogRow.error`/`ms` tipados `string|null` (el backend incluye la clave con null). +- [x] **U03 (PHASE 3)** Overview. ✅ commit `a4260c6` — `lib/overview.ts` (mapa puro + 6 tests), `svelte/overview/{Overview,StatusCard}.svelte` (island `client:load`, Promise.allSettled, auto-refresh 10s con guard de stale, estados honestos "—"/Unavailable+Retry), quick actions como anchors semánticos; `index.astro` monta el dashboard. **Fix defecto U02:** `RuntimeStatusOptions` estaba referenciado pero indefinido (invisible a los gates porque **frontend no tiene tsconfig** — astro build no typechecks; hardening pendiente para U14). Caveat: en build estático, links `/workflows/` solo renderizan `demo` hasta que U04 amplíe `getStaticPaths`. +- [x] **U04 (PHASE 4)** Workflow library. ✅ commit `c40b1c3` — `lib/workflows-ui.ts` (helpers puros + 21 tests), `svelte/workflows/{WorkflowsList,RunWorkflowModal,WorkflowLogsModal,NewWorkflowModal}.svelte`, página `/workflows` con lista real (create/run/logs/delete dialogs accesibles, estados honestos, delete con confirm). **Descubrimiento clave (verificado contra `workflow-yaml.ts`/`graph.ts`): el YAML real de workflows usa `nodes` como ARRAY de objetos con campos del engine (no mapa), sin campo `next` (flujo solo por `edges`), y `llm_call` exige `model` no vacío + `mode` (`generate`/`refine`/`passthrough`); `provider` opcional; scaffolds `system`/`assistant` (NO `prompt`). El template original (forma mapa + `prompt`) 400a en el backend — corregido en el modal antes del commit. Forma mínima canónica: `nodes: [{id: start, type: start}, {id: llm, type: llm_call, mode: generate, model: }, {id: end, type: end}]` + edges `start→llm→end`. U05 debe construir el editor sobre ESTA forma. +- [x] **U05 (PHASE 5)** Workflow editor foundation. ✅ commit pendiente abajo — `lib/workflow-nodes.ts` (taxonomía 14 tipos: categorías, fields por tipo, defaults, orden palette, `sanitizeNodeFields`, `makeNodeId`), `lib/workflow-flow.ts` (`graphToFlow`/`flowToGraph` con whitelist de campos + round-trip estable, `parseWorkflowYamlToGraph` vía paquete `yaml` (mismo dep del backend), `NODE_GROUPS`, seam `CanvasApi`), tests (workflow-nodes + workflow-flow, round-trip y drop de campos desconocidos), `svelte/workflow-editor/{FlowEditor,CanvasCommands,NodePalette,Inspector,nodes/*}` (6 custom nodes por grupo, minimap+controls, fitView, click-to-add, inspector de lectura — formularios en U06), ruta `[name].astro` monta ``; `WorkflowEditor.svelte` + `workflow-nodes-demo.ts` huérfanos (demo movido; borrar en U06 o tras confirmar). Decisiones: sin drag-and-drop nativo (pelea con el pane; click-to-add como seam), `rag_local`/`embeddings` sin campo `model` (el GraphNode real no lo tiene). Hallazgos: esta versión de Svelte exporta `tick`, no `nextTick`. +- [x] **U06 (PHASE 6)** Workflow semantics. ✅ commit `d17be52` — Inspector editable por tipo (`llm_call`: mode/model/provider/system/assistant/ctx/on_429/tool_calls_route con hint "local registry"; `loop`: body+iterations; `pipeline`: params; `condition`: source con subset local honesto en `workflow-condition.ts`; `rag_local` k; `memory` convId; `embeddings` text; `data.code` code), `lib/workflow-validate.ts` (prevalidación estructural espejo de graph.ts para feedback rápido — **autoridad = backend**: Save hace PUT y muestra errores 400 verbatim), Save explícito (Cmd/Ctrl+S) = `flowToGraph`→`serializeGraphToYaml`→`PUT`, flash "Saved vN", start/end protegidos (`onBeforeDelete`). Fixes: `seedFromGraph` pisaba `savedVersion` con null; keys duplicadas en Inspector (#each por índice); import depth 4 niveles (rompía build). Borrados demo: `WorkflowEditor.svelte` + `workflow-nodes-demo.ts` (cero referencias). 22 tests condition/validate; svelte-check 0 errores. +- [x] **U07 (PHASE 7)** Workflow execution. ✅ commit `00f1899` — `lib/{ws,sse,run-transcript}.ts` (+3 suites, 45 tests), `RunPanel.svelte` (drawer en FlowEditor, tabs Run/Runs, transcript puro, fallback one-shot honesto ante bind fallido, refresh de logs tras status terminal porque `runner.ts` escribe la fila ANTES de resolver), toolbar Run con icono play, icons play/check en `Icon.svelte`. Gates verdes: typecheck ✓, lint ✓, 462 tests ✓, build:frontend ✓ 12 páginas. Confirmado: el protocolo WS NO trae tokens por paso (solo lifecycle `step_started/step_completed`) — la salida llega en frames finales `data: {JSON}` + `data: [DONE]`. +- [x] **U08 (PHASE 8)** Models. ✅ commit `8a04fba` — `lib/models-ui.ts` (pure reducers + 18 tests: counts, chips, pid/port/error formatting), `svelte/models/ModelsRegistry.svelte` (island: 15s auto-refresh con stale-guard, honest 401 "Authorization required"/empty/error+Retry, resumen total/active/disabled/error, Activate/Deactivate POSTs reales con busy labels y refresh, errores verbatim, nunca optimista), `svelte/models/ModelDetail.svelte` (drawer a11y con getModel real). **Verificado: NO existe endpoint de catálogo ni downloads** (`src/routes/api.ts` solo lista/status/activate/deactivate; `src/catalog/`+`src/downloads/` son internos del backend) → secciones catalog/downloads muestran **"Coming from backend"** honesto. **Sin telemetría inventada**: ModelStatus es solo `{id, state, pid?, port?, error?}`. Hallazgo: `GET /api/models/:id/status` devuelve el full ModelStatus (no hay getModelStatus separado). +- [x] **U09 (PHASE 9)** Providers. ✅ commit `bd80710` — página `/providers` + `/providers/[kind]` como shells estáticos honestos **"Coming from backend"** para los 3 kinds reales del contrato (`openai|anthropic|openrouter` de `registry.ts`): keys en el keychain del server, **nunca al frontend**; fallback links server-side (`providers` rows). Verificado de nuevo: NO existe endpoint de providers en `src/routes/api.ts` → nada que renderizar. Gotcha del build (Astro): **los const del frontmatter NO son visibles dentro de `getStaticPaths`** — se inlinea el literal de kinds (primera escritura falló el build con `PROVIDER_KINDS is not defined`; fix = literal inline; build ahora 15 páginas). +- [x] **U10 (PHASE 10)** Executions. ✅ commit `0615954` — página `/executions` + `svelte/executions/ExecutionsList.svelte` (island): agregación honesta **sin API global** — pool lazy con concurrencia máx 2 y guard de generación (`refreshSeq`), filas expandibles (aria-expanded, refetch on expand), latest-run por workflow desde `workflowLogs`, summary strip etiquetado como "counts de logs cargados de X de N, no agregado global", Refresh manual (runs completados son inmutables — `runner.ts` escribe la fila antes de resolver), degradación por-fila ante fallo del list refresh. `lib/executions-ui.ts` + 15 tests puros (el writer reportó 18; el conteo real de la suite commiteada es 15). Nav `/executions` ya existía (U01, grupo Observe). +- [x] **U11 (PHASE 11)** API Playground. ✅ commit `d2e1587` — `lib/api/v1.ts` (cliente /v1 tipado: chatCompletion JSON + stream async-generator `frame|done|truncated|aborted`, splitSSEFrames; **nunca envía Authorization** — la auth vive server-side WEAVELLM_AUTH y el 401 se muestra verbatim con hint; sin credenciales jamás), `lib/v1-ui.ts` (highlightJson byte-preserving + tests), `svelte/playground/Playground.svelte` (request editor: model + datalist real de /api/models ids + gateway/, messages fieldset, temperature 0–2, stream default on; vista raw SSE progresiva con [DONE]; errores verbatim: 401 Bearer hint, 404 unknown-model con hint de wiring local), AbortController cancel, aria-live. 22 tests nuevos. Nav: `/api` → `/playground`, `api.astro` queda como alias (no romper deep links). Superficie verificada: solo `POST /v1/chat/completions` + `GET /v1/models`; ids locales → envelope 404 unknown-model (boot no cableado). +- [x] **U12 (PHASE 12)** Runtime. ✅ commit `5408f6d` — página `/runtime` (flat placeholder `/runtime.astro` → `runtime/index.astro`): tarjetas reales — Health (status verbatim + latencia medida con performance.now + localModels), Server address (API/WS origin desde config del cliente, honesto), Authentication (probe de `runtime.ts` + nota WEAVELLM_AUTH), WebSocket (probe one-shot connect→600ms→close con reducer `wsProbeState` idle|connecting|connected|closed|error, sin auto-reconnect, aria-live, Retry), Version (root package.json build-time). `lib/runtime-ui.ts` + 17 tests. Gotcha Svelte 5: el `$effect` del probe NO debe leer el `$state` del ws (loop); socket/closeTimer como locals no reactivos; cleanup nulls handlers. Import de package.json verificado contra about.astro (4 niveles). +- [x] **U13 (PHASE 13)** Settings + About. ✅ commit `c288f0c` — `settings.astro` (placeholder U01) → `pages/settings/index.astro` + island `svelte/settings/Settings.svelte`: **Authentication** card con probe real de `runtimeStatus()` (mapeo puro `authProbe`/`authView` → Enabled/Disabled/Unavailable con estados loading/error por tarjeta, Retry, nota exacta "Owned server-side via WEAVELLM_AUTH — this console never holds or changes the key."); **Server environment** card con 4 rows honestas "Coming from backend" (Host/Port/App data directory/State store — env server NO expuesto por API pública; test enforcement anti-path-fake) + nota neutral; sin forms ni botones de save; Refresh manual re-corre el probe. `about.astro` upgrade (ruta intacta, estático, sin island): package rows reales de package.json raíz (name/version/license/homepage link), stack factual (Bun ≥1.4, TypeScript strict, Astro 7 static + Svelte 5 islands, llama.cpp vía llama-server), nota honesta admin-only + stores no credentials. Nav `/settings` y `/about` ya cableados (U01). `lib/settings-ui.ts` + 10 tests (honest-data guards). Hallazgo: eslint solo cubre `**/*.js` y `**/*.ts` (`.astro`/`.svelte` NO se lintan — verificación UI = astro build; refuerza argumento del typecheck U14). +- [x] **U14 (PHASE 14)** a11y + responsive + performance + hardening. ✅ commit `8996603` — **cobertura de tipos al frontend lograda**: `@astrojs/check 0.9.10` + `frontend/tsconfig.json` (strict) + script `typecheck:frontend` — pasó de 53+5 errores a **0** (era exactamente el objetivo: defectos tipo invisibles para `astro build`; clases reales: unions literales de navigation.ts, discriminant de workflow-condition, fixtures de tests flojos; 77→0 contados en la primera corrida post-setup). **a11y real encontrado y fixeado**: WCAG 2.4.3 — el foco escapaba de TODOS los modales (CommandPalette + 3 modals de workflows + delete dialog) mentiendo el `aria-modal` → util compartida `trapFocus()` (focus-in, Tab/Shift+Tab containment, cleanup) cableada en los 5 diálogos; resto del checklist OK (skip-link, landmarks, aria-current, Escape+restore, aria-live, labels, reduced-motion). **Lazy islands**: chrome AppLayout (Sidebar/Topbar/CommandPalette) `client:load`→`client:idle` (StatusBar resultó server-only); islas de contenido crítico mantienen `client:load`. **Responsive** verificado CSS: shell 240px→64px (768–1023)→0 (≤767), drawer con scrim. `ui-state.test.ts` nuevo. Caveat honesto documentado: `focus-trap.ts` es DOM glue sin unit test (no hay harness DOM en el repo; sin deps nuevas). **Prueba de detección**: error introd revertido reportado por astro check. Gates finales: typecheck root ✓, typecheck:frontend ✓ 0, lint ✓, frontend 260 ✓, backend 462 ✓, build 16 páginas ✓, cero `any`/`ts-ignore`/eslint-disable. + +**Acceptance criteria (por fase):** `bun run typecheck && bun run lint && bun test && bun run build:frontend` verdes; UI dev habla con backend real vía proxy; ningún endpoint ficticio; copia en inglés consistente; tokens aplicados sin excepciones #F095C8. + +**Delivery strategy:** `ask-on-risk` (default) — forecast >> 400 líneas → antes del primer PR se pregunta estrategia de cadena (stacked-to-main | feature-branch-chain) y se registra aquí. Work-unit commits por fase/tarea en `feat/ui-console`; push/PR = decisión del usuario. + +**Resolved mode:** TDD no configurado explícito en proyecto; checks por tarea: `bun run typecheck`, `bun run lint`, `bun test`, `bun run build:frontend`. + +**Delivery (decidido 2026-09-22 por usuario):** `feature-branch-chain`. Tracker = `feat/ui-console` (acumula integración final). Al abrir PRs: PR #1 con base = tracker (rama child `ui-console/slice-1`), PRs hijos con base = rama del PR anterior (`ui-console/slice-N` contra `slice-(N-1)`); solo el tracker mergea a main. Cargar skills `chained-pr` + `work-unit-commits` + `branch-pr` por registry ANTES de planear/crear PRs. + +**Progress/next step:** U01 ✅ … U14 ✅ — **TODAS las 14 fases de la consola completadas e implementadas** (14 commits feature + 14 docs). Pendiente: delivery según `feature-branch-chain` (armar slices `ui-console/slice-N`, PR #1 base = tracker `feat/ui-console`, hijos base = slice previo; solo tracker → main) — cargar skills `chained-pr` + `work-unit-commits` + `branch-pr` antes, y requiere autorización explícita del usuario para push/PR (operación remota). + +**buildings/new-workflow (seam U05 → U06/U07, anotado 2026-09-22):** el editor U05 es lectura + edición del modelo de canvas solamente: `FlowEditor.svelte` carga el workflow vía API client, convierte a FlowNode/FlowEdge y NO persiste. `flowToGraph` emite el id de placeholder `"workflow"` (el nombre real se antepone al guardar). Guardar (U07) = `flowToGraph` → `serializeGraphToYaml` → `PUT /api/workflows/:name` con el YAML canónico; validar antes con `validateGraph()` del backend (autoridad). El slot `condition` (AST opaque en `FlowNodeData`) y `params` (pipeline) se preservan por fidelidad de round-trip pero no tienen UI de edición aún (U06). `start`/`end` no se pueden borrar ni desconectar del flujo por ahora; `llm_call` exige `model` + `mode` en el backend (la tarjeta marca `model` "unset"). No se inventaron endpoints: el editor lee `GET /api/workflows/:name`; no existe guardado de UI todavía, solo el enlace `buildings/new-workflow`. \ No newline at end of file diff --git a/package.json b/package.json index 598c4ba..f82ab56 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "start": "bun run build:frontend && bun run scripts/start-desktop.ts", "test": "bun test", "typecheck": "tsc --noEmit", + "typecheck:frontend": "astro check --root frontend", "lint": "eslint .", "format": "eslint --fix ." }, @@ -60,6 +61,7 @@ "zod": "^3.25.76" }, "devDependencies": { + "@astrojs/check": "0.9.10", "@playwright/test": "^1.62.1", "@types/bun": "^1.4.0", "@typescript-eslint/eslint-plugin": "^8.69.0",