From c446ab8fcaaead58c56c62c5f347621d2195a079 Mon Sep 17 00:00:00 2001 From: 0xr3ngar Date: Tue, 10 Mar 2026 23:09:29 +0100 Subject: [PATCH 1/6] chore: remove prettier --- .prettierignore | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .prettierignore diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 19ace6c..0000000 --- a/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -worker/migrations-postgres/ -src/routeTree.gen.ts From 7dc18ddd814a7efb3b1808864ba19699db00525a Mon Sep 17 00:00:00 2001 From: 0xr3ngar Date: Tue, 10 Mar 2026 23:10:28 +0100 Subject: [PATCH 2/6] chore: setup settings and extensions --- .vscode/extensions.json | 3 +++ .vscode/settings.json | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..9d5eb3d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cd21c47 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,20 @@ +{ + "editor.defaultFormatter": "oxc.oxc-vscode", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[json]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true, + "typescript.preferences.importModuleSpecifier": "non-relative" +} From e7a06d0f05f147290ab0e680b2765e78a7843467 Mon Sep 17 00:00:00 2001 From: 0xr3ngar Date: Tue, 10 Mar 2026 23:10:56 +0100 Subject: [PATCH 3/6] chore: enforce an editor config cuz 2 space indent is killing me X) --- .editorconfig | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6797a0c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{ts,js,json,jsonc}] +indent_style = space +indent_size = 4 + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file From 4014b92df42ed30931d9bbc35ae8c63e7df54edd Mon Sep 17 00:00:00 2001 From: 0xr3ngar Date: Tue, 10 Mar 2026 23:11:41 +0100 Subject: [PATCH 4/6] chore: add oxfmtrc json with import ordering --- .oxfmtrc.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .oxfmtrc.json diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..d5a3fff --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,19 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [], + "experimentalSortImports": { + "newlinesBetween": false, + "groups": [ + ["value-builtin", "value-external"], + ["value-internal", "value-parent", "value-sibling", "value-index"], + { + "newlinesBetween": true + }, + "type-import", + "unknown" + ] + }, + "experimentalSortPackageJson": { + "sortScripts": true + } +} From 257472d54788cbffd2276c70a132ad5e28794144 Mon Sep 17 00:00:00 2001 From: 0xr3ngar Date: Tue, 10 Mar 2026 23:13:32 +0100 Subject: [PATCH 5/6] chore: add ignore pattern --- .oxfmtrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index d5a3fff..2df8b3d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": [], + "ignorePatterns": ["worker/migrations-postgres/","src/routeTree.gen.ts"], "experimentalSortImports": { "newlinesBetween": false, "groups": [ From 433bc09f412b6a6695283c9595ba06dbfc60bbf8 Mon Sep 17 00:00:00 2001 From: 0xr3ngar Date: Tue, 10 Mar 2026 23:15:44 +0100 Subject: [PATCH 6/6] chore: run format on all files --- .oxfmtrc.json | 2 +- .oxlintrc.json | 34 +- bun.lock | 45 +- commitlint.config.js | 8 +- components.json | 42 +- conductor.json | 10 +- drizzle.config.ts | 12 +- knip.json | 38 +- package.json | 262 +++--- packages/desktop-shell/package.json | 16 +- packages/desktop-shell/src/app-server.mts | 2 +- packages/desktop-shell/src/index.mts | 2 +- packages/desktop-shell/src/node-utils.mts | 3 +- packages/desktop-shell/tsconfig.json | 32 +- packages/marketing/.astro/content.d.ts | 385 +++++---- packages/marketing/.astro/data-store.json | 14 +- packages/marketing/.astro/settings.json | 6 +- packages/marketing/astro.config.mjs | 2 +- packages/marketing/package.json | 28 +- packages/marketing/src/pages/api/waitlist.ts | 57 +- packages/marketing/tsconfig.json | 2 +- packages/runner/package.json | 30 +- packages/runner/src/assistant-session-diff.ts | 221 ++--- packages/runner/src/assistant-session.ts | 160 ++-- packages/runner/src/cli.ts | 48 +- .../runner/src/list-assistant-sessions.ts | 53 +- packages/runner/src/local-runner-client.ts | 190 ++-- packages/runner/src/local-runner-protocol.ts | 106 +-- packages/runner/src/local-runner-server.ts | 335 ++++---- packages/runner/src/opencode-client.ts | 20 +- packages/runner/src/opencode-models.ts | 51 +- packages/runner/src/opencode-server.ts | 301 +++---- packages/runner/src/opencode.ts | 4 +- packages/runner/src/task-assistant-session.ts | 493 +++++------ packages/runner/src/workspace.ts | 404 ++++----- packages/runner/tsconfig.json | 10 +- src/components/add-project-dialog.tsx | 4 +- src/components/animated-stream-item.tsx | 2 +- src/components/app-query-provider.tsx | 3 +- src/components/collapsed-activity-group.tsx | 2 +- src/components/layout.tsx | 4 +- src/components/layout/mobile-header.tsx | 2 +- src/components/layout/org-switcher.tsx | 6 +- src/components/layout/task-list.tsx | 12 +- src/components/layout/use-organization.ts | 18 +- src/components/layout/user-menu.tsx | 4 +- src/components/new-task-button.tsx | 4 +- src/components/open-editor-dropdown.tsx | 2 +- src/components/task-model-picker.tsx | 15 +- src/components/task-page-input.tsx | 3 +- src/components/task-page-message-list.tsx | 7 +- src/components/ui/avatar.tsx | 3 +- src/components/ui/button.tsx | 3 +- src/components/ui/card.tsx | 1 - src/components/ui/dialog.tsx | 5 +- src/components/ui/dropdown-menu.tsx | 3 +- src/components/ui/input.tsx | 1 - src/components/ui/separator.tsx | 3 +- src/components/ui/textarea.tsx | 1 - src/components/ui/tooltip.tsx | 2 +- src/lib/auth-client.ts | 4 +- src/lib/collections.ts | 540 ++++++------ src/lib/desktop-runner.ts | 130 +-- src/lib/fail-task-run.ts | 40 +- src/lib/format-duration.ts | 22 +- src/lib/hotkeys.ts | 20 +- src/lib/is-desktop-app.ts | 8 +- src/lib/pull-request.ts | 188 ++-- src/lib/runner-diffs.ts | 56 +- src/lib/runner-models.ts | 226 ++--- src/lib/session-state.ts | 202 ++--- src/lib/task-activity-mapper.ts | 813 +++++++++--------- src/lib/task-sidebar.ts | 205 ++--- src/lib/task-stream-cache.ts | 215 ++--- src/lib/task-timeline.ts | 439 +++++----- src/lib/theme.ts | 48 +- src/lib/use-task-event-stream.ts | 247 +++--- src/lib/utils.ts | 2 +- src/pages/login-page.tsx | 4 +- src/pages/pending-access-page.tsx | 4 +- src/pages/settings-page.tsx | 10 +- src/pages/task-page.tsx | 20 +- src/routes/__root.tsx | 7 +- src/routes/_layout.index.tsx | 2 +- src/routes/_layout.tasks.$taskId.tsx | 4 +- src/routes/api/auth/$.ts | 26 +- .../internal/task-runs/$executionId/branch.ts | 89 +- .../task-runs/$executionId/complete.ts | 124 +-- .../internal/task-runs/$executionId/event.ts | 103 +-- .../internal/task-runs/$executionId/fail.ts | 52 +- .../task-runs/$executionId/heartbeat.ts | 26 +- .../task-runs/$executionId/message.ts | 64 +- src/routes/api/projects/shape.ts | 30 +- src/routes/api/pull-requests/shape.ts | 82 +- src/routes/api/tasks/$taskId/stream.ts | 166 ++-- src/routes/api/tasks/messages/shape.ts | 30 +- src/routes/api/tasks/shape.ts | 30 +- src/routes/login.tsx | 2 +- src/routes/webhook.ts | 106 +-- src/server/auth.ts | 302 +++---- src/server/db/client.ts | 54 +- src/server/db/schema.ts | 384 ++++----- src/server/db/transaction.ts | 49 +- src/server/env.ts | 88 +- src/server/functions/common.ts | 36 +- src/server/functions/installations.ts | 201 ++--- src/server/functions/projects.ts | 412 ++++----- src/server/functions/task-runs.ts | 112 +-- src/server/functions/tasks.ts | 493 +++++------ src/server/lib/clause-to-string.ts | 39 +- src/server/lib/durable-streams.ts | 153 ++-- src/server/lib/electric.ts | 60 +- src/server/lib/ensure-default-organization.ts | 92 +- src/server/lib/github-app.ts | 183 ++-- src/server/lib/opencode.ts | 2 +- src/server/lib/provider-credentials.ts | 117 +-- src/server/lib/secret-crypto.ts | 78 +- src/server/lib/task-execution/helpers.ts | 85 +- src/server/lib/task-run-callback-token.ts | 110 +-- src/server/lib/task-run-callback.ts | 48 +- src/server/lib/user-access.ts | 8 +- src/server/middleware.ts | 56 +- src/server/requireSession.ts | 46 +- src/server/session-error-response.ts | 74 +- src/server/webhook/github/check-run.ts | 99 +-- src/server/webhook/github/check-suite.ts | 133 +-- src/server/webhook/github/installation.ts | 111 +-- src/server/webhook/github/ping.ts | 2 +- .../webhook/github/pull-request-check-runs.ts | 225 ++--- .../webhook/github/pull-request-review.ts | 139 +-- src/server/webhook/github/pull-request.ts | 421 ++++----- src/shared/task-stream-events.ts | 163 ++-- tsconfig.json | 38 +- vercel.json | 2 +- vite.config.ts | 40 +- 135 files changed, 6367 insertions(+), 6212 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 2df8b3d..e5962c7 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,6 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": ["worker/migrations-postgres/","src/routeTree.gen.ts"], + "ignorePatterns": ["src/routeTree.gen.ts", "drizzle/"], "experimentalSortImports": { "newlinesBetween": false, "groups": [ diff --git a/.oxlintrc.json b/.oxlintrc.json index 1c7544e..b7721e8 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,19 +1,19 @@ { - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["typescript", "react", "import", "unicorn"], - "categories": { - "correctness": "warn", - "suspicious": "warn" - }, - "env": { - "browser": true - }, - "globals": { - "Bun": "readonly" - }, - "rules": { - "react/react-in-jsx-scope": "off", - "import/no-unassigned-import": "off" - }, - "ignorePatterns": ["dist/", "node_modules/", ".vercel/", ".astro/"] + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "react", "import", "unicorn"], + "categories": { + "correctness": "warn", + "suspicious": "warn" + }, + "env": { + "browser": true + }, + "globals": { + "Bun": "readonly" + }, + "rules": { + "react/react-in-jsx-scope": "off", + "import/no-unassigned-import": "off" + }, + "ignorePatterns": ["dist/", "node_modules/", ".vercel/", ".astro/"] } diff --git a/bun.lock b/bun.lock index 1de3f23..8c721fe 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "clanki", @@ -50,7 +49,7 @@ "electron": "^36.0.0", "electron-builder": "^26.0.12", "knip": "^5.82.1", - "oxfmt": "^0.27.0", + "oxfmt": "^0.37.0", "oxlint": "^1.42.0", "shadcn": "^3.8.4", "tw-animate-css": "^1.4.0", @@ -566,21 +565,43 @@ "@oxc-transform/binding-win32-x64-msvc": ["@oxc-transform/binding-win32-x64-msvc@0.110.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QROrowwlrApI1fEScMknGWKM6GTM/Z2xwMnDqvSaEmzNazBsDUlE08Jasw610hFEsYAVU2K5sp/YaCa9ORdP4A=="], - "@oxfmt/darwin-arm64": ["@oxfmt/darwin-arm64@0.27.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3vwqyzNlVTVFVzHMlrqxb4tgVgHp6FYS0uIxsIZ/SeEDG0azaqiOw/2t8LlJ9f72PKRLWSey+Ak99tiKgpbsnQ=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.37.0", "", { "os": "android", "cpu": "arm" }, "sha512-2AW4VHG6mePEb1r4l6nBOVz1MwevNa0obayXd5Xce+gtP+cL/FCaoVK7JtpqCj4cEVxbLU4jijBUIWK41X2GGg=="], - "@oxfmt/darwin-x64": ["@oxfmt/darwin-x64@0.27.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-5u8mZVLm70v6l1wLZ2MmeNIEzGsruwKw5F7duePzpakPfxGtLpiFNUwe4aBUJULTP6aMzH+A4dA0JOn8lb7Luw=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.37.0", "", { "os": "android", "cpu": "arm64" }, "sha512-fW/oGfK337wYb/qfoeqKrcv3tMv7DlsKVmHca0DZrWHLMUYftpYD9z7TYOD5VQ1Lg8D/iTzQiTneT2CAMThPxg=="], - "@oxfmt/linux-arm64-gnu": ["@oxfmt/linux-arm64-gnu@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-aql/LLYriX/5Ar7o5Qivnp/qMTUPNiOCr7cFLvmvzYZa3XL0H8XtbKUfIVm+9ILR0urXQzcml+L8pLe1p8sgEg=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.37.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8sfuzKA8Ic43ZCC1ZMwk12rNVao9nn7K6crTvtLQy+yQVbXE1xxR4P1YTxqaLEOGJNq+sB2xyrfJywKVF9VODw=="], - "@oxfmt/linux-arm64-musl": ["@oxfmt/linux-arm64-musl@0.27.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-6u/kNb7hubthg4u/pn3MK/GJLwPgjDvDDnjjr7TC0/OK/xztef8ToXmycxIQ9OeDNIJJf7Z0Ss/rHnKvQOWzRw=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.37.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-X67bSfIDL1ufBY5OLxK3oG5Gj8Jvp7f2yEDVSduvolV+a0k6KJ1ZDFqG9wyTfancKVb7aZ5lTs63pAOxZYrj4A=="], - "@oxfmt/linux-x64-gnu": ["@oxfmt/linux-x64-gnu@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EhvDfFHO1yrK/Cu75eU1U828lBsW2cV0JITOrka5AjR3PlmnQQ03Mr9ROkWkbPmzAMklXI4Q16eO+4n+7FhS1w=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.37.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ULQ6098xUjZoZbT38qHj3Bgwq1BbglgnLOpB01Dsi79n94Dd4V0dPD4TlnSCdX33Rr/DBje4S2IpzgnAs8kknw=="], - "@oxfmt/linux-x64-musl": ["@oxfmt/linux-x64-musl@0.27.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1pgjuwMT5sCekuteYZ7LkDsto7DJouaccwjozHqdWohSj2zJpFeSP2rMaC+6JJ1KD5r9HG9sWRuHZGEaoX9uOw=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.37.0", "", { "os": "linux", "cpu": "arm" }, "sha512-GsNuj91bKV8jHdRBtnCxe7vpX06IADFbyOwkScmDaoroRooBOK9NeStctE0/wE4DT6QY7qfF0YzUTGB2e5tjzQ=="], - "@oxfmt/win32-arm64": ["@oxfmt/win32-arm64@0.27.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmuEhXZEhAYAeyjVTWwGKIA3RSb2b/He9wrXkDJPhmqp8qISUzkVg1dQmLEt4hD+wI5rzR+6vchPt521tzuRDA=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.37.0", "", { "os": "linux", "cpu": "arm" }, "sha512-13ywNNp291Tc1nUaISUS3u2Y2O26zERJoVy1xK2uO+/1oon3EAHxMrXd0bQjopT+Ia3rTPwO6iFxW1DZratehA=="], - "@oxfmt/win32-x64": ["@oxfmt/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cXKVkL1DuRq31QjwHqtBEUztyBmM9YZKdeFhsDLBURNdk1CFW42uWsmTsaqrXSoiCj7nCjfP0pwTOzxhQZra/A=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.37.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-JAYqsm6sTfZZbUp1CQfWZ+prXg9qBRSs5bO7bgLdD9SiqsDHn2+EfJXESL6uLqT/UO5FYvE16wivup0EOHit5w=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.37.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-EZj3TurW1iLbq+7tBr++wsxwFyD+pvjMrTNRuSynDrs8J7w46cu/ZIzU/lFw7OG1/tDRDZ9nrKXxwbvIKXo2zA=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.37.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ELXrDe1xRj+f7VpzJO2j54izMbi+Hov+kdqusXO3T1BwVEbA5sWgZrVMqkwEsj4k6Lw/obJK1SLUeNulR1D//g=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.37.0", "", { "os": "linux", "cpu": "none" }, "sha512-79gMZgLD62dGmo5Xl4gaMc6NHRFj3GuxPrchHBlW54tcRSXTtb3gLh/J6Bl8nbbzSFRQGR7dkNQ8yYadXt6txQ=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.37.0", "", { "os": "linux", "cpu": "none" }, "sha512-QFdi9OhyWxnh975jeG490atcINXZwZb7epyNASPaT4wcodOTuDitrDgSPT8CFl8BcGOFTGZ6c3P/s8Afeg1Ngg=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.37.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qweAj7+pLFQXfe3UU7EZiOmo+/2SWjzVZjyyTDcrZAT0E92zEKJBvYpHinUAOqipfo2Xlp8GIfq0FSb5Tmqd8g=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.37.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Lqc/0vS20qzZLw1ThpWn1hQgRqj4rM+E7PuBzrqp+wLH5lYFqieAiontGpl2pMPvJ0QrmQYav9mslHlAB5kOSQ=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.37.0", "", { "os": "linux", "cpu": "x64" }, "sha512-TnJm22+1cEcpYXzbcXS5Z9+9c+R0ronFdx5bG4OTdOL/wSpQQKzc2izgAXJ03QkP3tq7aAPhlhhxasvH3xgoUA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.37.0", "", { "os": "none", "cpu": "arm64" }, "sha512-YLq27qMur3hPUponvV3Zr0oHxowox71j3+nc+/oCc1O+M0zFafhd6AoAoCiRrSYRW+asWhz3/UMPh0bYpimcMw=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.37.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-0lYOsiYSODNh5RE9VqsydSUY7yMz8l+C4O2i3zpdZWEDNR6Tk949sMbakwUbtE5hViHnAq1cubr197DzKW+d6g=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.37.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-KHQF8DsMTE6nqQ5uBU0sx8sQsyBK/PzJdJV65+28lJGOJO59jCS5WlGcKnGtq14a2B3Xr6LoJGrSFi19xsBs/A=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.37.0", "", { "os": "win32", "cpu": "x64" }, "sha512-tDVVCHOPbIJ+sQE1z2DdWk82ewhmgcbXlYv4xUCnkY75vM7R3VkVgO2KqgEolMRXwI5RrsAbk+ZoP9/LKdzKVg=="], "@oxlint/darwin-arm64": ["@oxlint/darwin-arm64@1.42.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ui5CdAcDsXPQwZQEXOOSWsilJWhgj9jqHCvYBm2tDE8zfwZZuF9q58+hGKH1x5y0SV4sRlyobB2Quq6uU6EgeA=="], @@ -1954,7 +1975,7 @@ "oxc-transform": ["oxc-transform@0.110.0", "", { "optionalDependencies": { "@oxc-transform/binding-android-arm-eabi": "0.110.0", "@oxc-transform/binding-android-arm64": "0.110.0", "@oxc-transform/binding-darwin-arm64": "0.110.0", "@oxc-transform/binding-darwin-x64": "0.110.0", "@oxc-transform/binding-freebsd-x64": "0.110.0", "@oxc-transform/binding-linux-arm-gnueabihf": "0.110.0", "@oxc-transform/binding-linux-arm-musleabihf": "0.110.0", "@oxc-transform/binding-linux-arm64-gnu": "0.110.0", "@oxc-transform/binding-linux-arm64-musl": "0.110.0", "@oxc-transform/binding-linux-ppc64-gnu": "0.110.0", "@oxc-transform/binding-linux-riscv64-gnu": "0.110.0", "@oxc-transform/binding-linux-riscv64-musl": "0.110.0", "@oxc-transform/binding-linux-s390x-gnu": "0.110.0", "@oxc-transform/binding-linux-x64-gnu": "0.110.0", "@oxc-transform/binding-linux-x64-musl": "0.110.0", "@oxc-transform/binding-openharmony-arm64": "0.110.0", "@oxc-transform/binding-wasm32-wasi": "0.110.0", "@oxc-transform/binding-win32-arm64-msvc": "0.110.0", "@oxc-transform/binding-win32-ia32-msvc": "0.110.0", "@oxc-transform/binding-win32-x64-msvc": "0.110.0" } }, "sha512-/fymQNzzUoKZweH0nC5yvbI2eR0yWYusT9TEKDYVgOgYrf9Qmdez9lUFyvxKR9ycx+PTHi/reIOzqf3wkShQsw=="], - "oxfmt": ["oxfmt@0.27.0", "", { "dependencies": { "tinypool": "2.0.0" }, "optionalDependencies": { "@oxfmt/darwin-arm64": "0.27.0", "@oxfmt/darwin-x64": "0.27.0", "@oxfmt/linux-arm64-gnu": "0.27.0", "@oxfmt/linux-arm64-musl": "0.27.0", "@oxfmt/linux-x64-gnu": "0.27.0", "@oxfmt/linux-x64-musl": "0.27.0", "@oxfmt/win32-arm64": "0.27.0", "@oxfmt/win32-x64": "0.27.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-FHR0HR3WeMKBuVEQvW3EeiRZXs/cQzNHxGbhCoAIEPr1FVcOa9GCqrKJXPqv2jkzmCg6Wqot+DvN9RzemyFJhw=="], + "oxfmt": ["oxfmt@0.37.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.37.0", "@oxfmt/binding-android-arm64": "0.37.0", "@oxfmt/binding-darwin-arm64": "0.37.0", "@oxfmt/binding-darwin-x64": "0.37.0", "@oxfmt/binding-freebsd-x64": "0.37.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.37.0", "@oxfmt/binding-linux-arm-musleabihf": "0.37.0", "@oxfmt/binding-linux-arm64-gnu": "0.37.0", "@oxfmt/binding-linux-arm64-musl": "0.37.0", "@oxfmt/binding-linux-ppc64-gnu": "0.37.0", "@oxfmt/binding-linux-riscv64-gnu": "0.37.0", "@oxfmt/binding-linux-riscv64-musl": "0.37.0", "@oxfmt/binding-linux-s390x-gnu": "0.37.0", "@oxfmt/binding-linux-x64-gnu": "0.37.0", "@oxfmt/binding-linux-x64-musl": "0.37.0", "@oxfmt/binding-openharmony-arm64": "0.37.0", "@oxfmt/binding-win32-arm64-msvc": "0.37.0", "@oxfmt/binding-win32-ia32-msvc": "0.37.0", "@oxfmt/binding-win32-x64-msvc": "0.37.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-Kd47gakZAU/i9KkXv3F0EDRoMvSso9O5966kflf9zYto0oZ0NN+Fh5vKKrLwp2Mkt0efYBk5LjCAS0BNC0y0eQ=="], "oxlint": ["oxlint@1.42.0", "", { "optionalDependencies": { "@oxlint/darwin-arm64": "1.42.0", "@oxlint/darwin-x64": "1.42.0", "@oxlint/linux-arm64-gnu": "1.42.0", "@oxlint/linux-arm64-musl": "1.42.0", "@oxlint/linux-x64-gnu": "1.42.0", "@oxlint/linux-x64-musl": "1.42.0", "@oxlint/win32-arm64": "1.42.0", "@oxlint/win32-x64": "1.42.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.11.2" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnspC/lrp8FgKNaONLLn14dm+W5t0SSlus6V5NJpgI2YNT1tkFYZt4fBf14ESxf9AAh98WBASnW5f0gtw462Lg=="], @@ -2312,7 +2333,7 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - "tinypool": ["tinypool@2.0.0", "", {}, "sha512-/RX9RzeH2xU5ADE7n2Ykvmi9ED3FBGPAjw9u3zucrNNaEBIO0HPSYgL0NT7+3p147ojeSdaVu08F6hjpv31HJg=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], diff --git a/commitlint.config.js b/commitlint.config.js index 6099623..1ee5f67 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -1,6 +1,6 @@ export default { - extends: ["@commitlint/config-conventional"], - rules: { - "body-max-line-length": [0, "always"], - }, + extends: ["@commitlint/config-conventional"], + rules: { + "body-max-line-length": [0, "always"], + }, }; diff --git a/components.json b/components.json index 6388ee1..4e6e982 100644 --- a/components.json +++ b/components.json @@ -1,23 +1,23 @@ { - "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", - "rsc": false, - "tsx": true, - "tailwind": { - "config": "", - "css": "frontend/src/index.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "lucide", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "registries": {} + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "frontend/src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": {} } diff --git a/conductor.json b/conductor.json index 1c68199..fcb6b35 100644 --- a/conductor.json +++ b/conductor.json @@ -1,7 +1,7 @@ { - "scripts": { - "setup": "./scripts/setup-conductor-worktree.sh", - "run": "bun run electron:dev", - "archive": "rm -rf " - } + "scripts": { + "setup": "./scripts/setup-conductor-worktree.sh", + "run": "bun run electron:dev", + "archive": "rm -rf " + } } diff --git a/drizzle.config.ts b/drizzle.config.ts index 2505082..c5ce072 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -3,10 +3,10 @@ import { defineConfig } from "drizzle-kit"; const databaseUrl = process.env.DATABASE_URL as any; export default defineConfig({ - out: "./drizzle", - schema: "./src/server/db/schema.ts", - dialect: "postgresql", - dbCredentials: { - url: databaseUrl, - }, + out: "./drizzle", + schema: "./src/server/db/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: databaseUrl, + }, }); diff --git a/knip.json b/knip.json index 72dfb46..3c891eb 100644 --- a/knip.json +++ b/knip.json @@ -1,22 +1,22 @@ { - "$schema": "https://unpkg.com/knip@5/schema.json", - "workspaces": { - ".": { - "entry": ["src/router.tsx", "src/routes/**/*.{ts,tsx}", "src/lib/auth-client.ts"], - "project": ["src/**/*.{ts,tsx}"], - "ignore": ["src/components/ui/**", "src/routeTree.gen.ts", "src/server/db/schema.ts"] + "$schema": "https://unpkg.com/knip@5/schema.json", + "workspaces": { + ".": { + "entry": ["src/router.tsx", "src/routes/**/*.{ts,tsx}", "src/lib/auth-client.ts"], + "project": ["src/**/*.{ts,tsx}"], + "ignore": ["src/components/ui/**", "src/routeTree.gen.ts", "src/server/db/schema.ts"] + }, + "packages/desktop-shell": { + "entry": ["src/preload.mts"], + "project": ["src/**/*.mts"] + }, + "packages/runner": { + "project": ["src/**/*.ts"] + }, + "packages/marketing": { + "entry": ["src/pages/**/*.{astro,ts}"], + "project": ["src/**/*.{astro,ts}"] + } }, - "packages/desktop-shell": { - "entry": ["src/preload.mts"], - "project": ["src/**/*.mts"] - }, - "packages/runner": { - "project": ["src/**/*.ts"] - }, - "packages/marketing": { - "entry": ["src/pages/**/*.{astro,ts}"], - "project": ["src/**/*.{astro,ts}"] - } - }, - "ignoreDependencies": ["tailwindcss", "shadcn", "tw-animate-css"] + "ignoreDependencies": ["tailwindcss", "shadcn", "tw-animate-css"] } diff --git a/package.json b/package.json index 7180f52..710cb28 100644 --- a/package.json +++ b/package.json @@ -1,138 +1,138 @@ { - "name": "clanki", - "version": "0.1.0", - "private": true, - "description": "Clanki desktop application", - "author": "Ruud Andriessen", - "repository": { - "type": "git", - "url": "git@github.com:ruudandriessen/clanki.ai.git" - }, - "workspaces": [ - "packages/*" - ], - "type": "module", - "main": "packages/desktop-shell/dist/index.mjs", - "scripts": { - "dev": "vite dev", - "build:web": "vite build", - "build": "bun run build:web && bun run --cwd packages/runner build && bun run --cwd packages/desktop-shell build && tsc --noEmit", - "preview": "vite preview", - "runner:dev": "bun run --cwd packages/runner dev", - "electron:start": "bun run build && electron packages/desktop-shell/dist/index.mjs", - "electron:build": "bun run build", - "electron:dev": "bun run --cwd packages/desktop-shell build && concurrently -k \"bun run --cwd packages/desktop-shell dev\" \"bun run dev -- --host 127.0.0.1 --port 1420\" \"CLANKI_ELECTRON_DEV_URL=http://127.0.0.1:1420 electron packages/desktop-shell/dist/index.mjs\"", - "package:desktop": "bun run build && electron-builder --publish never", - "release:desktop": "bun run build && electron-builder --publish always", - "db:generate": "drizzle-kit generate --config drizzle.config.ts", - "db:migrate": "DATABASE_URL=\"${DATABASE_URL:-$(grep '^DATABASE_URL=' .env | cut -d= -f2-)}\" sh -c '[ -n \"$DATABASE_URL\" ] || { echo \"Missing DATABASE_URL\"; exit 1; }; drizzle-kit migrate --config drizzle.config.ts'", - "db:push": "DATABASE_URL=\"${DATABASE_URL:-$(grep '^DATABASE_URL=' .env | cut -d= -f2-)}\" sh -c '[ -n \"$DATABASE_URL\" ] || { echo \"Missing DATABASE_URL\"; exit 1; }; drizzle-kit push --config drizzle.config.ts'", - "format": "oxfmt --write .", - "format:check": "oxfmt --check .", - "lint": "oxlint", - "lint:fix": "oxlint --fix", - "knip": "knip", - "test": "bun test", - "deploy": "bunx vercel deploy --prod" - }, - "dependencies": { - "@durable-streams/client": "^0.2.1", - "@electric-sql/client": "^1.5.4", - "@octokit/webhooks": "^14.2.0", - "@opencode-ai/sdk": "^1.2.1", - "@pierre/diffs": "^1.0.11", - "@tailwindcss/vite": "^4.1.18", - "@tanstack/electric-db-collection": "^0.2.33", - "@tanstack/hotkeys": "^0.4.1", - "@tanstack/react-db": "0.1.70", - "@tanstack/react-hotkeys": "^0.4.1", - "@tanstack/react-query": "^5.90.5", - "@tanstack/react-router": "^1.162.8", - "@tanstack/react-start": "^1.162.8", - "better-auth": "^1.4.18", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "drizzle-orm": "^0.45.1", - "lucide-react": "^0.563.0", - "motion": "^12.35.1", - "nitro": "^3.0.1-alpha.2", - "postgres": "^3.4.8", - "radix-ui": "^1.4.3", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "react-markdown": "^10.1.0", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.18", - "vite-tsconfig-paths": "^6.1.1", - "yaml": "^2.8.2", - "zod": "^4.3.6" - }, - "devDependencies": { - "@commitlint/cli": "^20.3.1", - "@commitlint/config-conventional": "^20.3.1", - "@types/node": "^24.3.0", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "@vitejs/plugin-react": "^4.3.0", - "babel-plugin-react-compiler": "^1.0.0", - "bun-types": "^1.3.8", - "concurrently": "^9.2.1", - "drizzle-kit": "^0.31.8", - "electron": "^36.0.0", - "electron-builder": "^26.0.12", - "knip": "^5.82.1", - "oxfmt": "^0.27.0", - "oxlint": "^1.42.0", - "shadcn": "^3.8.4", - "tw-animate-css": "^1.4.0", - "typescript": "^5.7.0", - "vite": "^7.3.1" - }, - "build": { - "appId": "ai.clanki.desktop", - "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", - "directories": { - "output": "release" + "name": "clanki", + "version": "0.1.0", + "private": true, + "description": "Clanki desktop application", + "author": "Ruud Andriessen", + "repository": { + "type": "git", + "url": "git@github.com:ruudandriessen/clanki.ai.git" }, - "files": [ - "packages/desktop-shell/dist/**/*" + "workspaces": [ + "packages/*" ], - "extraResources": [ - { - "from": ".output", - "to": ".output" - }, - { - "from": "packages/runner/dist", - "to": "packages/runner/dist" - } - ], - "mac": { - "category": "public.app-category.developer-tools", - "target": [ - "zip" - ] + "type": "module", + "main": "packages/desktop-shell/dist/index.mjs", + "scripts": { + "build": "bun run build:web && bun run --cwd packages/runner build && bun run --cwd packages/desktop-shell build && tsc --noEmit", + "build:web": "vite build", + "db:generate": "drizzle-kit generate --config drizzle.config.ts", + "db:migrate": "DATABASE_URL=\"${DATABASE_URL:-$(grep '^DATABASE_URL=' .env | cut -d= -f2-)}\" sh -c '[ -n \"$DATABASE_URL\" ] || { echo \"Missing DATABASE_URL\"; exit 1; }; drizzle-kit migrate --config drizzle.config.ts'", + "db:push": "DATABASE_URL=\"${DATABASE_URL:-$(grep '^DATABASE_URL=' .env | cut -d= -f2-)}\" sh -c '[ -n \"$DATABASE_URL\" ] || { echo \"Missing DATABASE_URL\"; exit 1; }; drizzle-kit push --config drizzle.config.ts'", + "deploy": "bunx vercel deploy --prod", + "dev": "vite dev", + "electron:build": "bun run build", + "electron:dev": "bun run --cwd packages/desktop-shell build && concurrently -k \"bun run --cwd packages/desktop-shell dev\" \"bun run dev -- --host 127.0.0.1 --port 1420\" \"CLANKI_ELECTRON_DEV_URL=http://127.0.0.1:1420 electron packages/desktop-shell/dist/index.mjs\"", + "electron:start": "bun run build && electron packages/desktop-shell/dist/index.mjs", + "format": "oxfmt --write .", + "format:check": "oxfmt --check .", + "knip": "knip", + "lint": "oxlint", + "lint:fix": "oxlint --fix", + "package:desktop": "bun run build && electron-builder --publish never", + "preview": "vite preview", + "release:desktop": "bun run build && electron-builder --publish always", + "runner:dev": "bun run --cwd packages/runner dev", + "test": "bun test" + }, + "dependencies": { + "@durable-streams/client": "^0.2.1", + "@electric-sql/client": "^1.5.4", + "@octokit/webhooks": "^14.2.0", + "@opencode-ai/sdk": "^1.2.1", + "@pierre/diffs": "^1.0.11", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/electric-db-collection": "^0.2.33", + "@tanstack/hotkeys": "^0.4.1", + "@tanstack/react-db": "0.1.70", + "@tanstack/react-hotkeys": "^0.4.1", + "@tanstack/react-query": "^5.90.5", + "@tanstack/react-router": "^1.162.8", + "@tanstack/react-start": "^1.162.8", + "better-auth": "^1.4.18", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "drizzle-orm": "^0.45.1", + "lucide-react": "^0.563.0", + "motion": "^12.35.1", + "nitro": "^3.0.1-alpha.2", + "postgres": "^3.4.8", + "radix-ui": "^1.4.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "vite-tsconfig-paths": "^6.1.1", + "yaml": "^2.8.2", + "zod": "^4.3.6" }, - "linux": { - "category": "Development", - "target": [ - "zip" - ] + "devDependencies": { + "@commitlint/cli": "^20.3.1", + "@commitlint/config-conventional": "^20.3.1", + "@types/node": "^24.3.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "babel-plugin-react-compiler": "^1.0.0", + "bun-types": "^1.3.8", + "concurrently": "^9.2.1", + "drizzle-kit": "^0.31.8", + "electron": "^36.0.0", + "electron-builder": "^26.0.12", + "knip": "^5.82.1", + "oxfmt": "^0.37.0", + "oxlint": "^1.42.0", + "shadcn": "^3.8.4", + "tw-animate-css": "^1.4.0", + "typescript": "^5.7.0", + "vite": "^7.3.1" }, - "win": { - "target": [ - "zip" - ] + "build": { + "appId": "ai.clanki.desktop", + "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", + "directories": { + "output": "release" + }, + "files": [ + "packages/desktop-shell/dist/**/*" + ], + "extraResources": [ + { + "from": ".output", + "to": ".output" + }, + { + "from": "packages/runner/dist", + "to": "packages/runner/dist" + } + ], + "mac": { + "category": "public.app-category.developer-tools", + "target": [ + "zip" + ] + }, + "linux": { + "category": "Development", + "target": [ + "zip" + ] + }, + "win": { + "target": [ + "zip" + ] + }, + "publish": [ + { + "channel": "latest", + "owner": "ruudandriessen", + "provider": "github", + "repo": "clanki.ai", + "releaseType": "release" + } + ] }, - "publish": [ - { - "channel": "latest", - "owner": "ruudandriessen", - "provider": "github", - "repo": "clanki.ai", - "releaseType": "release" - } - ] - }, - "productName": "Clanki" + "productName": "Clanki" } diff --git a/packages/desktop-shell/package.json b/packages/desktop-shell/package.json index dc1c209..34d2b7b 100644 --- a/packages/desktop-shell/package.json +++ b/packages/desktop-shell/package.json @@ -1,10 +1,10 @@ { - "name": "@clanki/desktop-shell", - "private": true, - "type": "module", - "main": "./dist/index.mjs", - "scripts": { - "build": "tsc -p tsconfig.json", - "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" - } + "name": "@clanki/desktop-shell", + "private": true, + "type": "module", + "main": "./dist/index.mjs", + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput" + } } diff --git a/packages/desktop-shell/src/app-server.mts b/packages/desktop-shell/src/app-server.mts index 33f00d2..1666beb 100644 --- a/packages/desktop-shell/src/app-server.mts +++ b/packages/desktop-shell/src/app-server.mts @@ -1,6 +1,6 @@ +import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { spawn, type ChildProcess } from "node:child_process"; import { attachProcessStderr, reserveLocalPort, diff --git a/packages/desktop-shell/src/index.mts b/packages/desktop-shell/src/index.mts index 563f4d9..ee73f85 100644 --- a/packages/desktop-shell/src/index.mts +++ b/packages/desktop-shell/src/index.mts @@ -1,6 +1,6 @@ +import { app, BrowserWindow, ipcMain, shell } from "electron"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { app, BrowserWindow, ipcMain, shell } from "electron"; import { createAppServerController } from "./app-server.mjs"; import { createDesktopRunnerController } from "./desktop-runner.mjs"; diff --git a/packages/desktop-shell/src/node-utils.mts b/packages/desktop-shell/src/node-utils.mts index 7660cb1..6f6c501 100644 --- a/packages/desktop-shell/src/node-utils.mts +++ b/packages/desktop-shell/src/node-utils.mts @@ -1,7 +1,8 @@ -import type { ChildProcess } from "node:child_process"; import net from "node:net"; import { setTimeout as delay } from "node:timers/promises"; +import type { ChildProcess } from "node:child_process"; + type WaitOptions = { check?: () => void; timeoutMs?: number; diff --git a/packages/desktop-shell/tsconfig.json b/packages/desktop-shell/tsconfig.json index afefd8d..e1b5d33 100644 --- a/packages/desktop-shell/tsconfig.json +++ b/packages/desktop-shell/tsconfig.json @@ -1,18 +1,18 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "lib": ["ES2023", "DOM"], - "types": ["node"], - "skipLibCheck": true, - "resolveJsonModule": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "rootDir": "./src", - "outDir": "./dist", - "noEmit": false - }, - "include": ["src/**/*.mts"] + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "lib": ["ES2023", "DOM"], + "types": ["node"], + "skipLibCheck": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "rootDir": "./src", + "outDir": "./dist", + "noEmit": false + }, + "include": ["src/**/*.mts"] } diff --git a/packages/marketing/.astro/content.d.ts b/packages/marketing/.astro/content.d.ts index a9dd670..31ef4e3 100644 --- a/packages/marketing/.astro/content.d.ts +++ b/packages/marketing/.astro/content.d.ts @@ -1,195 +1,200 @@ declare module "astro:content" { - export interface RenderResult { - Content: import("astro/runtime/server/index.js").AstroComponentFactory; - headings: import("astro").MarkdownHeading[]; - remarkPluginFrontmatter: Record; - } - interface Render { - ".md": Promise; - } - - export interface RenderedContent { - html: string; - metadata?: { - imagePaths: Array; - [key: string]: unknown; - }; - } + export interface RenderResult { + Content: import("astro/runtime/server/index.js").AstroComponentFactory; + headings: import("astro").MarkdownHeading[]; + remarkPluginFrontmatter: Record; + } + interface Render { + ".md": Promise; + } + + export interface RenderedContent { + html: string; + metadata?: { + imagePaths: Array; + [key: string]: unknown; + }; + } } declare module "astro:content" { - type Flatten = T extends { [K: string]: infer U } ? U : never; - - export type CollectionKey = keyof AnyEntryMap; - export type CollectionEntry = Flatten; - - export type ContentCollectionKey = keyof ContentEntryMap; - export type DataCollectionKey = keyof DataEntryMap; - - type AllValuesOf = T extends any ? T[keyof T] : never; - type ValidContentEntrySlug = AllValuesOf< - ContentEntryMap[C] - >["slug"]; - - export type ReferenceDataEntry< - C extends CollectionKey, - E extends keyof DataEntryMap[C] = string, - > = { - collection: C; - id: E; - }; - export type ReferenceContentEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}) = string, - > = { - collection: C; - slug: E; - }; - export type ReferenceLiveEntry = { - collection: C; - id: string; - }; - - /** @deprecated Use `getEntry` instead. */ - export function getEntryBySlug< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - collection: C, - // Note that this has to accept a regular string too, for SSR - entrySlug: E, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - - /** @deprecated Use `getEntry` instead. */ - export function getDataEntryById( - collection: C, - entryId: E, - ): Promise>; - - export function getCollection>( - collection: C, - filter?: (entry: CollectionEntry) => entry is E, - ): Promise; - export function getCollection( - collection: C, - filter?: (entry: CollectionEntry) => unknown, - ): Promise[]>; - - export function getLiveCollection( - collection: C, - filter?: LiveLoaderCollectionFilterType, - ): Promise< - import("astro").LiveDataCollectionResult, LiveLoaderErrorType> - >; - - export function getEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - entry: ReferenceContentEntry, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - export function getEntry< - C extends keyof DataEntryMap, - E extends keyof DataEntryMap[C] | (string & {}), - >( - entry: ReferenceDataEntry, - ): E extends keyof DataEntryMap[C] - ? Promise - : Promise | undefined>; - export function getEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - collection: C, - slug: E, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - export function getEntry< - C extends keyof DataEntryMap, - E extends keyof DataEntryMap[C] | (string & {}), - >( - collection: C, - id: E, - ): E extends keyof DataEntryMap[C] - ? string extends keyof DataEntryMap[C] - ? Promise | undefined - : Promise - : Promise | undefined>; - export function getLiveEntry( - collection: C, - filter: string | LiveLoaderEntryFilterType, - ): Promise, LiveLoaderErrorType>>; - - /** Resolve an array of entry references from the same collection */ - export function getEntries( - entries: ReferenceContentEntry>[], - ): Promise[]>; - export function getEntries( - entries: ReferenceDataEntry[], - ): Promise[]>; - - export function render( - entry: AnyEntryMap[C][string], - ): Promise; - - export function reference( - collection: C, - ): import("astro/zod").ZodEffects< - import("astro/zod").ZodString, - C extends keyof ContentEntryMap - ? ReferenceContentEntry> - : ReferenceDataEntry - >; - // Allow generic `string` to avoid excessive type errors in the config - // if `dev` is not running to update as you edit. - // Invalid collection names will be caught at build time. - export function reference( - collection: C, - ): import("astro/zod").ZodEffects; - - type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; - type InferEntrySchema = import("astro/zod").infer< - ReturnTypeOrOriginal["schema"]> - >; - - type ContentEntryMap = {}; - - type DataEntryMap = {}; - - type AnyEntryMap = ContentEntryMap & DataEntryMap; - - type ExtractLoaderTypes = T extends import("astro/loaders").LiveLoader< - infer TData, - infer TEntryFilter, - infer TCollectionFilter, - infer TError - > - ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } - : { data: never; entryFilter: never; collectionFilter: never; error: never }; - type ExtractDataType = ExtractLoaderTypes["data"]; - type ExtractEntryFilterType = ExtractLoaderTypes["entryFilter"]; - type ExtractCollectionFilterType = ExtractLoaderTypes["collectionFilter"]; - type ExtractErrorType = ExtractLoaderTypes["error"]; - - type LiveLoaderDataType = - LiveContentConfig["collections"][C]["schema"] extends undefined - ? ExtractDataType - : import("astro/zod").infer< - Exclude - >; - type LiveLoaderEntryFilterType = - ExtractEntryFilterType; - type LiveLoaderCollectionFilterType = - ExtractCollectionFilterType; - type LiveLoaderErrorType = ExtractErrorType< - LiveContentConfig["collections"][C]["loader"] - >; - - export type ContentConfig = typeof import("../src/content.config.mjs"); - export type LiveContentConfig = never; + type Flatten = T extends { [K: string]: infer U } ? U : never; + + export type CollectionKey = keyof AnyEntryMap; + export type CollectionEntry = Flatten; + + export type ContentCollectionKey = keyof ContentEntryMap; + export type DataCollectionKey = keyof DataEntryMap; + + type AllValuesOf = T extends any ? T[keyof T] : never; + type ValidContentEntrySlug = AllValuesOf< + ContentEntryMap[C] + >["slug"]; + + export type ReferenceDataEntry< + C extends CollectionKey, + E extends keyof DataEntryMap[C] = string, + > = { + collection: C; + id: E; + }; + export type ReferenceContentEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}) = string, + > = { + collection: C; + slug: E; + }; + export type ReferenceLiveEntry = { + collection: C; + id: string; + }; + + /** @deprecated Use `getEntry` instead. */ + export function getEntryBySlug< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + collection: C, + // Note that this has to accept a regular string too, for SSR + entrySlug: E, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + + /** @deprecated Use `getEntry` instead. */ + export function getDataEntryById( + collection: C, + entryId: E, + ): Promise>; + + export function getCollection>( + collection: C, + filter?: (entry: CollectionEntry) => entry is E, + ): Promise; + export function getCollection( + collection: C, + filter?: (entry: CollectionEntry) => unknown, + ): Promise[]>; + + export function getLiveCollection( + collection: C, + filter?: LiveLoaderCollectionFilterType, + ): Promise< + import("astro").LiveDataCollectionResult, LiveLoaderErrorType> + >; + + export function getEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + entry: ReferenceContentEntry, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + entry: ReferenceDataEntry, + ): E extends keyof DataEntryMap[C] + ? Promise + : Promise | undefined>; + export function getEntry< + C extends keyof ContentEntryMap, + E extends ValidContentEntrySlug | (string & {}), + >( + collection: C, + slug: E, + ): E extends ValidContentEntrySlug + ? Promise> + : Promise | undefined>; + export function getEntry< + C extends keyof DataEntryMap, + E extends keyof DataEntryMap[C] | (string & {}), + >( + collection: C, + id: E, + ): E extends keyof DataEntryMap[C] + ? string extends keyof DataEntryMap[C] + ? Promise | undefined + : Promise + : Promise | undefined>; + export function getLiveEntry( + collection: C, + filter: string | LiveLoaderEntryFilterType, + ): Promise, LiveLoaderErrorType>>; + + /** Resolve an array of entry references from the same collection */ + export function getEntries( + entries: ReferenceContentEntry>[], + ): Promise[]>; + export function getEntries( + entries: ReferenceDataEntry[], + ): Promise[]>; + + export function render( + entry: AnyEntryMap[C][string], + ): Promise; + + export function reference( + collection: C, + ): import("astro/zod").ZodEffects< + import("astro/zod").ZodString, + C extends keyof ContentEntryMap + ? ReferenceContentEntry> + : ReferenceDataEntry + >; + // Allow generic `string` to avoid excessive type errors in the config + // if `dev` is not running to update as you edit. + // Invalid collection names will be caught at build time. + export function reference( + collection: C, + ): import("astro/zod").ZodEffects; + + type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; + type InferEntrySchema = import("astro/zod").infer< + ReturnTypeOrOriginal["schema"]> + >; + + type ContentEntryMap = {}; + + type DataEntryMap = {}; + + type AnyEntryMap = ContentEntryMap & DataEntryMap; + + type ExtractLoaderTypes = T extends import("astro/loaders").LiveLoader< + infer TData, + infer TEntryFilter, + infer TCollectionFilter, + infer TError + > + ? { + data: TData; + entryFilter: TEntryFilter; + collectionFilter: TCollectionFilter; + error: TError; + } + : { data: never; entryFilter: never; collectionFilter: never; error: never }; + type ExtractDataType = ExtractLoaderTypes["data"]; + type ExtractEntryFilterType = ExtractLoaderTypes["entryFilter"]; + type ExtractCollectionFilterType = ExtractLoaderTypes["collectionFilter"]; + type ExtractErrorType = ExtractLoaderTypes["error"]; + + type LiveLoaderDataType = + LiveContentConfig["collections"][C]["schema"] extends undefined + ? ExtractDataType + : import("astro/zod").infer< + Exclude + >; + type LiveLoaderEntryFilterType = + ExtractEntryFilterType; + type LiveLoaderCollectionFilterType = + ExtractCollectionFilterType; + type LiveLoaderErrorType = ExtractErrorType< + LiveContentConfig["collections"][C]["loader"] + >; + + export type ContentConfig = typeof import("../src/content.config.mjs"); + export type LiveContentConfig = never; } diff --git a/packages/marketing/.astro/data-store.json b/packages/marketing/.astro/data-store.json index fe3ba55..d1fee9f 100644 --- a/packages/marketing/.astro/data-store.json +++ b/packages/marketing/.astro/data-store.json @@ -1,9 +1,9 @@ [ - ["Map", 1, 2], - "meta::meta", - ["Map", 3, 4, 5, 6], - "astro-version", - "5.18.0", - "astro-config-digest", - "{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false}}" + ["Map", 1, 2], + "meta::meta", + ["Map", 3, 4, 5, 6], + "astro-version", + "5.18.0", + "astro-config-digest", + "{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[],\"actionBodySizeLimit\":1048576},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false,\"svgo\":false},\"legacy\":{\"collections\":false}}" ] diff --git a/packages/marketing/.astro/settings.json b/packages/marketing/.astro/settings.json index 3792b82..eb7167e 100644 --- a/packages/marketing/.astro/settings.json +++ b/packages/marketing/.astro/settings.json @@ -1,5 +1,5 @@ { - "_variables": { - "lastUpdateCheck": 1772968176027 - } + "_variables": { + "lastUpdateCheck": 1772968176027 + } } diff --git a/packages/marketing/astro.config.mjs b/packages/marketing/astro.config.mjs index 427c56a..c70e830 100644 --- a/packages/marketing/astro.config.mjs +++ b/packages/marketing/astro.config.mjs @@ -1,5 +1,5 @@ -import { defineConfig } from "astro/config"; import vercel from "@astrojs/vercel"; +import { defineConfig } from "astro/config"; export default defineConfig({ adapter: vercel(), diff --git a/packages/marketing/package.json b/packages/marketing/package.json index a90814f..eb5fed9 100644 --- a/packages/marketing/package.json +++ b/packages/marketing/package.json @@ -1,16 +1,16 @@ { - "name": "@clanki/marketing", - "version": "0.0.1", - "private": true, - "type": "module", - "scripts": { - "dev": "astro dev", - "build": "astro build", - "preview": "astro preview" - }, - "dependencies": { - "@astrojs/vercel": "^8.1.4", - "astro": "^5.10.3", - "ioredis": "^5.6.1" - } + "name": "@clanki/marketing", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build": "astro build", + "dev": "astro dev", + "preview": "astro preview" + }, + "dependencies": { + "@astrojs/vercel": "^8.1.4", + "astro": "^5.10.3", + "ioredis": "^5.6.1" + } } diff --git a/packages/marketing/src/pages/api/waitlist.ts b/packages/marketing/src/pages/api/waitlist.ts index 3b0ddcc..5ad9626 100644 --- a/packages/marketing/src/pages/api/waitlist.ts +++ b/packages/marketing/src/pages/api/waitlist.ts @@ -1,39 +1,40 @@ -import type { APIRoute } from "astro"; import Redis from "ioredis"; +import type { APIRoute } from "astro"; + export const prerender = false; const WAITLIST_KEY = "waitlist:emails"; export const POST: APIRoute = async ({ request }) => { - const body = await request.json().catch(() => null); - const email = body?.email; + const body = await request.json().catch(() => null); + const email = body?.email; - if (typeof email !== "string" || !email.includes("@")) { - return new Response(JSON.stringify({ error: "Invalid email" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } + if (typeof email !== "string" || !email.includes("@")) { + return new Response(JSON.stringify({ error: "Invalid email" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const redisUrl = import.meta.env.REDIS_URL; + if (!redisUrl) { + return new Response(JSON.stringify({ error: "Server misconfigured" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + const normalized = email.trim().toLowerCase(); + const redis = new Redis(redisUrl); + try { + await redis.sadd(WAITLIST_KEY, normalized); + } finally { + redis.disconnect(); + } - const redisUrl = import.meta.env.REDIS_URL; - if (!redisUrl) { - return new Response(JSON.stringify({ error: "Server misconfigured" }), { - status: 500, - headers: { "Content-Type": "application/json" }, + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, }); - } - - const normalized = email.trim().toLowerCase(); - const redis = new Redis(redisUrl); - try { - await redis.sadd(WAITLIST_KEY, normalized); - } finally { - redis.disconnect(); - } - - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); }; diff --git a/packages/marketing/tsconfig.json b/packages/marketing/tsconfig.json index bcbf8b5..418a1a1 100644 --- a/packages/marketing/tsconfig.json +++ b/packages/marketing/tsconfig.json @@ -1,3 +1,3 @@ { - "extends": "astro/tsconfigs/strict" + "extends": "astro/tsconfigs/strict" } diff --git a/packages/runner/package.json b/packages/runner/package.json index 76e4f30..752c6a6 100644 --- a/packages/runner/package.json +++ b/packages/runner/package.json @@ -1,17 +1,17 @@ { - "name": "@clanki/runner", - "private": true, - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "scripts": { - "build": "bun build ./src/cli.ts --target=bun --format=esm --packages=bundle --outfile=./dist/cli.mjs", - "dev": "bun run ./src/cli.ts" - }, - "dependencies": { - "@hono/node-server": "^1.19.9", - "@opencode-ai/sdk": "^1.2.1", - "hono": "^4.11.7" - } + "name": "@clanki/runner", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "bun build ./src/cli.ts --target=bun --format=esm --packages=bundle --outfile=./dist/cli.mjs", + "dev": "bun run ./src/cli.ts" + }, + "dependencies": { + "@hono/node-server": "^1.19.9", + "@opencode-ai/sdk": "^1.2.1", + "hono": "^4.11.7" + } } diff --git a/packages/runner/src/assistant-session-diff.ts b/packages/runner/src/assistant-session-diff.ts index 3310005..d952644 100644 --- a/packages/runner/src/assistant-session-diff.ts +++ b/packages/runner/src/assistant-session-diff.ts @@ -1,143 +1,144 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; -import { spawnSync } from "node:child_process"; + import type { FileDiff } from "@opencode-ai/sdk"; export async function getAssistantSessionDiff(args: { - directory: string; - messageId?: string; - sessionId: string; + directory: string; + messageId?: string; + sessionId: string; }): Promise { - void args.messageId; - void args.sessionId; - - const directory = path.resolve(args.directory); - const defaultBranch = resolveDefaultBranch(directory); - fetchDefaultBranch(directory, defaultBranch); - - const mergeBase = runGitCommand( - directory, - ["merge-base", `origin/${defaultBranch}`, "HEAD"], - "Failed to resolve merge base for workspace diff", - ).trim(); - - if (mergeBase.length === 0) { - throw new Error("Failed to resolve merge base for workspace diff"); - } - - const changedFiles = listChangedFiles(directory, mergeBase); - - return changedFiles.map((file) => { - const stats = readDiffStats(directory, mergeBase, file); - - return { - additions: stats.additions, - after: readWorkingTreeFile(directory, file), - before: readGitFile(directory, mergeBase, file), - deletions: stats.deletions, - file, - }; - }); + void args.messageId; + void args.sessionId; + + const directory = path.resolve(args.directory); + const defaultBranch = resolveDefaultBranch(directory); + fetchDefaultBranch(directory, defaultBranch); + + const mergeBase = runGitCommand( + directory, + ["merge-base", `origin/${defaultBranch}`, "HEAD"], + "Failed to resolve merge base for workspace diff", + ).trim(); + + if (mergeBase.length === 0) { + throw new Error("Failed to resolve merge base for workspace diff"); + } + + const changedFiles = listChangedFiles(directory, mergeBase); + + return changedFiles.map((file) => { + const stats = readDiffStats(directory, mergeBase, file); + + return { + additions: stats.additions, + after: readWorkingTreeFile(directory, file), + before: readGitFile(directory, mergeBase, file), + deletions: stats.deletions, + file, + }; + }); } function fetchDefaultBranch(directory: string, defaultBranch: string): void { - runGitCommand( - directory, - ["fetch", "origin", defaultBranch, "--prune"], - `Failed to fetch origin/${defaultBranch} for workspace diff`, - ); + runGitCommand( + directory, + ["fetch", "origin", defaultBranch, "--prune"], + `Failed to fetch origin/${defaultBranch} for workspace diff`, + ); } function listChangedFiles(directory: string, mergeBase: string): string[] { - const output = runGitCommand( - directory, - ["diff", "--name-only", "--no-renames", mergeBase, "--"], - "Failed to list workspace diff files", - ); - - return output - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); + const output = runGitCommand( + directory, + ["diff", "--name-only", "--no-renames", mergeBase, "--"], + "Failed to list workspace diff files", + ); + + return output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); } function readGitFile(directory: string, revision: string, file: string): string { - const output = spawnSync("git", ["-C", directory, "show", `${revision}:${file}`], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); + const output = spawnSync("git", ["-C", directory, "show", `${revision}:${file}`], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); - if (output.status === 0) { - return output.stdout; - } + if (output.status === 0) { + return output.stdout; + } - const stderr = output.stderr.trim(); - if (stderr.includes("exists on disk, but not in") || stderr.includes("does not exist in")) { - return ""; - } + const stderr = output.stderr.trim(); + if (stderr.includes("exists on disk, but not in") || stderr.includes("does not exist in")) { + return ""; + } - throw new Error(stderr || `Failed to read ${file} from ${revision}`); + throw new Error(stderr || `Failed to read ${file} from ${revision}`); } function readDiffStats( - directory: string, - mergeBase: string, - file: string, + directory: string, + mergeBase: string, + file: string, ): { additions: number; deletions: number } { - const output = runGitCommand( - directory, - ["diff", "--numstat", "--no-renames", mergeBase, "--", file], - `Failed to read diff stats for ${file}`, - ).trim(); - - const [additionsRaw = "0", deletionsRaw = "0"] = output.split("\t"); - - return { - additions: Number.parseInt(additionsRaw, 10) || 0, - deletions: Number.parseInt(deletionsRaw, 10) || 0, - }; + const output = runGitCommand( + directory, + ["diff", "--numstat", "--no-renames", mergeBase, "--", file], + `Failed to read diff stats for ${file}`, + ).trim(); + + const [additionsRaw = "0", deletionsRaw = "0"] = output.split("\t"); + + return { + additions: Number.parseInt(additionsRaw, 10) || 0, + deletions: Number.parseInt(deletionsRaw, 10) || 0, + }; } function readWorkingTreeFile(directory: string, file: string): string { - const absolutePath = path.join(directory, file); - if (!fs.existsSync(absolutePath)) { - return ""; - } + const absolutePath = path.join(directory, file); + if (!fs.existsSync(absolutePath)) { + return ""; + } - return fs.readFileSync(absolutePath, "utf8"); + return fs.readFileSync(absolutePath, "utf8"); } function resolveDefaultBranch(directory: string): string { - const branch = runGitCommand( - directory, - ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], - "Failed to resolve default branch for workspace diff", - ) - .trim() - .replace(/^origin\//u, ""); - - if (branch.length === 0) { - throw new Error("Failed to resolve default branch for workspace diff"); - } - - return branch; + const branch = runGitCommand( + directory, + ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + "Failed to resolve default branch for workspace diff", + ) + .trim() + .replace(/^origin\//u, ""); + + if (branch.length === 0) { + throw new Error("Failed to resolve default branch for workspace diff"); + } + + return branch; } function runGitCommand(directory: string, args: string[], errorContext: string): string { - const output = spawnSync("git", ["-C", directory, ...args], { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (output.error) { - throw new Error(`${errorContext}: ${output.error.message}`); - } - - if (output.status === 0) { - return output.stdout; - } - - const stderr = output.stderr.trim(); - const stdout = output.stdout.trim(); - throw new Error(`${errorContext}: ${stderr || stdout || `exit status ${output.status}`}`); + const output = spawnSync("git", ["-C", directory, ...args], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (output.error) { + throw new Error(`${errorContext}: ${output.error.message}`); + } + + if (output.status === 0) { + return output.stdout; + } + + const stderr = output.stderr.trim(); + const stdout = output.stdout.trim(); + throw new Error(`${errorContext}: ${stderr || stdout || `exit status ${output.status}`}`); } diff --git a/packages/runner/src/assistant-session.ts b/packages/runner/src/assistant-session.ts index 3035bea..ca1ed51 100644 --- a/packages/runner/src/assistant-session.ts +++ b/packages/runner/src/assistant-session.ts @@ -1,102 +1,102 @@ -import { createLocalRunnerOpencodeClient } from "./opencode-client"; import { toProviderModelRef } from "./opencode"; +import { createLocalRunnerOpencodeClient } from "./opencode-client"; export async function ensureAssistantSession(args: { - directory: string; - existingSessionId?: string | null; - model?: string; - provider?: string; - taskTitle: string; + directory: string; + existingSessionId?: string | null; + model?: string; + provider?: string; + taskTitle: string; }): Promise<{ isNewSession: boolean; sessionId: string }> { - const clientConfig = - args.provider && args.model - ? { - enabled_providers: [args.provider], - model: toProviderModelRef(args.provider, args.model), - } - : undefined; - const { client } = await createLocalRunnerOpencodeClient({ - directory: args.directory, - config: clientConfig, - }); + const clientConfig = + args.provider && args.model + ? { + enabled_providers: [args.provider], + model: toProviderModelRef(args.provider, args.model), + } + : undefined; + const { client } = await createLocalRunnerOpencodeClient({ + directory: args.directory, + config: clientConfig, + }); - let sessionId = args.existingSessionId?.trim() ?? ""; - let isNewSession = false; + let sessionId = args.existingSessionId?.trim() ?? ""; + let isNewSession = false; - if (sessionId.length > 0) { - try { - const existing = await client.session.get({ - path: { id: sessionId }, - query: { directory: args.directory }, - }); - if (!existing.data) { - sessionId = ""; - } - } catch { - sessionId = ""; + if (sessionId.length > 0) { + try { + const existing = await client.session.get({ + path: { id: sessionId }, + query: { directory: args.directory }, + }); + if (!existing.data) { + sessionId = ""; + } + } catch { + sessionId = ""; + } } - } - if (sessionId.length === 0) { - const createResponse = await client.session.create({ - query: { directory: args.directory }, - body: { title: args.taskTitle }, - }); + if (sessionId.length === 0) { + const createResponse = await client.session.create({ + query: { directory: args.directory }, + body: { title: args.taskTitle }, + }); - if (!createResponse.response.ok || !createResponse.data?.id) { - throw new Error(formatStatusError("Failed to create OpenCode session", createResponse)); - } + if (!createResponse.response.ok || !createResponse.data?.id) { + throw new Error(formatStatusError("Failed to create OpenCode session", createResponse)); + } - sessionId = createResponse.data.id; - isNewSession = true; - } + sessionId = createResponse.data.id; + isNewSession = true; + } - return { isNewSession, sessionId }; + return { isNewSession, sessionId }; } export async function promptAssistantSession(args: { - directory: string; - model?: string; - provider?: string; - prompt: string; - sessionId: string; + directory: string; + model?: string; + provider?: string; + prompt: string; + sessionId: string; }): Promise { - const { client } = await createLocalRunnerOpencodeClient({ - directory: args.directory, - }); - const promptResponse = await client.session.promptAsync({ - path: { id: args.sessionId }, - query: { directory: args.directory }, - body: { - model: - args.provider && args.model - ? { - modelID: args.model, - providerID: args.provider, - } - : undefined, - parts: [{ type: "text", text: args.prompt }], - }, - }); + const { client } = await createLocalRunnerOpencodeClient({ + directory: args.directory, + }); + const promptResponse = await client.session.promptAsync({ + path: { id: args.sessionId }, + query: { directory: args.directory }, + body: { + model: + args.provider && args.model + ? { + modelID: args.model, + providerID: args.provider, + } + : undefined, + parts: [{ type: "text", text: args.prompt }], + }, + }); - if (!promptResponse.response.ok) { - throw new Error( - formatStatusError("Failed to dispatch prompt to OpenCode session", promptResponse), - ); - } + if (!promptResponse.response.ok) { + throw new Error( + formatStatusError("Failed to dispatch prompt to OpenCode session", promptResponse), + ); + } } function formatStatusError( - prefix: string, - response: - | { response: Response; data?: { id?: string } | null } - | { response: Response; data?: unknown | null }, + prefix: string, + response: + | { response: Response; data?: { id?: string } | null } + | { response: Response; data?: unknown | null }, ): string { - const statusText = response.response.statusText.trim(); - const statusInfo = - statusText.length > 0 - ? `${response.response.status} ${statusText}` - : String(response.response.status); + const statusText = response.response.statusText.trim(); + const statusInfo = + statusText.length > 0 + ? `${response.response.status} ${statusText}` + : String(response.response.status); - return `${prefix} (${statusInfo})`; + return `${prefix} (${statusInfo})`; } diff --git a/packages/runner/src/cli.ts b/packages/runner/src/cli.ts index 60c5e47..17a3af1 100644 --- a/packages/runner/src/cli.ts +++ b/packages/runner/src/cli.ts @@ -5,35 +5,35 @@ const server = await startLocalRunnerServer(options); const address = server.address(); if (!address || typeof address === "string") { - throw new Error("Failed to resolve local runner server address"); + throw new Error("Failed to resolve local runner server address"); } console.log(`Local runner listening on http://${address.address}:${address.port}`); function parseArgs(args: string[]): { host?: string; port?: number } { - const options: { host?: string; port?: number } = {}; - - for (let index = 0; index < args.length; index++) { - const arg = args[index]; - const value = args[index + 1]; - - if (arg === "--host" && value) { - options.host = value; - index += 1; - continue; - } - - if (arg === "--port" && value) { - const port = Number(value); - if (!Number.isInteger(port) || port <= 0) { - throw new Error(`Invalid port: ${value}`); - } - - options.port = port; - index += 1; - continue; + const options: { host?: string; port?: number } = {}; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + const value = args[index + 1]; + + if (arg === "--host" && value) { + options.host = value; + index += 1; + continue; + } + + if (arg === "--port" && value) { + const port = Number(value); + if (!Number.isInteger(port) || port <= 0) { + throw new Error(`Invalid port: ${value}`); + } + + options.port = port; + index += 1; + continue; + } } - } - return options; + return options; } diff --git a/packages/runner/src/list-assistant-sessions.ts b/packages/runner/src/list-assistant-sessions.ts index 638a855..00ccfba 100644 --- a/packages/runner/src/list-assistant-sessions.ts +++ b/packages/runner/src/list-assistant-sessions.ts @@ -1,38 +1,39 @@ import { createLocalRunnerOpencodeClient } from "./opencode-client"; + import type { AssistantSessionSummary } from "./local-runner-protocol"; export async function listAssistantSessions(args: { - directory: string; + directory: string; }): Promise { - const { client } = await createLocalRunnerOpencodeClient({ - directory: args.directory, - }); - const response = await client.session.list({ - query: { directory: args.directory }, - }); + const { client } = await createLocalRunnerOpencodeClient({ + directory: args.directory, + }); + const response = await client.session.list({ + query: { directory: args.directory }, + }); - if (!response.response.ok || !response.data) { - throw new Error(formatStatusError("Failed to list OpenCode sessions", response.response)); - } + if (!response.response.ok || !response.data) { + throw new Error(formatStatusError("Failed to list OpenCode sessions", response.response)); + } - return response.data - .slice() - .toSorted( - (left, right) => - right.time.updated - left.time.updated || right.time.created - left.time.created, - ) - .map((session) => ({ - createdAt: session.time.created, - directory: session.directory, - id: session.id, - title: session.title, - updatedAt: session.time.updated, - })); + return response.data + .slice() + .toSorted( + (left, right) => + right.time.updated - left.time.updated || right.time.created - left.time.created, + ) + .map((session) => ({ + createdAt: session.time.created, + directory: session.directory, + id: session.id, + title: session.title, + updatedAt: session.time.updated, + })); } function formatStatusError(prefix: string, response: Response): string { - const statusText = response.statusText.trim(); - const statusInfo = statusText.length > 0 ? `${response.status} ${statusText}` : response.status; + const statusText = response.statusText.trim(); + const statusInfo = statusText.length > 0 ? `${response.status} ${statusText}` : response.status; - return `${prefix} (${statusInfo})`; + return `${prefix} (${statusInfo})`; } diff --git a/packages/runner/src/local-runner-client.ts b/packages/runner/src/local-runner-client.ts index 0244c16..a1cf57b 100644 --- a/packages/runner/src/local-runner-client.ts +++ b/packages/runner/src/local-runner-client.ts @@ -1,115 +1,115 @@ import type { - CreateAssistantSessionRequest, - CreateAssistantSessionResponse, - DeleteWorkspaceRequest, - DeleteWorkspaceResponse, - EnsureAssistantSessionRequest, - EnsureAssistantSessionResponse, - GetAssistantSessionDiffRequest, - GetAssistantSessionDiffResponse, - ListAssistantSessionsRequest, - ListAssistantSessionsResponse, - ListOpencodeModelsRequest, - ListOpencodeModelsResponse, - LocalRunnerHealthResponse, - LocalRunnerInfoResponse, - PromptAssistantSessionRequest, - PromptAssistantSessionResponse, + CreateAssistantSessionRequest, + CreateAssistantSessionResponse, + DeleteWorkspaceRequest, + DeleteWorkspaceResponse, + EnsureAssistantSessionRequest, + EnsureAssistantSessionResponse, + GetAssistantSessionDiffRequest, + GetAssistantSessionDiffResponse, + ListAssistantSessionsRequest, + ListAssistantSessionsResponse, + ListOpencodeModelsRequest, + ListOpencodeModelsResponse, + LocalRunnerHealthResponse, + LocalRunnerInfoResponse, + PromptAssistantSessionRequest, + PromptAssistantSessionResponse, } from "./local-runner-protocol"; export function createLocalRunnerClient(baseUrl: string) { - const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + const normalizedBaseUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; - return { - async createAssistantSession( - body: CreateAssistantSessionRequest, - ): Promise { - return await postJson(`${normalizedBaseUrl}/assistant/session/create`, body); - }, - async deleteWorkspace(body: DeleteWorkspaceRequest): Promise { - return await postJson(`${normalizedBaseUrl}/workspace/delete`, body); - }, - async ensureAssistantSession( - body: EnsureAssistantSessionRequest, - ): Promise { - return await postJson(`${normalizedBaseUrl}/assistant/session/ensure`, body); - }, - async getAssistantSessionDiff( - params: GetAssistantSessionDiffRequest, - ): Promise { - const searchParams = new URLSearchParams({ - directory: params.directory, - sessionId: params.sessionId, - }); + return { + async createAssistantSession( + body: CreateAssistantSessionRequest, + ): Promise { + return await postJson(`${normalizedBaseUrl}/assistant/session/create`, body); + }, + async deleteWorkspace(body: DeleteWorkspaceRequest): Promise { + return await postJson(`${normalizedBaseUrl}/workspace/delete`, body); + }, + async ensureAssistantSession( + body: EnsureAssistantSessionRequest, + ): Promise { + return await postJson(`${normalizedBaseUrl}/assistant/session/ensure`, body); + }, + async getAssistantSessionDiff( + params: GetAssistantSessionDiffRequest, + ): Promise { + const searchParams = new URLSearchParams({ + directory: params.directory, + sessionId: params.sessionId, + }); - if (params.messageId) { - searchParams.set("messageId", params.messageId); - } + if (params.messageId) { + searchParams.set("messageId", params.messageId); + } - return await getJson( - `${normalizedBaseUrl}/assistant/session/diff?${searchParams.toString()}`, - ); - }, - async health(): Promise { - return await getJson(`${normalizedBaseUrl}/health`); - }, - async info(): Promise { - return await getJson(`${normalizedBaseUrl}/runner/info`); - }, - async listAssistantSessions( - params: ListAssistantSessionsRequest, - ): Promise { - return await getJson( - `${normalizedBaseUrl}/assistant/sessions?${new URLSearchParams({ - directory: params.directory, - }).toString()}`, - ); - }, - async listOpencodeModels( - params: ListOpencodeModelsRequest, - ): Promise { - return await getJson( - `${normalizedBaseUrl}/opencode/models?${new URLSearchParams({ - directory: params.directory, - }).toString()}`, - ); - }, - async promptAssistantSession( - body: PromptAssistantSessionRequest, - ): Promise { - return await postJson(`${normalizedBaseUrl}/assistant/session/prompt`, body); - }, - }; + return await getJson( + `${normalizedBaseUrl}/assistant/session/diff?${searchParams.toString()}`, + ); + }, + async health(): Promise { + return await getJson(`${normalizedBaseUrl}/health`); + }, + async info(): Promise { + return await getJson(`${normalizedBaseUrl}/runner/info`); + }, + async listAssistantSessions( + params: ListAssistantSessionsRequest, + ): Promise { + return await getJson( + `${normalizedBaseUrl}/assistant/sessions?${new URLSearchParams({ + directory: params.directory, + }).toString()}`, + ); + }, + async listOpencodeModels( + params: ListOpencodeModelsRequest, + ): Promise { + return await getJson( + `${normalizedBaseUrl}/opencode/models?${new URLSearchParams({ + directory: params.directory, + }).toString()}`, + ); + }, + async promptAssistantSession( + body: PromptAssistantSessionRequest, + ): Promise { + return await postJson(`${normalizedBaseUrl}/assistant/session/prompt`, body); + }, + }; } async function getJson(url: string): Promise { - const response = await fetch(url); - return await parseJsonResponse(response); + const response = await fetch(url); + return await parseJsonResponse(response); } async function postJson(url: string, body: unknown): Promise { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); - return await parseJsonResponse(response); + return await parseJsonResponse(response); } async function parseJsonResponse(response: Response): Promise { - const text = await response.text(); - const body = text.trim().length > 0 ? (JSON.parse(text) as T | { error: string }) : null; + const text = await response.text(); + const body = text.trim().length > 0 ? (JSON.parse(text) as T | { error: string }) : null; - if (!response.ok) { - const message = - body && typeof body === "object" && "error" in body && typeof body.error === "string" - ? body.error - : `${response.status} ${response.statusText}`.trim(); - throw new Error(message); - } + if (!response.ok) { + const message = + body && typeof body === "object" && "error" in body && typeof body.error === "string" + ? body.error + : `${response.status} ${response.statusText}`.trim(); + throw new Error(message); + } - return body as T; + return body as T; } diff --git a/packages/runner/src/local-runner-protocol.ts b/packages/runner/src/local-runner-protocol.ts index f3da1b8..ef2fa4b 100644 --- a/packages/runner/src/local-runner-protocol.ts +++ b/packages/runner/src/local-runner-protocol.ts @@ -3,113 +3,113 @@ import type { FileDiff, ProviderListResponse } from "@opencode-ai/sdk"; export const LOCAL_RUNNER_PROTOCOL_VERSION = "v1alpha1"; export type LocalRunnerHealthResponse = { - ok: true; + ok: true; }; export type LocalRunnerInfoResponse = { - capabilities: { - assistantSessions: true; - }; - protocolVersion: typeof LOCAL_RUNNER_PROTOCOL_VERSION; - runnerType: "local-worktree"; + capabilities: { + assistantSessions: true; + }; + protocolVersion: typeof LOCAL_RUNNER_PROTOCOL_VERSION; + runnerType: "local-worktree"; }; export type ListOpencodeModelsRequest = { - directory: string; + directory: string; }; export type LocalRunnerOpencodeProvider = ProviderListResponse["all"][number]; export type ListOpencodeModelsResponse = { - connected: ProviderListResponse["connected"]; - default: ProviderListResponse["default"]; - providers: Array; + connected: ProviderListResponse["connected"]; + default: ProviderListResponse["default"]; + providers: Array; }; export type CreateAssistantSessionRequest = { - model: string; - provider: string; - repoUrl: string; - taskTitle: string; + model: string; + provider: string; + repoUrl: string; + taskTitle: string; }; export type CreateAssistantSessionResponse = { - sessionId: string; - workspaceDirectory: string; + sessionId: string; + workspaceDirectory: string; }; export type EnsureAssistantSessionRequest = { - directory: string; - model?: string; - provider?: string; - sessionId?: string | null; - taskTitle: string; + directory: string; + model?: string; + provider?: string; + sessionId?: string | null; + taskTitle: string; }; export type EnsureAssistantSessionResponse = { - isNewSession: boolean; - sessionId: string; + isNewSession: boolean; + sessionId: string; }; export type AssistantSessionSummary = { - createdAt: number; - directory: string; - id: string; - title: string; - updatedAt: number; + createdAt: number; + directory: string; + id: string; + title: string; + updatedAt: number; }; export type ListAssistantSessionsRequest = { - directory: string; + directory: string; }; export type ListAssistantSessionsResponse = { - sessions: AssistantSessionSummary[]; + sessions: AssistantSessionSummary[]; }; export type GetAssistantSessionDiffRequest = { - directory: string; - messageId?: string; - sessionId: string; + directory: string; + messageId?: string; + sessionId: string; }; export type GetAssistantSessionDiffResponse = { - diffs: FileDiff[]; + diffs: FileDiff[]; }; export type PromptAssistantSessionRequest = { - directory: string; - model?: string; - provider?: string; - prompt: string; - sessionId: string; + directory: string; + model?: string; + provider?: string; + prompt: string; + sessionId: string; }; export type PromptAssistantSessionResponse = { - ok: true; + ok: true; }; export type PromptTaskAssistantSessionRequest = { - directory: string; - model?: string; - provider?: string; - prompt: string; - sessionId: string; - taskRun: { - backendBaseUrl: string; - callbackToken: string; - executionId: string; - }; + directory: string; + model?: string; + provider?: string; + prompt: string; + sessionId: string; + taskRun: { + backendBaseUrl: string; + callbackToken: string; + executionId: string; + }; }; export type PromptTaskAssistantSessionResponse = { - ok: true; + ok: true; }; export type DeleteWorkspaceRequest = { - workspaceDirectory: string; + workspaceDirectory: string; }; export type DeleteWorkspaceResponse = { - ok: true; + ok: true; }; diff --git a/packages/runner/src/local-runner-server.ts b/packages/runner/src/local-runner-server.ts index 63ddf5c..ae66b81 100644 --- a/packages/runner/src/local-runner-server.ts +++ b/packages/runner/src/local-runner-server.ts @@ -1,227 +1,228 @@ -import type { Server } from "node:http"; import { createAdaptorServer } from "@hono/node-server"; import { Hono, type Context } from "hono"; -import { getAssistantSessionDiff } from "./assistant-session-diff"; import { ensureAssistantSession, promptAssistantSession } from "./assistant-session"; +import { getAssistantSessionDiff } from "./assistant-session-diff"; import { listAssistantSessions } from "./list-assistant-sessions"; import { - LOCAL_RUNNER_PROTOCOL_VERSION, - type CreateAssistantSessionRequest, - type DeleteWorkspaceRequest, - type EnsureAssistantSessionRequest, - type GetAssistantSessionDiffRequest, - type ListAssistantSessionsRequest, - type ListOpencodeModelsRequest, - type PromptAssistantSessionRequest, - type PromptTaskAssistantSessionRequest, + LOCAL_RUNNER_PROTOCOL_VERSION, + type CreateAssistantSessionRequest, + type DeleteWorkspaceRequest, + type EnsureAssistantSessionRequest, + type GetAssistantSessionDiffRequest, + type ListAssistantSessionsRequest, + type ListOpencodeModelsRequest, + type PromptAssistantSessionRequest, + type PromptTaskAssistantSessionRequest, } from "./local-runner-protocol"; import { listOpencodeModels } from "./opencode-models"; import { promptTaskAssistantSession } from "./task-assistant-session"; import { createWorkspace, deleteWorkspace } from "./workspace"; +import type { Server } from "node:http"; + export type LocalRunnerServerOptions = { - host?: string; - port?: number; + host?: string; + port?: number; }; export function createLocalRunnerApp(): Hono { - const app = new Hono(); + const app = new Hono(); + + app.use("*", async (c, next) => { + try { + await next(); + } finally { + setCorsHeaders(c); + } + }); - app.use("*", async (c, next) => { - try { - await next(); - } finally { - setCorsHeaders(c); - } - }); + app.options("*", (c) => c.body(null, 204)); + + app.onError((error, c) => { + return c.json( + { + error: error instanceof Error ? error.message : String(error), + }, + error instanceof RequestError ? error.statusCode : 500, + ); + }); - app.options("*", (c) => c.body(null, 204)); + app.get("/health", (c) => c.json({ ok: true })); - app.onError((error, c) => { - return c.json( - { - error: error instanceof Error ? error.message : String(error), - }, - error instanceof RequestError ? error.statusCode : 500, - ); - }); - - app.get("/health", (c) => c.json({ ok: true })); - - app.get("/runner/info", (c) => - c.json({ - capabilities: { - assistantSessions: true, - }, - protocolVersion: LOCAL_RUNNER_PROTOCOL_VERSION, - runnerType: "local-worktree", - }), - ); - - app.get("/opencode/models", async (c) => { - const directory = readDirectoryQuery(c); - - return c.json( - await listOpencodeModels({ - directory, - } satisfies ListOpencodeModelsRequest), + app.get("/runner/info", (c) => + c.json({ + capabilities: { + assistantSessions: true, + }, + protocolVersion: LOCAL_RUNNER_PROTOCOL_VERSION, + runnerType: "local-worktree", + }), ); - }); - app.get("/assistant/sessions", async (c) => { - const directory = readDirectoryQuery(c); + app.get("/opencode/models", async (c) => { + const directory = readDirectoryQuery(c); - return c.json({ - sessions: await listAssistantSessions({ - directory, - } satisfies ListAssistantSessionsRequest), + return c.json( + await listOpencodeModels({ + directory, + } satisfies ListOpencodeModelsRequest), + ); }); - }); - - app.get("/assistant/session/diff", async (c) => { - const directory = readDirectoryQuery(c); - const sessionId = readRequiredQuery(c, "sessionId"); - const messageId = readOptionalQuery(c, "messageId"); - - return c.json({ - diffs: await getAssistantSessionDiff({ - directory, - messageId, - sessionId, - } satisfies GetAssistantSessionDiffRequest), + + app.get("/assistant/sessions", async (c) => { + const directory = readDirectoryQuery(c); + + return c.json({ + sessions: await listAssistantSessions({ + directory, + } satisfies ListAssistantSessionsRequest), + }); }); - }); - app.post("/assistant/session/create", async (c) => { - const body = await readJson(c); - const workspaceDirectory = createWorkspace({ - repoUrl: body.repoUrl, - title: body.taskTitle, + app.get("/assistant/session/diff", async (c) => { + const directory = readDirectoryQuery(c); + const sessionId = readRequiredQuery(c, "sessionId"); + const messageId = readOptionalQuery(c, "messageId"); + + return c.json({ + diffs: await getAssistantSessionDiff({ + directory, + messageId, + sessionId, + } satisfies GetAssistantSessionDiffRequest), + }); }); - try { - const session = await ensureAssistantSession({ - directory: workspaceDirectory, - existingSessionId: null, - model: body.model, - provider: body.provider, - taskTitle: body.taskTitle, - }); - - return c.json({ - sessionId: session.sessionId, - workspaceDirectory, - }); - } catch (error) { - deleteWorkspace(workspaceDirectory); - throw error; - } - }); - - app.post("/assistant/session/ensure", async (c) => { - const body = await readJson(c); - - return c.json( - await ensureAssistantSession({ - directory: body.directory, - existingSessionId: body.sessionId, - model: body.model, - provider: body.provider, - taskTitle: body.taskTitle, - }), - ); - }); + app.post("/assistant/session/create", async (c) => { + const body = await readJson(c); + const workspaceDirectory = createWorkspace({ + repoUrl: body.repoUrl, + title: body.taskTitle, + }); + + try { + const session = await ensureAssistantSession({ + directory: workspaceDirectory, + existingSessionId: null, + model: body.model, + provider: body.provider, + taskTitle: body.taskTitle, + }); + + return c.json({ + sessionId: session.sessionId, + workspaceDirectory, + }); + } catch (error) { + deleteWorkspace(workspaceDirectory); + throw error; + } + }); - app.post("/workspace/delete", async (c) => { - const body = await readJson(c); + app.post("/assistant/session/ensure", async (c) => { + const body = await readJson(c); + + return c.json( + await ensureAssistantSession({ + directory: body.directory, + existingSessionId: body.sessionId, + model: body.model, + provider: body.provider, + taskTitle: body.taskTitle, + }), + ); + }); - deleteWorkspace(body.workspaceDirectory); + app.post("/workspace/delete", async (c) => { + const body = await readJson(c); - return c.json({ ok: true }); - }); + deleteWorkspace(body.workspaceDirectory); - app.post("/assistant/session/prompt", async (c) => { - const body = await readJson(c); + return c.json({ ok: true }); + }); + + app.post("/assistant/session/prompt", async (c) => { + const body = await readJson(c); - await promptAssistantSession(body); + await promptAssistantSession(body); - return c.json({ ok: true }); - }); + return c.json({ ok: true }); + }); - app.post("/assistant/session/task-prompt", async (c) => { - const body = await readJson(c); + app.post("/assistant/session/task-prompt", async (c) => { + const body = await readJson(c); - await promptTaskAssistantSession(body); + await promptTaskAssistantSession(body); - return c.json({ ok: true }); - }); + return c.json({ ok: true }); + }); - app.notFound((c) => c.json({ error: `Unknown route: ${c.req.method} ${c.req.path}` }, 404)); + app.notFound((c) => c.json({ error: `Unknown route: ${c.req.method} ${c.req.path}` }, 404)); - return app; + return app; } class RequestError extends Error { - constructor( - message: string, - readonly statusCode: 400 | 404 = 400, - ) { - super(message); - } + constructor( + message: string, + readonly statusCode: 400 | 404 = 400, + ) { + super(message); + } } export function startLocalRunnerServer(options?: LocalRunnerServerOptions): Promise { - const host = options?.host ?? "127.0.0.1"; - const port = options?.port ?? 4318; - const app = createLocalRunnerApp(); - const server = createAdaptorServer({ - fetch: app.fetch, - hostname: host, - port, - }) as Server; - - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, host, () => { - server.off("error", reject); - resolve(server); + const host = options?.host ?? "127.0.0.1"; + const port = options?.port ?? 4318; + const app = createLocalRunnerApp(); + const server = createAdaptorServer({ + fetch: app.fetch, + hostname: host, + port, + }) as Server; + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, host, () => { + server.off("error", reject); + resolve(server); + }); }); - }); } async function readJson(c: Context): Promise { - const body = (await c.req.text()).trim(); - if (body.length === 0) { - throw new RequestError("Expected JSON request body"); - } - - try { - return JSON.parse(body) as T; - } catch { - throw new RequestError("Invalid JSON request body"); - } + const body = (await c.req.text()).trim(); + if (body.length === 0) { + throw new RequestError("Expected JSON request body"); + } + + try { + return JSON.parse(body) as T; + } catch { + throw new RequestError("Invalid JSON request body"); + } } function setCorsHeaders(c: Context): void { - c.header("Access-Control-Allow-Headers", "Content-Type"); - c.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - c.header("Access-Control-Allow-Origin", "*"); + c.header("Access-Control-Allow-Headers", "Content-Type"); + c.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + c.header("Access-Control-Allow-Origin", "*"); } function readDirectoryQuery(c: Context): string { - return readRequiredQuery(c, "directory"); + return readRequiredQuery(c, "directory"); } function readOptionalQuery(c: Context, key: string): string | undefined { - const value = c.req.query(key)?.trim(); - return value && value.length > 0 ? value : undefined; + const value = c.req.query(key)?.trim(); + return value && value.length > 0 ? value : undefined; } function readRequiredQuery(c: Context, key: string): string { - const value = readOptionalQuery(c, key) ?? ""; - if (value.length === 0) { - throw new RequestError(`${key} query parameter is required`); - } + const value = readOptionalQuery(c, key) ?? ""; + if (value.length === 0) { + throw new RequestError(`${key} query parameter is required`); + } - return value; + return value; } diff --git a/packages/runner/src/opencode-client.ts b/packages/runner/src/opencode-client.ts index 0206c75..c244a0c 100644 --- a/packages/runner/src/opencode-client.ts +++ b/packages/runner/src/opencode-client.ts @@ -2,16 +2,16 @@ import { createOpencodeClient, type Config } from "@opencode-ai/sdk"; import { ensureLocalOpencodeServer } from "./opencode-server"; export async function createLocalRunnerOpencodeClient(args: { - directory: string; - config?: Config; + directory: string; + config?: Config; }) { - const server = await ensureLocalOpencodeServer(args.config); + const server = await ensureLocalOpencodeServer(args.config); - return { - client: createOpencodeClient({ - baseUrl: server.baseUrl, - directory: args.directory, - }), - server, - }; + return { + client: createOpencodeClient({ + baseUrl: server.baseUrl, + directory: args.directory, + }), + server, + }; } diff --git a/packages/runner/src/opencode-models.ts b/packages/runner/src/opencode-models.ts index f1a82b2..75a1f40 100644 --- a/packages/runner/src/opencode-models.ts +++ b/packages/runner/src/opencode-models.ts @@ -1,39 +1,40 @@ import { createLocalRunnerOpencodeClient } from "./opencode-client"; + import type { ListOpencodeModelsResponse } from "./local-runner-protocol"; export async function listOpencodeModels(args: { - directory: string; + directory: string; }): Promise { - const directory = args.directory.trim(); - if (directory.length === 0) { - throw new Error("directory is required"); - } + const directory = args.directory.trim(); + if (directory.length === 0) { + throw new Error("directory is required"); + } - const { client } = await createLocalRunnerOpencodeClient({ directory }); - const providerListResponse = await client.provider.list({ - query: { directory }, - }); + const { client } = await createLocalRunnerOpencodeClient({ directory }); + const providerListResponse = await client.provider.list({ + query: { directory }, + }); - if (!providerListResponse.response.ok) { - throw new Error(formatStatusError("Failed to list OpenCode models", providerListResponse)); - } + if (!providerListResponse.response.ok) { + throw new Error(formatStatusError("Failed to list OpenCode models", providerListResponse)); + } - return { - connected: providerListResponse.data?.connected ?? [], - default: providerListResponse.data?.default ?? {}, - providers: providerListResponse.data?.all ?? [], - }; + return { + connected: providerListResponse.data?.connected ?? [], + default: providerListResponse.data?.default ?? {}, + providers: providerListResponse.data?.all ?? [], + }; } function formatStatusError( - prefix: string, - response: { response: Response; data?: unknown | null }, + prefix: string, + response: { response: Response; data?: unknown | null }, ): string { - const statusText = response.response.statusText.trim(); - const statusInfo = - statusText.length > 0 - ? `${response.response.status} ${statusText}` - : String(response.response.status); + const statusText = response.response.statusText.trim(); + const statusInfo = + statusText.length > 0 + ? `${response.response.status} ${statusText}` + : String(response.response.status); - return `${prefix} (${statusInfo})`; + return `${prefix} (${statusInfo})`; } diff --git a/packages/runner/src/opencode-server.ts b/packages/runner/src/opencode-server.ts index 4fddd55..a33babf 100644 --- a/packages/runner/src/opencode-server.ts +++ b/packages/runner/src/opencode-server.ts @@ -1,6 +1,7 @@ -import net from "node:net"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import net from "node:net"; import { setTimeout as sleep } from "node:timers/promises"; + import type { Config } from "@opencode-ai/sdk"; const DEFAULT_HOST = "127.0.0.1"; @@ -8,191 +9,191 @@ const READY_RETRY_COUNT = 30; const READY_RETRY_DELAY_MS = 500; type OpencodeServerState = { - child: ChildProcessWithoutNullStreams | null; - config: Config | undefined; - host: string; - port: number | null; - startPromise: Promise | null; + child: ChildProcessWithoutNullStreams | null; + config: Config | undefined; + host: string; + port: number | null; + startPromise: Promise | null; }; const serverState: OpencodeServerState = { - child: null, - config: undefined, - host: DEFAULT_HOST, - port: null, - startPromise: null, + child: null, + config: undefined, + host: DEFAULT_HOST, + port: null, + startPromise: null, }; export type LocalOpencodeServer = { - baseUrl: string; - host: string; - pid: number | null; - port: number; + baseUrl: string; + host: string; + pid: number | null; + port: number; }; export async function ensureLocalOpencodeServer(config?: Config): Promise { - if (config !== undefined && serverState.config === undefined) { - serverState.config = config; - } - - if ( - serverState.child && - serverState.port !== null && - (await isOpencodeServerReady(serverState.host, serverState.port)) - ) { - return toLocalOpencodeServer(); - } - - if (serverState.startPromise) { - return await serverState.startPromise; - } - - const startPromise = startLocalOpencodeServer(); - serverState.startPromise = startPromise; - - try { - return await startPromise; - } finally { - if (serverState.startPromise === startPromise) { - serverState.startPromise = null; + if (config !== undefined && serverState.config === undefined) { + serverState.config = config; } - } -} -async function isOpencodeServerReady(host: string, port: number): Promise { - try { - const response = await fetch(`http://${host}:${port}/session/status`); - if (!response.ok) { - return false; + if ( + serverState.child && + serverState.port !== null && + (await isOpencodeServerReady(serverState.host, serverState.port)) + ) { + return toLocalOpencodeServer(); } - const body = (await response.text()).trim(); - if (body.length === 0) { - return false; + if (serverState.startPromise) { + return await serverState.startPromise; } - const parsed = JSON.parse(body) as unknown; - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed); - } catch { - return false; - } -} - -async function startLocalOpencodeServer(): Promise { - await stopOpencodeServerChild(); - - const port = await reserveLocalPort(); - const child = spawn( - "opencode", - ["serve", "--hostname", serverState.host, "--port", String(port)], - { - env: { - ...process.env, - OPENCODE_CONFIG_CONTENT: JSON.stringify(serverState.config ?? {}), - }, - stdio: "pipe", - }, - ); - - serverState.child = child; - serverState.port = port; - - child.stderr.on("data", (chunk) => { - process.stderr.write(chunk); - }); - - child.stdout.on("data", (chunk) => { - process.stdout.write(chunk); - }); - - child.once("exit", () => { - if (serverState.child === child) { - serverState.child = null; - serverState.port = null; - } - }); + const startPromise = startLocalOpencodeServer(); + serverState.startPromise = startPromise; - for (let attempt = 0; attempt < READY_RETRY_COUNT; attempt++) { - if (await isOpencodeServerReady(serverState.host, port)) { - return toLocalOpencodeServer(); + try { + return await startPromise; + } finally { + if (serverState.startPromise === startPromise) { + serverState.startPromise = null; + } } +} - if (child.exitCode !== null) { - throw new Error(`Local OpenCode server exited with code ${child.exitCode}`); - } +async function isOpencodeServerReady(host: string, port: number): Promise { + try { + const response = await fetch(`http://${host}:${port}/session/status`); + if (!response.ok) { + return false; + } - await sleep(READY_RETRY_DELAY_MS); - } + const body = (await response.text()).trim(); + if (body.length === 0) { + return false; + } - await stopOpencodeServerChild(); - throw new Error("Timed out waiting for the local OpenCode server to become ready"); + const parsed = JSON.parse(body) as unknown; + return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed); + } catch { + return false; + } } -async function stopOpencodeServerChild(): Promise { - const child = serverState.child; - if (!child) { - serverState.port = null; - return; - } - - if (child.exitCode !== null) { - serverState.child = null; - serverState.port = null; - return; - } +async function startLocalOpencodeServer(): Promise { + await stopOpencodeServerChild(); + + const port = await reserveLocalPort(); + const child = spawn( + "opencode", + ["serve", "--hostname", serverState.host, "--port", String(port)], + { + env: { + ...process.env, + OPENCODE_CONFIG_CONTENT: JSON.stringify(serverState.config ?? {}), + }, + stdio: "pipe", + }, + ); + + serverState.child = child; + serverState.port = port; + + child.stderr.on("data", (chunk) => { + process.stderr.write(chunk); + }); - await new Promise((resolve) => { - const timeoutId = setTimeout(() => { - child.kill("SIGKILL"); - resolve(); - }, 1_000); + child.stdout.on("data", (chunk) => { + process.stdout.write(chunk); + }); child.once("exit", () => { - clearTimeout(timeoutId); - resolve(); + if (serverState.child === child) { + serverState.child = null; + serverState.port = null; + } }); - child.kill("SIGTERM"); - }); + for (let attempt = 0; attempt < READY_RETRY_COUNT; attempt++) { + if (await isOpencodeServerReady(serverState.host, port)) { + return toLocalOpencodeServer(); + } + + if (child.exitCode !== null) { + throw new Error(`Local OpenCode server exited with code ${child.exitCode}`); + } + + await sleep(READY_RETRY_DELAY_MS); + } - serverState.child = null; - serverState.port = null; + await stopOpencodeServerChild(); + throw new Error("Timed out waiting for the local OpenCode server to become ready"); } -async function reserveLocalPort(): Promise { - return await new Promise((resolve, reject) => { - const server = net.createServer(); +async function stopOpencodeServerChild(): Promise { + const child = serverState.child; + if (!child) { + serverState.port = null; + return; + } - server.once("error", reject); - server.listen(0, serverState.host, () => { - const address = server.address(); + if (child.exitCode !== null) { + serverState.child = null; + serverState.port = null; + return; + } - server.close((error) => { - if (error) { - reject(error); - return; - } + await new Promise((resolve) => { + const timeoutId = setTimeout(() => { + child.kill("SIGKILL"); + resolve(); + }, 1_000); - if (!address || typeof address === "string") { - reject(new Error("Failed to reserve a local port for OpenCode")); - return; - } + child.once("exit", () => { + clearTimeout(timeoutId); + resolve(); + }); - resolve(address.port); - }); + child.kill("SIGTERM"); + }); + + serverState.child = null; + serverState.port = null; +} + +async function reserveLocalPort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + + server.once("error", reject); + server.listen(0, serverState.host, () => { + const address = server.address(); + + server.close((error) => { + if (error) { + reject(error); + return; + } + + if (!address || typeof address === "string") { + reject(new Error("Failed to reserve a local port for OpenCode")); + return; + } + + resolve(address.port); + }); + }); }); - }); } function toLocalOpencodeServer(): LocalOpencodeServer { - if (serverState.port === null) { - throw new Error("Local OpenCode server port is not available"); - } - - return { - baseUrl: `http://${serverState.host}:${serverState.port}`, - host: serverState.host, - pid: serverState.child?.pid ?? null, - port: serverState.port, - }; + if (serverState.port === null) { + throw new Error("Local OpenCode server port is not available"); + } + + return { + baseUrl: `http://${serverState.host}:${serverState.port}`, + host: serverState.host, + pid: serverState.child?.pid ?? null, + port: serverState.port, + }; } diff --git a/packages/runner/src/opencode.ts b/packages/runner/src/opencode.ts index 12e9410..1df2f65 100644 --- a/packages/runner/src/opencode.ts +++ b/packages/runner/src/opencode.ts @@ -6,9 +6,9 @@ export const SUPPORTED_OPENCODE_PROVIDERS = [DEFAULT_OPENCODE_PROVIDER] as const export type SupportedOpencodeProvider = (typeof SUPPORTED_OPENCODE_PROVIDERS)[number]; export function isSupportedOpencodeProvider(value: string): value is SupportedOpencodeProvider { - return SUPPORTED_OPENCODE_PROVIDERS.includes(value as SupportedOpencodeProvider); + return SUPPORTED_OPENCODE_PROVIDERS.includes(value as SupportedOpencodeProvider); } export function toProviderModelRef(provider: string, model: string): string { - return `${provider}/${model}`; + return `${provider}/${model}`; } diff --git a/packages/runner/src/task-assistant-session.ts b/packages/runner/src/task-assistant-session.ts index 4046870..b5e6943 100644 --- a/packages/runner/src/task-assistant-session.ts +++ b/packages/runner/src/task-assistant-session.ts @@ -1,312 +1,315 @@ -import type { Event as OpenCodeEvent, OpencodeClient, TextPart } from "@opencode-ai/sdk"; -import { createLocalRunnerOpencodeClient } from "./opencode-client"; import { promptAssistantSession } from "./assistant-session"; +import { createLocalRunnerOpencodeClient } from "./opencode-client"; + +import type { Event as OpenCodeEvent, OpencodeClient, TextPart } from "@opencode-ai/sdk"; const DEFAULT_FIRST_TASK_INSTRUCTION = - "Before doing any work, create and switch to a dedicated git branch for this task if you are not already on one. If the workspace is already on a dedicated task branch, keep using it. Do all work for this task on that branch."; + "Before doing any work, create and switch to a dedicated git branch for this task if you are not already on one. If the workspace is already on a dedicated task branch, keep using it. Do all work for this task on that branch."; type TaskRunCallbackInfo = { - backendBaseUrl: string; - callbackToken: string; - executionId: string; + backendBaseUrl: string; + callbackToken: string; + executionId: string; }; export async function promptTaskAssistantSession(args: { - directory: string; - model?: string; - provider?: string; - prompt: string; - sessionId: string; - taskRun: TaskRunCallbackInfo; + directory: string; + model?: string; + provider?: string; + prompt: string; + sessionId: string; + taskRun: TaskRunCallbackInfo; }): Promise { - const { client } = await createLocalRunnerOpencodeClient({ - directory: args.directory, - }); - const abortController = new AbortController(); - const eventStream = await client.event.subscribe({ - query: { directory: args.directory }, - signal: abortController.signal, - sseMaxRetryAttempts: 0, - }); - - try { - const idlePromise = relayTaskRunEvents({ - directory: args.directory, - client, - sessionId: args.sessionId, - stream: eventStream.stream, - taskRun: args.taskRun, + const { client } = await createLocalRunnerOpencodeClient({ + directory: args.directory, }); - const prompt = await buildTaskPrompt({ - client, - directory: args.directory, - prompt: args.prompt, - sessionId: args.sessionId, + const abortController = new AbortController(); + const eventStream = await client.event.subscribe({ + query: { directory: args.directory }, + signal: abortController.signal, + sseMaxRetryAttempts: 0, }); - await promptAssistantSession({ - directory: args.directory, - model: args.model, - prompt, - provider: args.provider, - sessionId: args.sessionId, - }); + try { + const idlePromise = relayTaskRunEvents({ + directory: args.directory, + client, + sessionId: args.sessionId, + stream: eventStream.stream, + taskRun: args.taskRun, + }); + const prompt = await buildTaskPrompt({ + client, + directory: args.directory, + prompt: args.prompt, + sessionId: args.sessionId, + }); - await idlePromise; + await promptAssistantSession({ + directory: args.directory, + model: args.model, + prompt, + provider: args.provider, + sessionId: args.sessionId, + }); - const assistantOutput = await readLatestAssistantOutput({ - client, - directory: args.directory, - sessionId: args.sessionId, - }); - const branch = await readCurrentBranch({ - client, - directory: args.directory, - }); + await idlePromise; - await postTaskRunCallback({ - body: { - assistantOutput: assistantOutput.length > 0 ? assistantOutput : undefined, - }, - taskRun: args.taskRun, - type: "complete", - }); + const assistantOutput = await readLatestAssistantOutput({ + client, + directory: args.directory, + sessionId: args.sessionId, + }); + const branch = await readCurrentBranch({ + client, + directory: args.directory, + }); - await postTaskRunCallback({ - body: { branch }, - taskRun: args.taskRun, - type: "branch", - }); - } catch (error) { - await postTaskRunCallback({ - body: { - error: error instanceof Error ? error.message : String(error), - }, - taskRun: args.taskRun, - type: "fail", - }).catch(() => undefined); - - throw error; - } finally { - abortController.abort(); - } + await postTaskRunCallback({ + body: { + assistantOutput: assistantOutput.length > 0 ? assistantOutput : undefined, + }, + taskRun: args.taskRun, + type: "complete", + }); + + await postTaskRunCallback({ + body: { branch }, + taskRun: args.taskRun, + type: "branch", + }); + } catch (error) { + await postTaskRunCallback({ + body: { + error: error instanceof Error ? error.message : String(error), + }, + taskRun: args.taskRun, + type: "fail", + }).catch(() => undefined); + + throw error; + } finally { + abortController.abort(); + } } async function buildTaskPrompt(args: { - client: OpencodeClient; - directory: string; - prompt: string; - sessionId: string; + client: OpencodeClient; + directory: string; + prompt: string; + sessionId: string; }): Promise { - const hasConversation = await sessionHasConversation(args); + const hasConversation = await sessionHasConversation(args); - if (hasConversation) { - return args.prompt; - } + if (hasConversation) { + return args.prompt; + } - return `${DEFAULT_FIRST_TASK_INSTRUCTION}\n\nTask:\n${args.prompt}`; + return `${DEFAULT_FIRST_TASK_INSTRUCTION}\n\nTask:\n${args.prompt}`; } async function sessionHasConversation(args: { - client: OpencodeClient; - directory: string; - sessionId: string; + client: OpencodeClient; + directory: string; + sessionId: string; }): Promise { - const response = await args.client.session.messages({ - path: { id: args.sessionId }, - query: { - directory: args.directory, - limit: 20, - }, - }); - - if (!response.response.ok || !response.data) { - return false; - } - - return response.data.some((message) => { - const role = message.info.role; - return role === "user" || role === "assistant"; - }); -} + const response = await args.client.session.messages({ + path: { id: args.sessionId }, + query: { + directory: args.directory, + limit: 20, + }, + }); -async function relayTaskRunEvents(args: { - directory: string; - client: OpencodeClient; - sessionId: string; - stream: AsyncGenerator; - taskRun: TaskRunCallbackInfo; -}): Promise { - let reportedBranch = await readCurrentBranch({ - client: args.client, - directory: args.directory, - }); - - for await (const event of args.stream) { - if (!shouldRelayTaskEvent(event, args.sessionId, args.directory)) { - continue; + if (!response.response.ok || !response.data) { + return false; } - await postTaskRunCallback({ - body: { event }, - taskRun: args.taskRun, - type: "event", + return response.data.some((message) => { + const role = message.info.role; + return role === "user" || role === "assistant"; }); +} - if (event.type === "vcs.branch.updated") { - const branch = await readCurrentBranch({ +async function relayTaskRunEvents(args: { + directory: string; + client: OpencodeClient; + sessionId: string; + stream: AsyncGenerator; + taskRun: TaskRunCallbackInfo; +}): Promise { + let reportedBranch = await readCurrentBranch({ client: args.client, directory: args.directory, - }); + }); - if (branch !== reportedBranch) { - reportedBranch = branch; + for await (const event of args.stream) { + if (!shouldRelayTaskEvent(event, args.sessionId, args.directory)) { + continue; + } await postTaskRunCallback({ - body: { branch }, - taskRun: args.taskRun, - type: "branch", + body: { event }, + taskRun: args.taskRun, + type: "event", }); - } - } - - if (event.type === "session.error" && event.properties.sessionID === args.sessionId) { - throw new Error(getSessionErrorMessage(event)); - } - if (event.type === "session.idle" && event.properties.sessionID === args.sessionId) { - return; + if (event.type === "vcs.branch.updated") { + const branch = await readCurrentBranch({ + client: args.client, + directory: args.directory, + }); + + if (branch !== reportedBranch) { + reportedBranch = branch; + + await postTaskRunCallback({ + body: { branch }, + taskRun: args.taskRun, + type: "branch", + }); + } + } + + if (event.type === "session.error" && event.properties.sessionID === args.sessionId) { + throw new Error(getSessionErrorMessage(event)); + } + + if (event.type === "session.idle" && event.properties.sessionID === args.sessionId) { + return; + } } - } - throw new Error("OpenCode event stream ended before the session became idle"); + throw new Error("OpenCode event stream ended before the session became idle"); } async function readLatestAssistantOutput(args: { - client: OpencodeClient; - directory: string; - sessionId: string; + client: OpencodeClient; + directory: string; + sessionId: string; }): Promise { - const response = await args.client.session.messages({ - path: { id: args.sessionId }, - query: { - directory: args.directory, - limit: 100, - }, - }); - - if (!response.response.ok || !response.data) { - return ""; - } - - const assistantMessage = [...response.data] - .toReversed() - .find((message) => message.info.role === "assistant"); - - if (!assistantMessage) { - return ""; - } - - return assistantMessage.parts - .filter((part): part is TextPart => part.type === "text" && !part.ignored) - .map((part) => part.text) - .join("") - .trim(); + const response = await args.client.session.messages({ + path: { id: args.sessionId }, + query: { + directory: args.directory, + limit: 100, + }, + }); + + if (!response.response.ok || !response.data) { + return ""; + } + + const assistantMessage = [...response.data] + .toReversed() + .find((message) => message.info.role === "assistant"); + + if (!assistantMessage) { + return ""; + } + + return assistantMessage.parts + .filter((part): part is TextPart => part.type === "text" && !part.ignored) + .map((part) => part.text) + .join("") + .trim(); } async function readCurrentBranch(args: { - client: OpencodeClient; - directory: string; + client: OpencodeClient; + directory: string; }): Promise { - const response = await args.client.vcs.get({ - query: { directory: args.directory }, - }); + const response = await args.client.vcs.get({ + query: { directory: args.directory }, + }); - if (!response.response.ok || !response.data?.branch) { - return null; - } + if (!response.response.ok || !response.data?.branch) { + return null; + } - const branch = response.data.branch.trim(); - return branch.length > 0 ? branch : null; + const branch = response.data.branch.trim(); + return branch.length > 0 ? branch : null; } function shouldRelayTaskEvent(event: OpenCodeEvent, sessionId: string, directory: string): boolean { - switch (event.type) { - case "command.executed": - case "permission.replied": - case "session.idle": - case "session.status": - case "todo.updated": - return event.properties.sessionID === sessionId; - case "message.part.removed": - return event.properties.sessionID === sessionId; - case "message.part.updated": - return event.properties.part.sessionID === sessionId; - case "message.updated": - return event.properties.info.sessionID === sessionId; - case "permission.updated": - return event.properties.sessionID === sessionId; - case "session.compacted": - return event.properties.sessionID === sessionId; - case "session.error": - return event.properties.sessionID === sessionId; - case "session.updated": - return event.properties.info.id === sessionId; - case "vcs.branch.updated": - return isVcsBranchEventForCurrentDirectory(event, directory); - default: - return false; - } + switch (event.type) { + case "command.executed": + case "permission.replied": + case "session.idle": + case "session.status": + case "todo.updated": + return event.properties.sessionID === sessionId; + case "message.part.removed": + return event.properties.sessionID === sessionId; + case "message.part.updated": + return event.properties.part.sessionID === sessionId; + case "message.updated": + return event.properties.info.sessionID === sessionId; + case "permission.updated": + return event.properties.sessionID === sessionId; + case "session.compacted": + return event.properties.sessionID === sessionId; + case "session.error": + return event.properties.sessionID === sessionId; + case "session.updated": + return event.properties.info.id === sessionId; + case "vcs.branch.updated": + return isVcsBranchEventForCurrentDirectory(event, directory); + default: + return false; + } } function isVcsBranchEventForCurrentDirectory( - event: Extract, - directory: string, + event: Extract, + directory: string, ): boolean { - const properties = event.properties as Record; - const root = properties.root; - const cwd = properties.cwd; - - return ( - root === directory || cwd === directory || (typeof root !== "string" && typeof cwd !== "string") - ); + const properties = event.properties as Record; + const root = properties.root; + const cwd = properties.cwd; + + return ( + root === directory || + cwd === directory || + (typeof root !== "string" && typeof cwd !== "string") + ); } function getSessionErrorMessage(event: Extract): string { - const explicitMessage = event.properties.error?.data?.message; - if (typeof explicitMessage === "string" && explicitMessage.trim().length > 0) { - return explicitMessage; - } + const explicitMessage = event.properties.error?.data?.message; + if (typeof explicitMessage === "string" && explicitMessage.trim().length > 0) { + return explicitMessage; + } - return "Session failed"; + return "Session failed"; } async function postTaskRunCallback(args: { - body: unknown; - taskRun: TaskRunCallbackInfo; - type: "branch" | "complete" | "event" | "fail"; + body: unknown; + taskRun: TaskRunCallbackInfo; + type: "branch" | "complete" | "event" | "fail"; }): Promise { - const baseUrl = args.taskRun.backendBaseUrl.endsWith("/") - ? args.taskRun.backendBaseUrl.slice(0, -1) - : args.taskRun.backendBaseUrl; - const response = await fetch( - `${baseUrl}/api/internal/task-runs/${args.taskRun.executionId}/${args.type}`, - { - method: "POST", - headers: { - Authorization: `Bearer ${args.taskRun.callbackToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(args.body), - }, - ); - - if (!response.ok) { - const text = (await response.text()).trim(); - throw new Error( - `Task run callback ${args.type} failed (${response.status} ${response.statusText})${ - text.length > 0 ? `: ${text}` : "" - }`, + const baseUrl = args.taskRun.backendBaseUrl.endsWith("/") + ? args.taskRun.backendBaseUrl.slice(0, -1) + : args.taskRun.backendBaseUrl; + const response = await fetch( + `${baseUrl}/api/internal/task-runs/${args.taskRun.executionId}/${args.type}`, + { + method: "POST", + headers: { + Authorization: `Bearer ${args.taskRun.callbackToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(args.body), + }, ); - } + + if (!response.ok) { + const text = (await response.text()).trim(); + throw new Error( + `Task run callback ${args.type} failed (${response.status} ${response.statusText})${ + text.length > 0 ? `: ${text}` : "" + }`, + ); + } } diff --git a/packages/runner/src/workspace.ts b/packages/runner/src/workspace.ts index 5d89203..524470c 100644 --- a/packages/runner/src/workspace.ts +++ b/packages/runner/src/workspace.ts @@ -1,271 +1,273 @@ +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; type CreateWorkspaceArgs = { - repoUrl: string; - title: string; + repoUrl: string; + title: string; }; type PreparedWorkspace = { - defaultDirectory: string; - directory: string; + defaultDirectory: string; + directory: string; }; type RepoWorkspacePaths = { - defaultDirectory: string; - repoRoot: string; + defaultDirectory: string; + repoRoot: string; }; export function createWorkspace({ repoUrl, title }: CreateWorkspaceArgs): string { - const trimmedTitle = title.trim(); - if (trimmedTitle.length === 0) { - throw new Error("title is required"); - } + const trimmedTitle = title.trim(); + if (trimmedTitle.length === 0) { + throw new Error("title is required"); + } - return prepareSessionWorktree(repoUrl, trimmedTitle).directory; + return prepareSessionWorktree(repoUrl, trimmedTitle).directory; } export function deleteWorkspace(workspaceDirectory: string): void { - const managedWorkspaceDirectory = resolveManagedWorkspaceDirectory(workspaceDirectory); - const defaultDirectory = resolveRepoDefaultDirectory(managedWorkspaceDirectory); - - if (!fs.existsSync(managedWorkspaceDirectory)) { - return; - } - - if (!fs.existsSync(defaultDirectory)) { - fs.rmSync(managedWorkspaceDirectory, { force: true, recursive: true }); - return; - } - - runCommand( - "git", - ["-C", defaultDirectory, "worktree", "remove", "--force", managedWorkspaceDirectory], - `Failed to remove the worktree at ${managedWorkspaceDirectory}`, - ); + const managedWorkspaceDirectory = resolveManagedWorkspaceDirectory(workspaceDirectory); + const defaultDirectory = resolveRepoDefaultDirectory(managedWorkspaceDirectory); + + if (!fs.existsSync(managedWorkspaceDirectory)) { + return; + } + + if (!fs.existsSync(defaultDirectory)) { + fs.rmSync(managedWorkspaceDirectory, { force: true, recursive: true }); + return; + } + + runCommand( + "git", + ["-C", defaultDirectory, "worktree", "remove", "--force", managedWorkspaceDirectory], + `Failed to remove the worktree at ${managedWorkspaceDirectory}`, + ); } function prepareSessionWorktree(repoUrl: string, title: string): PreparedWorkspace { - const workspace = resolveRepoWorkspacePaths(repoUrl); - const defaultBranch = ensureDefaultCheckout(repoUrl, workspace); - const identifier = nextWorktreeIdentifier(workspace.repoRoot, title); - const directory = path.join(workspace.repoRoot, identifier); - - runCommand( - "git", - [ - "-C", - workspace.defaultDirectory, - "worktree", - "add", - "--detach", - directory, - `origin/${defaultBranch}`, - ], - `Failed to create a worktree at ${directory}`, - ); - - return { - defaultDirectory: workspace.defaultDirectory, - directory, - }; + const workspace = resolveRepoWorkspacePaths(repoUrl); + const defaultBranch = ensureDefaultCheckout(repoUrl, workspace); + const identifier = nextWorktreeIdentifier(workspace.repoRoot, title); + const directory = path.join(workspace.repoRoot, identifier); + + runCommand( + "git", + [ + "-C", + workspace.defaultDirectory, + "worktree", + "add", + "--detach", + directory, + `origin/${defaultBranch}`, + ], + `Failed to create a worktree at ${directory}`, + ); + + return { + defaultDirectory: workspace.defaultDirectory, + directory, + }; } function ensureDefaultCheckout(repoUrl: string, workspace: RepoWorkspacePaths): string { - fs.mkdirSync(workspace.repoRoot, { recursive: true }); - - if (!fs.existsSync(workspace.defaultDirectory)) { - cloneDefaultCheckout(repoUrl, workspace.defaultDirectory); - } else if (!fs.existsSync(path.join(workspace.defaultDirectory, ".git"))) { - throw new Error(`Managed checkout exists without git metadata: ${workspace.defaultDirectory}`); - } - - runCommand( - "git", - ["-C", workspace.defaultDirectory, "fetch", "origin", "--prune"], - `Failed to fetch the default checkout in ${workspace.defaultDirectory}`, - ); - - const defaultBranch = resolveDefaultBranch(workspace.defaultDirectory); - - runCommand( - "git", - ["-C", workspace.defaultDirectory, "checkout", defaultBranch], - `Failed to checkout ${defaultBranch} in ${workspace.defaultDirectory}`, - ); - - runCommand( - "git", - ["-C", workspace.defaultDirectory, "pull", "--ff-only", "origin", defaultBranch], - `Failed to fast-forward ${defaultBranch} in ${workspace.defaultDirectory}`, - ); - - return defaultBranch; + fs.mkdirSync(workspace.repoRoot, { recursive: true }); + + if (!fs.existsSync(workspace.defaultDirectory)) { + cloneDefaultCheckout(repoUrl, workspace.defaultDirectory); + } else if (!fs.existsSync(path.join(workspace.defaultDirectory, ".git"))) { + throw new Error( + `Managed checkout exists without git metadata: ${workspace.defaultDirectory}`, + ); + } + + runCommand( + "git", + ["-C", workspace.defaultDirectory, "fetch", "origin", "--prune"], + `Failed to fetch the default checkout in ${workspace.defaultDirectory}`, + ); + + const defaultBranch = resolveDefaultBranch(workspace.defaultDirectory); + + runCommand( + "git", + ["-C", workspace.defaultDirectory, "checkout", defaultBranch], + `Failed to checkout ${defaultBranch} in ${workspace.defaultDirectory}`, + ); + + runCommand( + "git", + ["-C", workspace.defaultDirectory, "pull", "--ff-only", "origin", defaultBranch], + `Failed to fast-forward ${defaultBranch} in ${workspace.defaultDirectory}`, + ); + + return defaultBranch; } function cloneDefaultCheckout(repoUrl: string, defaultDirectory: string): void { - fs.mkdirSync(path.dirname(defaultDirectory), { recursive: true }); - - const repoSlug = parseRepoSlug(repoUrl); - runCommand( - "gh", - ["repo", "clone", repoSlug, defaultDirectory], - `Failed to clone ${repoSlug} into ${defaultDirectory}`, - ); + fs.mkdirSync(path.dirname(defaultDirectory), { recursive: true }); + + const repoSlug = parseRepoSlug(repoUrl); + runCommand( + "gh", + ["repo", "clone", repoSlug, defaultDirectory], + `Failed to clone ${repoSlug} into ${defaultDirectory}`, + ); } function resolveDefaultBranch(defaultDirectory: string): string { - const reference = runCommand( - "git", - ["-C", defaultDirectory, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"], - `Failed to resolve the default branch for ${defaultDirectory}`, - ) - .trim() - .replace(/^origin\//u, ""); - - if (!reference) { - throw new Error(`Failed to resolve the default branch for ${defaultDirectory}`); - } - - return reference; + const reference = runCommand( + "git", + ["-C", defaultDirectory, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"], + `Failed to resolve the default branch for ${defaultDirectory}`, + ) + .trim() + .replace(/^origin\//u, ""); + + if (!reference) { + throw new Error(`Failed to resolve the default branch for ${defaultDirectory}`); + } + + return reference; } function nextWorktreeIdentifier(repoRoot: string, title: string): string { - const titleSlug = slugifyIdentifier(title); - const timestamp = Math.floor(Date.now() / 1_000); + const titleSlug = slugifyIdentifier(title); + const timestamp = Math.floor(Date.now() / 1_000); - for (let attempt = 0; attempt < 100; attempt += 1) { - const identifier = - attempt === 0 ? `${timestamp}-${titleSlug}` : `${timestamp}-${titleSlug}-${attempt}`; + for (let attempt = 0; attempt < 100; attempt += 1) { + const identifier = + attempt === 0 ? `${timestamp}-${titleSlug}` : `${timestamp}-${titleSlug}-${attempt}`; - if (!fs.existsSync(path.join(repoRoot, identifier))) { - return identifier; + if (!fs.existsSync(path.join(repoRoot, identifier))) { + return identifier; + } } - } - throw new Error(`Failed to allocate a unique worktree directory in ${repoRoot}`); + throw new Error(`Failed to allocate a unique worktree directory in ${repoRoot}`); } function slugifyIdentifier(value: string): string { - let slug = ""; - let lastWasSeparator = false; - - for (const character of value) { - if (/[a-z0-9]/iu.test(character)) { - slug += character.toLowerCase(); - lastWasSeparator = false; - continue; + let slug = ""; + let lastWasSeparator = false; + + for (const character of value) { + if (/[a-z0-9]/iu.test(character)) { + slug += character.toLowerCase(); + lastWasSeparator = false; + continue; + } + + if (!lastWasSeparator) { + slug += "-"; + lastWasSeparator = true; + } } - if (!lastWasSeparator) { - slug += "-"; - lastWasSeparator = true; - } - } - - const trimmed = slug.replace(/^-+|-+$/gu, ""); - return trimmed || "session"; + const trimmed = slug.replace(/^-+|-+$/gu, ""); + return trimmed || "session"; } function resolveRepoWorkspacePaths(repoUrl: string): RepoWorkspacePaths { - const repoName = parseRepoName(repoUrl); - const repoRoot = path.join(resolveRunnerRoot(), repoName); + const repoName = parseRepoName(repoUrl); + const repoRoot = path.join(resolveRunnerRoot(), repoName); - return { - defaultDirectory: path.join(repoRoot, "default"), - repoRoot, - }; + return { + defaultDirectory: path.join(repoRoot, "default"), + repoRoot, + }; } function resolveRunnerRoot(): string { - return path.join(os.homedir(), "clanki"); + return path.join(os.homedir(), "clanki"); } function resolveManagedWorkspaceDirectory(workspaceDirectory: string): string { - const resolvedWorkspaceDirectory = path.resolve(workspaceDirectory); - const runnerRoot = resolveRunnerRoot(); - const relativePath = path.relative(runnerRoot, resolvedWorkspaceDirectory); - - if ( - relativePath.startsWith("..") || - path.isAbsolute(relativePath) || - relativePath.length === 0 || - path.basename(resolvedWorkspaceDirectory) === "default" - ) { - throw new Error(`Refusing to remove unmanaged workspace: ${workspaceDirectory}`); - } - - return resolvedWorkspaceDirectory; + const resolvedWorkspaceDirectory = path.resolve(workspaceDirectory); + const runnerRoot = resolveRunnerRoot(); + const relativePath = path.relative(runnerRoot, resolvedWorkspaceDirectory); + + if ( + relativePath.startsWith("..") || + path.isAbsolute(relativePath) || + relativePath.length === 0 || + path.basename(resolvedWorkspaceDirectory) === "default" + ) { + throw new Error(`Refusing to remove unmanaged workspace: ${workspaceDirectory}`); + } + + return resolvedWorkspaceDirectory; } function resolveRepoDefaultDirectory(workspaceDirectory: string): string { - return path.join(path.dirname(workspaceDirectory), "default"); + return path.join(path.dirname(workspaceDirectory), "default"); } function parseRepoSlug(repoUrl: string): string { - const normalized = normalizeRepoReference(repoUrl); - let repoPath = normalized; - - if (repoPath.startsWith("https://github.com/")) { - repoPath = repoPath.slice("https://github.com/".length); - } else if (repoPath.startsWith("git@github.com:")) { - repoPath = repoPath.slice("git@github.com:".length); - } else if (repoPath.startsWith("ssh://git@github.com/")) { - repoPath = repoPath.slice("ssh://git@github.com/".length); - } - - const segments = repoPath.split("/").filter(Boolean); - if (segments.length !== 2) { - throw new Error(`Unsupported GitHub repository URL: ${repoUrl}`); - } - - return `${segments[0]}/${segments[1]}`; + const normalized = normalizeRepoReference(repoUrl); + let repoPath = normalized; + + if (repoPath.startsWith("https://github.com/")) { + repoPath = repoPath.slice("https://github.com/".length); + } else if (repoPath.startsWith("git@github.com:")) { + repoPath = repoPath.slice("git@github.com:".length); + } else if (repoPath.startsWith("ssh://git@github.com/")) { + repoPath = repoPath.slice("ssh://git@github.com/".length); + } + + const segments = repoPath.split("/").filter(Boolean); + if (segments.length !== 2) { + throw new Error(`Unsupported GitHub repository URL: ${repoUrl}`); + } + + return `${segments[0]}/${segments[1]}`; } function parseRepoName(repoUrl: string): string { - const repoSlug = parseRepoSlug(repoUrl); - const repoName = repoSlug.split("/")[1]; + const repoSlug = parseRepoSlug(repoUrl); + const repoName = repoSlug.split("/")[1]; - if (!repoName) { - throw new Error(`Unsupported GitHub repository URL: ${repoUrl}`); - } + if (!repoName) { + throw new Error(`Unsupported GitHub repository URL: ${repoUrl}`); + } - return repoName; + return repoName; } function normalizeRepoReference(repoUrl: string): string { - return repoUrl - .trim() - .replace(/\/+$/u, "") - .replace(/\.git$/u, ""); + return repoUrl + .trim() + .replace(/\/+$/u, "") + .replace(/\.git$/u, ""); } function runCommand(program: string, args: string[], errorContext?: string): string { - const output = spawnSync(program, args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }); - - if (output.error) { - const message = errorContext - ? `${errorContext}: ${output.error.message}` - : `Failed to run ${program}: ${output.error.message}`; - throw new Error(message); - } - - if (output.status === 0) { - return output.stdout; - } - - const stderr = output.stderr.trim(); - const stdout = output.stdout.trim(); - const details = stderr || stdout || `exit status ${output.status}`; - - if (errorContext) { - throw new Error(`${errorContext}: ${details}`); - } - - throw new Error(`Command ${program} failed: ${details}`); + const output = spawnSync(program, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (output.error) { + const message = errorContext + ? `${errorContext}: ${output.error.message}` + : `Failed to run ${program}: ${output.error.message}`; + throw new Error(message); + } + + if (output.status === 0) { + return output.stdout; + } + + const stderr = output.stderr.trim(); + const stdout = output.stdout.trim(); + const details = stderr || stdout || `exit status ${output.status}`; + + if (errorContext) { + throw new Error(`${errorContext}: ${details}`); + } + + throw new Error(`Command ${program} failed: ${details}`); } diff --git a/packages/runner/tsconfig.json b/packages/runner/tsconfig.json index e9d3f77..771bbe6 100644 --- a/packages/runner/tsconfig.json +++ b/packages/runner/tsconfig.json @@ -1,7 +1,7 @@ { - "extends": "../../tsconfig.json", - "include": ["src/**/*.ts"], - "compilerOptions": { - "types": ["bun-types"] - } + "extends": "../../tsconfig.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "types": ["bun-types"] + } } diff --git a/src/components/add-project-dialog.tsx b/src/components/add-project-dialog.tsx index 306c1df..90a8a4e 100644 --- a/src/components/add-project-dialog.tsx +++ b/src/components/add-project-dialog.tsx @@ -1,11 +1,11 @@ -import { useState } from "react"; import { useMutation, useQuery } from "@tanstack/react-query"; import { ArrowUpRight, Check, Loader2, Lock, Search, X } from "lucide-react"; +import { useState } from "react"; +import { cn } from "../lib/utils"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Project, projectsCollection } from "@/lib/collections"; -import { cn } from "../lib/utils"; import { fetchInstallAppUrl, fetchInstallationRepos, diff --git a/src/components/animated-stream-item.tsx b/src/components/animated-stream-item.tsx index 198c70b..209c744 100644 --- a/src/components/animated-stream-item.tsx +++ b/src/components/animated-stream-item.tsx @@ -1,5 +1,5 @@ -import { type ReactNode } from "react"; import { motion, useReducedMotion } from "motion/react"; +import { type ReactNode } from "react"; import { cn } from "@/lib/utils"; interface AnimatedStreamItemProps { diff --git a/src/components/app-query-provider.tsx b/src/components/app-query-provider.tsx index cbefab8..5d97bf3 100644 --- a/src/components/app-query-provider.tsx +++ b/src/components/app-query-provider.tsx @@ -1,6 +1,7 @@ -import type { ReactNode } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; + const appQueryClient = new QueryClient(); export function AppQueryProvider({ children }: { children: ReactNode }) { diff --git a/src/components/collapsed-activity-group.tsx b/src/components/collapsed-activity-group.tsx index d7a0b5f..2b30a7a 100644 --- a/src/components/collapsed-activity-group.tsx +++ b/src/components/collapsed-activity-group.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; import { ChevronRight, Wrench } from "lucide-react"; +import { useState } from "react"; import { TaskStreamActivity, type TaskStreamActivityItem } from "@/components/task-stream-activity"; import { cn } from "@/lib/utils"; diff --git a/src/components/layout.tsx b/src/components/layout.tsx index 289fe38..5f95cac 100644 --- a/src/components/layout.tsx +++ b/src/components/layout.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from "react"; import { Outlet, useRouterState } from "@tanstack/react-router"; -import { AppQueryProvider } from "@/components/app-query-provider"; +import { useEffect, useState } from "react"; import { cn } from "../lib/utils"; import { MobileHeader } from "./layout/mobile-header"; import { Sidebar } from "./layout/sidebar"; +import { AppQueryProvider } from "@/components/app-query-provider"; export function Layout() { const [sidebarOpen, setSidebarOpen] = useState(false); diff --git a/src/components/layout/mobile-header.tsx b/src/components/layout/mobile-header.tsx index ca98d3b..dc93077 100644 --- a/src/components/layout/mobile-header.tsx +++ b/src/components/layout/mobile-header.tsx @@ -1,7 +1,7 @@ import { Menu, X } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { useOrganization } from "./use-organization"; import { UserMenu } from "./user-menu"; +import { Button } from "@/components/ui/button"; type MobileHeaderProps = { sidebarOpen: boolean; diff --git a/src/components/layout/org-switcher.tsx b/src/components/layout/org-switcher.tsx index 6a45082..c2ed23f 100644 --- a/src/components/layout/org-switcher.tsx +++ b/src/components/layout/org-switcher.tsx @@ -1,9 +1,9 @@ -import { useEffect, useRef, useState } from "react"; import { Building2, Pencil, Check } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { useEffect, useRef, useState } from "react"; import { authClient } from "../../lib/auth-client"; import { useOrganization } from "./use-organization"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; export function OrgSwitcher() { const activeOrg = useOrganization(); diff --git a/src/components/layout/task-list.tsx b/src/components/layout/task-list.tsx index 7b963a1..0f494f5 100644 --- a/src/components/layout/task-list.tsx +++ b/src/components/layout/task-list.tsx @@ -1,7 +1,5 @@ -import { useState } from "react"; -import { Link, useRouterState } from "@tanstack/react-router"; import { useLiveQuery } from "@tanstack/react-db"; -import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; +import { Link, useRouterState } from "@tanstack/react-router"; import { CheckCheck, CircleAlert, @@ -10,8 +8,12 @@ import { MessageSquare, Trash2, } from "lucide-react"; -import { Button } from "@/components/ui/button"; +import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react"; +import { useState } from "react"; +import { projectsCollection, pullRequestsCollection, tasksCollection } from "../../lib/collections"; +import { cn } from "../../lib/utils"; import { NewTaskButton } from "@/components/new-task-button"; +import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, @@ -20,8 +22,6 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { cn } from "../../lib/utils"; -import { projectsCollection, pullRequestsCollection, tasksCollection } from "../../lib/collections"; import { deleteDesktopRunnerWorkspace } from "@/lib/desktop-runner"; import { buildTaskSidebarGroups, diff --git a/src/components/layout/use-organization.ts b/src/components/layout/use-organization.ts index 7ffc210..1bd29a4 100644 --- a/src/components/layout/use-organization.ts +++ b/src/components/layout/use-organization.ts @@ -2,15 +2,15 @@ import { useEffect } from "react"; import { authClient } from "../../lib/auth-client"; export function useOrganization() { - const activeOrg = authClient.useActiveOrganization(); - const orgs = authClient.useListOrganizations(); + const activeOrg = authClient.useActiveOrganization(); + const orgs = authClient.useListOrganizations(); - // Auto-set active org if none is set but user has orgs. - useEffect(() => { - if (!activeOrg.isPending && !activeOrg.data && orgs.data && orgs.data.length > 0) { - authClient.organization.setActive({ organizationId: orgs.data[0].id }); - } - }, [activeOrg.isPending, activeOrg.data, orgs.data]); + // Auto-set active org if none is set but user has orgs. + useEffect(() => { + if (!activeOrg.isPending && !activeOrg.data && orgs.data && orgs.data.length > 0) { + authClient.organization.setActive({ organizationId: orgs.data[0].id }); + } + }, [activeOrg.isPending, activeOrg.data, orgs.data]); - return activeOrg; + return activeOrg; } diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx index aa4eb0a..240f8f6 100644 --- a/src/components/layout/user-menu.tsx +++ b/src/components/layout/user-menu.tsx @@ -1,5 +1,7 @@ import { Link, useNavigate } from "@tanstack/react-router"; import { ChevronDown, LogOut, Settings } from "lucide-react"; +import { signOut, useSession } from "../../lib/auth-client"; +import { cn } from "../../lib/utils"; import { ThemeToggle } from "@/components/theme-toggle"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -11,8 +13,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { cn } from "../../lib/utils"; -import { signOut, useSession } from "../../lib/auth-client"; type UserMenuProps = { showIdentity?: boolean; diff --git a/src/components/new-task-button.tsx b/src/components/new-task-button.tsx index 3167ce8..8e4db59 100644 --- a/src/components/new-task-button.tsx +++ b/src/components/new-task-button.tsx @@ -1,8 +1,8 @@ -import { useState, type ComponentProps } from "react"; -import { useNavigate } from "@tanstack/react-router"; import { useLiveQuery } from "@tanstack/react-db"; import { useHotkey } from "@tanstack/react-hotkeys"; +import { useNavigate } from "@tanstack/react-router"; import { Loader2, Plus } from "lucide-react"; +import { useState, type ComponentProps } from "react"; import { Button } from "@/components/ui/button"; import { Kbd } from "@/components/ui/kbd"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; diff --git a/src/components/open-editor-dropdown.tsx b/src/components/open-editor-dropdown.tsx index d2ad65d..3e4060d 100644 --- a/src/components/open-editor-dropdown.tsx +++ b/src/components/open-editor-dropdown.tsx @@ -1,6 +1,5 @@ import { ChevronDown, ExternalLink, Loader2 } from "lucide-react"; import { useState } from "react"; -import { openDesktopWorkspaceInEditor, type DesktopWorkspaceEditor } from "@/lib/desktop-runner"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -8,6 +7,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { openDesktopWorkspaceInEditor, type DesktopWorkspaceEditor } from "@/lib/desktop-runner"; type OpenEditorDropdownProps = { onError?: (message: string | null) => void; diff --git a/src/components/task-model-picker.tsx b/src/components/task-model-picker.tsx index 72b447e..5613404 100644 --- a/src/components/task-model-picker.tsx +++ b/src/components/task-model-picker.tsx @@ -1,11 +1,4 @@ import { ChevronDown, Loader2 } from "lucide-react"; -import type { DesktopRunnerModelSelection } from "@/lib/desktop-runner"; -import { - getRunnerModelOptionGroups, - parseRunnerModelSelection, - serializeRunnerModelSelection, - type RunnerModelOption, -} from "@/lib/runner-models"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -16,8 +9,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { + getRunnerModelOptionGroups, + parseRunnerModelSelection, + serializeRunnerModelSelection, + type RunnerModelOption, +} from "@/lib/runner-models"; import { cn } from "@/lib/utils"; +import type { DesktopRunnerModelSelection } from "@/lib/desktop-runner"; + type TaskModelPickerProps = { disabled?: boolean; error?: string | null; diff --git a/src/components/task-page-input.tsx b/src/components/task-page-input.tsx index 9d2c419..7a2afc2 100644 --- a/src/components/task-page-input.tsx +++ b/src/components/task-page-input.tsx @@ -1,10 +1,11 @@ -import type { KeyboardEvent, RefObject } from "react"; import { Loader2, Send } from "lucide-react"; import { TaskModelPicker } from "@/components/task-model-picker"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; + import type { getRunnerModelOptions } from "@/lib/runner-models"; +import type { KeyboardEvent, RefObject } from "react"; interface TaskPageInputProps { inputRef: RefObject; diff --git a/src/components/task-page-message-list.tsx b/src/components/task-page-message-list.tsx index 455bf5a..be4c518 100644 --- a/src/components/task-page-message-list.tsx +++ b/src/components/task-page-message-list.tsx @@ -1,11 +1,12 @@ -import type { RefObject } from "react"; import { Loader2 } from "lucide-react"; -import { TaskStreamActivity } from "@/components/task-stream-activity"; -import { MarkdownContent } from "@/components/markdown-content"; import { AnimatedStreamItem } from "@/components/animated-stream-item"; import { CollapsedActivityGroup } from "@/components/collapsed-activity-group"; +import { MarkdownContent } from "@/components/markdown-content"; +import { TaskStreamActivity } from "@/components/task-stream-activity"; import { formatDuration } from "@/lib/format-duration"; + import type { TimelineEntry } from "@/lib/task-timeline"; +import type { RefObject } from "react"; interface TaskPageMessageListProps { messageListRef: RefObject; diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx index 68bb4b9..d1ac9b1 100644 --- a/src/components/ui/avatar.tsx +++ b/src/components/ui/avatar.tsx @@ -1,6 +1,5 @@ -import * as React from "react"; import { Avatar as AvatarPrimitive } from "radix-ui"; - +import * as React from "react"; import { cn } from "@/lib/utils"; function Avatar({ diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index d72b3cf..0f1ecfb 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,7 +1,6 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; import { Slot } from "radix-ui"; - +import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index 23ec276..453db6c 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 07ffda7..b637dd2 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,9 +1,8 @@ -import * as React from "react"; import { XIcon } from "lucide-react"; import { Dialog as DialogPrimitive } from "radix-ui"; - -import { cn } from "@/lib/utils"; +import * as React from "react"; import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; function Dialog({ ...props }: React.ComponentProps) { return ; diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 0a37ce9..a1240cd 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,9 +1,8 @@ "use client"; -import * as React from "react"; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; - +import * as React from "react"; import { cn } from "@/lib/utils"; function DropdownMenu({ ...props }: React.ComponentProps) { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index 47612bc..0926517 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { cn } from "@/lib/utils"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx index 10a5bda..93235b4 100644 --- a/src/components/ui/separator.tsx +++ b/src/components/ui/separator.tsx @@ -1,8 +1,7 @@ "use client"; -import * as React from "react"; import { Separator as SeparatorPrimitive } from "radix-ui"; - +import * as React from "react"; import { cn } from "@/lib/utils"; function Separator({ diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index f739906..bf239bb 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -1,5 +1,4 @@ import * as React from "react"; - import { cn } from "@/lib/utils"; function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index e15f840..7f9ec44 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -1,5 +1,5 @@ -import { cn } from "@/lib/utils"; import { Tooltip as TooltipPrimitive } from "radix-ui"; +import { cn } from "@/lib/utils"; function TooltipProvider({ children, diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 1c8ad81..9048034 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,8 +1,8 @@ -import { createAuthClient } from "better-auth/react"; import { organizationClient } from "better-auth/client/plugins"; +import { createAuthClient } from "better-auth/react"; export const authClient = createAuthClient({ - plugins: [organizationClient()], + plugins: [organizationClient()], }); export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/src/lib/collections.ts b/src/lib/collections.ts index e56cc71..b82b5df 100644 --- a/src/lib/collections.ts +++ b/src/lib/collections.ts @@ -1,276 +1,276 @@ -import { createCollection } from "@tanstack/react-db"; import { electricCollectionOptions } from "@tanstack/electric-db-collection"; +import { createCollection } from "@tanstack/react-db"; import { z } from "zod"; import { createProjects } from "@/server/functions/projects"; import { createTask, createTaskMessage, deleteTask, updateTask } from "@/server/functions/tasks"; const projectSchema = z.object({ - id: z.string(), - organization_id: z.string(), - name: z.string(), - repo_url: z.string().nullable(), - installation_id: z.number().nullable(), - setup_command: z.string().nullable(), - run_command: z.string().nullable(), - run_port: z.number().nullable(), - created_at: z.bigint(), - updated_at: z.bigint(), + id: z.string(), + organization_id: z.string(), + name: z.string(), + repo_url: z.string().nullable(), + installation_id: z.number().nullable(), + setup_command: z.string().nullable(), + run_command: z.string().nullable(), + run_port: z.number().nullable(), + created_at: z.bigint(), + updated_at: z.bigint(), }); export type Project = z.infer; const taskSchema = z.object({ - id: z.string(), - organization_id: z.string(), - project_id: z.string().nullable(), - title: z.string(), - status: z.string(), - runner_type: z.string().nullable(), - runner_session_id: z.string().nullable(), - stream_id: z.string().nullable(), - workspace_path: z.string().nullable(), - branch: z.string().nullable(), - error: z.string().nullable(), - created_at: z.bigint(), - updated_at: z.bigint(), + id: z.string(), + organization_id: z.string(), + project_id: z.string().nullable(), + title: z.string(), + status: z.string(), + runner_type: z.string().nullable(), + runner_session_id: z.string().nullable(), + stream_id: z.string().nullable(), + workspace_path: z.string().nullable(), + branch: z.string().nullable(), + error: z.string().nullable(), + created_at: z.bigint(), + updated_at: z.bigint(), }); export type Task = z.infer; const pullRequestSchema = z.object({ - id: z.string(), - installation_id: z.number(), - repository: z.string(), - branch: z.string().nullable(), - pr_number: z.number(), - opened_at: z.bigint(), - merged_by: z.string().nullable(), - merged_at: z.bigint().nullable(), - ready_at: z.bigint().nullable(), - state: z.string().optional(), - review_state: z.string().nullable().optional(), - review_updated_at: z.bigint().nullable().optional(), - checks_count: z.number().nullable().optional(), - checks_completed_count: z.number().nullable().optional(), - checks_state: z.string().nullable().optional(), - checks_conclusion: z.string().nullable().optional(), - checks_updated_at: z.bigint().nullable().optional(), + id: z.string(), + installation_id: z.number(), + repository: z.string(), + branch: z.string().nullable(), + pr_number: z.number(), + opened_at: z.bigint(), + merged_by: z.string().nullable(), + merged_at: z.bigint().nullable(), + ready_at: z.bigint().nullable(), + state: z.string().optional(), + review_state: z.string().nullable().optional(), + review_updated_at: z.bigint().nullable().optional(), + checks_count: z.number().nullable().optional(), + checks_completed_count: z.number().nullable().optional(), + checks_state: z.string().nullable().optional(), + checks_conclusion: z.string().nullable().optional(), + checks_updated_at: z.bigint().nullable().optional(), }); export type PullRequest = z.infer; const taskMessageSchema = z.object({ - id: z.string(), - task_id: z.string(), - role: z.string(), - content: z.string(), - created_at: z.bigint(), + id: z.string(), + task_id: z.string(), + role: z.string(), + content: z.string(), + created_at: z.bigint(), }); function txidsToMatch(txids: Array) { - if (txids.length === 0) { - return; - } + if (txids.length === 0) { + return; + } - if (txids.length === 1) { - return { txid: txids[0] }; - } + if (txids.length === 1) { + return { txid: txids[0] }; + } - return { txid: txids }; + return { txid: txids }; } const taskFieldMap: Record> = { - title: "title", - runnerType: "runner_type", - runnerSessionId: "runner_session_id", - workspacePath: "workspace_path", - error: "error", + title: "title", + runnerType: "runner_type", + runnerSessionId: "runner_session_id", + workspacePath: "workspace_path", + error: "error", }; function getTaskUpdateInput(changes: Partial): Record { - return Object.fromEntries( - Object.entries(taskFieldMap) - .filter(([, snakeKey]) => changes[snakeKey] !== undefined) - .map(([camelKey, snakeKey]) => [camelKey, changes[snakeKey]]), - ); + return Object.fromEntries( + Object.entries(taskFieldMap) + .filter(([, snakeKey]) => changes[snakeKey] !== undefined) + .map(([camelKey, snakeKey]) => [camelKey, changes[snakeKey]]), + ); } function createCollections(baseUrl: string) { - const projectsCollection = createCollection( - electricCollectionOptions({ - schema: projectSchema, - shapeOptions: { - url: `${baseUrl}/api/projects/shape`, - }, - getKey: (p) => p.id, - onInsert: async ({ transaction }) => { - const repos = transaction.mutations.map((mutation) => { - const project = mutation.modified; - if (!project.repo_url) { - throw new Error("Project repository URL is required"); - } - if (project.installation_id === null) { - throw new Error("Project installation ID is required"); - } - - return { - id: project.id, - name: project.name, - repoUrl: project.repo_url, - installationId: project.installation_id, - createdAt: Number(project.created_at), - updatedAt: Number(project.updated_at), - }; - }); - - const { txid } = await createProjects({ data: { repos } }); - return txid !== undefined ? { txid } : undefined; - }, - onUpdate: async () => { - throw new Error("Project updates are not supported"); - }, - onDelete: async () => { - throw new Error("Project deletion is not supported"); - }, - }), - ); - - const tasksCollection = createCollection( - electricCollectionOptions({ - schema: taskSchema, - shapeOptions: { - url: `${baseUrl}/api/tasks/shape`, - }, - getKey: (t) => t.id, - onInsert: async ({ transaction }) => { - const txids: Array = []; - - for (const mutation of transaction.mutations) { - const task = mutation.modified; - if (!task.project_id) { - throw new Error("Task project is required"); - } - - const { txid } = await createTask({ - data: { - id: task.id, - title: task.title, - projectId: task.project_id, - runnerSessionId: task.runner_session_id ?? undefined, - runnerType: task.runner_type ?? undefined, - status: task.status, - workspacePath: task.workspace_path ?? undefined, - createdAt: Number(task.created_at), - updatedAt: Number(task.updated_at), + const projectsCollection = createCollection( + electricCollectionOptions({ + schema: projectSchema, + shapeOptions: { + url: `${baseUrl}/api/projects/shape`, + }, + getKey: (p) => p.id, + onInsert: async ({ transaction }) => { + const repos = transaction.mutations.map((mutation) => { + const project = mutation.modified; + if (!project.repo_url) { + throw new Error("Project repository URL is required"); + } + if (project.installation_id === null) { + throw new Error("Project installation ID is required"); + } + + return { + id: project.id, + name: project.name, + repoUrl: project.repo_url, + installationId: project.installation_id, + createdAt: Number(project.created_at), + updatedAt: Number(project.updated_at), + }; + }); + + const { txid } = await createProjects({ data: { repos } }); + return txid !== undefined ? { txid } : undefined; + }, + onUpdate: async () => { + throw new Error("Project updates are not supported"); + }, + onDelete: async () => { + throw new Error("Project deletion is not supported"); + }, + }), + ); + + const tasksCollection = createCollection( + electricCollectionOptions({ + schema: taskSchema, + shapeOptions: { + url: `${baseUrl}/api/tasks/shape`, + }, + getKey: (t) => t.id, + onInsert: async ({ transaction }) => { + const txids: Array = []; + + for (const mutation of transaction.mutations) { + const task = mutation.modified; + if (!task.project_id) { + throw new Error("Task project is required"); + } + + const { txid } = await createTask({ + data: { + id: task.id, + title: task.title, + projectId: task.project_id, + runnerSessionId: task.runner_session_id ?? undefined, + runnerType: task.runner_type ?? undefined, + status: task.status, + workspacePath: task.workspace_path ?? undefined, + createdAt: Number(task.created_at), + updatedAt: Number(task.updated_at), + }, + }); + + if (txid !== undefined) { + txids.push(txid); + } + } + + return txidsToMatch(txids); + }, + onUpdate: async ({ transaction }) => { + const txids: Array = []; + + for (const mutation of transaction.mutations) { + const changes = getTaskUpdateInput(mutation.changes); + if (Object.keys(changes).length === 0) { + continue; + } + + const { txid } = await updateTask({ + data: { + taskId: String(mutation.key), + ...changes, + }, + }); + if (txid !== undefined) { + txids.push(txid); + } + } + + return txidsToMatch(txids); + }, + onDelete: async ({ transaction }) => { + const txids: Array = []; + + for (const mutation of transaction.mutations) { + const { txid } = await deleteTask({ data: { taskId: String(mutation.key) } }); + if (txid !== undefined) { + txids.push(txid); + } + } + + return txidsToMatch(txids); + }, + }), + ); + + const pullRequestsCollection = createCollection( + electricCollectionOptions({ + schema: pullRequestSchema, + shapeOptions: { + url: `${baseUrl}/api/pull-requests/shape`, + }, + getKey: (pr) => pr.id, + onInsert: async () => { + throw new Error("Pull request insertion is not supported"); + }, + onUpdate: async () => { + throw new Error("Pull request updates are not supported"); + }, + onDelete: async () => { + throw new Error("Pull request deletion is not supported"); + }, + }), + ); + + const taskMessagesCollection = createCollection( + electricCollectionOptions({ + schema: taskMessageSchema, + shapeOptions: { + url: `${baseUrl}/api/tasks/messages/shape`, + }, + getKey: (m) => m.id, + onInsert: async ({ transaction }) => { + const txids: Array = []; + + for (const mutation of transaction.mutations) { + const message = mutation.modified; + const { txid } = await createTaskMessage({ + data: { + taskId: message.task_id, + message: { + id: message.id, + role: message.role, + content: message.content, + createdAt: Number(message.created_at), + }, + }, + }); + + if (txid !== undefined) { + txids.push(txid); + } + } + + return txidsToMatch(txids); }, - }); - - if (txid !== undefined) { - txids.push(txid); - } - } - - return txidsToMatch(txids); - }, - onUpdate: async ({ transaction }) => { - const txids: Array = []; - - for (const mutation of transaction.mutations) { - const changes = getTaskUpdateInput(mutation.changes); - if (Object.keys(changes).length === 0) { - continue; - } - - const { txid } = await updateTask({ - data: { - taskId: String(mutation.key), - ...changes, + onUpdate: async () => { + throw new Error("Task message updates are not supported"); }, - }); - if (txid !== undefined) { - txids.push(txid); - } - } - - return txidsToMatch(txids); - }, - onDelete: async ({ transaction }) => { - const txids: Array = []; - - for (const mutation of transaction.mutations) { - const { txid } = await deleteTask({ data: { taskId: String(mutation.key) } }); - if (txid !== undefined) { - txids.push(txid); - } - } - - return txidsToMatch(txids); - }, - }), - ); - - const pullRequestsCollection = createCollection( - electricCollectionOptions({ - schema: pullRequestSchema, - shapeOptions: { - url: `${baseUrl}/api/pull-requests/shape`, - }, - getKey: (pr) => pr.id, - onInsert: async () => { - throw new Error("Pull request insertion is not supported"); - }, - onUpdate: async () => { - throw new Error("Pull request updates are not supported"); - }, - onDelete: async () => { - throw new Error("Pull request deletion is not supported"); - }, - }), - ); - - const taskMessagesCollection = createCollection( - electricCollectionOptions({ - schema: taskMessageSchema, - shapeOptions: { - url: `${baseUrl}/api/tasks/messages/shape`, - }, - getKey: (m) => m.id, - onInsert: async ({ transaction }) => { - const txids: Array = []; - - for (const mutation of transaction.mutations) { - const message = mutation.modified; - const { txid } = await createTaskMessage({ - data: { - taskId: message.task_id, - message: { - id: message.id, - role: message.role, - content: message.content, - createdAt: Number(message.created_at), - }, + onDelete: async () => { + throw new Error("Task message deletion is not supported"); }, - }); - - if (txid !== undefined) { - txids.push(txid); - } - } - - return txidsToMatch(txids); - }, - onUpdate: async () => { - throw new Error("Task message updates are not supported"); - }, - onDelete: async () => { - throw new Error("Task message deletion is not supported"); - }, - }), - ); - - return { - projectsCollection, - tasksCollection, - pullRequestsCollection, - taskMessagesCollection, - }; + }), + ); + + return { + projectsCollection, + tasksCollection, + pullRequestsCollection, + taskMessagesCollection, + }; } type Collections = ReturnType; @@ -278,44 +278,44 @@ type Collections = ReturnType; let collections: Collections | null = null; function getCollections(): Collections { - if (typeof window === "undefined") { - throw new Error("Collections are only available in the browser runtime"); - } + if (typeof window === "undefined") { + throw new Error("Collections are only available in the browser runtime"); + } - if (collections === null) { - collections = createCollections(window.location.origin); - } + if (collections === null) { + collections = createCollections(window.location.origin); + } - return collections; + return collections; } function createLazyCollection( - selectCollection: (collections: Collections) => TCollection, + selectCollection: (collections: Collections) => TCollection, ): TCollection { - return new Proxy({} as TCollection, { - get(_target, property) { - const collection = selectCollection(getCollections()); - const value = Reflect.get(collection, property); - - if (typeof value === "function") { - return value.bind(collection); - } - - return value; - }, - has(_target, property) { - return property in selectCollection(getCollections()); - }, - ownKeys() { - return Reflect.ownKeys(selectCollection(getCollections())); - }, - getOwnPropertyDescriptor(_target, property) { - return Object.getOwnPropertyDescriptor(selectCollection(getCollections()), property); - }, - getPrototypeOf() { - return Object.getPrototypeOf(selectCollection(getCollections())); - }, - }); + return new Proxy({} as TCollection, { + get(_target, property) { + const collection = selectCollection(getCollections()); + const value = Reflect.get(collection, property); + + if (typeof value === "function") { + return value.bind(collection); + } + + return value; + }, + has(_target, property) { + return property in selectCollection(getCollections()); + }, + ownKeys() { + return Reflect.ownKeys(selectCollection(getCollections())); + }, + getOwnPropertyDescriptor(_target, property) { + return Object.getOwnPropertyDescriptor(selectCollection(getCollections()), property); + }, + getPrototypeOf() { + return Object.getPrototypeOf(selectCollection(getCollections())); + }, + }); } export const projectsCollection = createLazyCollection((value) => value.projectsCollection); diff --git a/src/lib/desktop-runner.ts b/src/lib/desktop-runner.ts index d6cdf08..72b3b15 100644 --- a/src/lib/desktop-runner.ts +++ b/src/lib/desktop-runner.ts @@ -1,114 +1,114 @@ type CreateDesktopRunnerSessionResponse = { - runnerType: string; - sessionId: string; - workspaceDirectory: string; + runnerType: string; + sessionId: string; + workspaceDirectory: string; }; export type DesktopWorkspaceEditor = "cursor" | "vscode" | "zed"; export type DesktopRunnerModelSelection = { - model: string; - provider: string; + model: string; + provider: string; }; export type DesktopRunnerModelProvider = { - id: string; - models: Record; - name: string; + id: string; + models: Record; + name: string; }; export type DesktopRunnerDiff = { - additions: number; - after: string; - before: string; - deletions: number; - file: string; + additions: number; + after: string; + before: string; + deletions: number; + file: string; }; export type ListDesktopRunnerModelsResponse = { - connected: string[]; - default: Record; - providers: DesktopRunnerModelProvider[]; + connected: string[]; + default: Record; + providers: DesktopRunnerModelProvider[]; }; type DesktopRunnerBridge = { - createRunnerSession: ( - title: string, - repoUrl: string, - ) => Promise; - deleteRunnerWorkspace: (workspaceDirectory: string) => Promise; - getRunnerDiff: (args: { directory: string; sessionId: string }) => Promise; - listRunnerModels: (args: { directory: string }) => Promise; - openWorkspaceInEditor: (args: { - editor: DesktopWorkspaceEditor; - workspaceDirectory: string; - }) => Promise; - promptRunnerTask: (args: { - backendBaseUrl: string; - callbackToken: string; - directory: string; - executionId: string; - model?: string; - prompt: string; - provider?: string; - sessionId: string; - }) => Promise; + createRunnerSession: ( + title: string, + repoUrl: string, + ) => Promise; + deleteRunnerWorkspace: (workspaceDirectory: string) => Promise; + getRunnerDiff: (args: { directory: string; sessionId: string }) => Promise; + listRunnerModels: (args: { directory: string }) => Promise; + openWorkspaceInEditor: (args: { + editor: DesktopWorkspaceEditor; + workspaceDirectory: string; + }) => Promise; + promptRunnerTask: (args: { + backendBaseUrl: string; + callbackToken: string; + directory: string; + executionId: string; + model?: string; + prompt: string; + provider?: string; + sessionId: string; + }) => Promise; }; declare global { - interface Window { - clankiDesktop?: DesktopRunnerBridge; - } + interface Window { + clankiDesktop?: DesktopRunnerBridge; + } } function getDesktopRunnerBridge(): DesktopRunnerBridge { - if (typeof window === "undefined" || !window.clankiDesktop) { - throw new Error("The desktop runner API is only available in the Electron app."); - } + if (typeof window === "undefined" || !window.clankiDesktop) { + throw new Error("The desktop runner API is only available in the Electron app."); + } - return window.clankiDesktop; + return window.clankiDesktop; } export async function createDesktopRunnerSession( - title: string, - repoUrl: string, + title: string, + repoUrl: string, ): Promise<{ runnerType: string; sessionId: string; workspaceDirectory: string }> { - return await getDesktopRunnerBridge().createRunnerSession(title, repoUrl); + return await getDesktopRunnerBridge().createRunnerSession(title, repoUrl); } export async function deleteDesktopRunnerWorkspace(workspaceDirectory: string): Promise { - await getDesktopRunnerBridge().deleteRunnerWorkspace(workspaceDirectory); + await getDesktopRunnerBridge().deleteRunnerWorkspace(workspaceDirectory); } export async function getDesktopRunnerDiff(args: { - directory: string; - sessionId: string; + directory: string; + sessionId: string; }): Promise { - return await getDesktopRunnerBridge().getRunnerDiff(args); + return await getDesktopRunnerBridge().getRunnerDiff(args); } export async function listDesktopRunnerModels(args: { - directory: string; + directory: string; }): Promise { - return await getDesktopRunnerBridge().listRunnerModels(args); + return await getDesktopRunnerBridge().listRunnerModels(args); } export async function openDesktopWorkspaceInEditor(args: { - editor: DesktopWorkspaceEditor; - workspaceDirectory: string; + editor: DesktopWorkspaceEditor; + workspaceDirectory: string; }): Promise { - await getDesktopRunnerBridge().openWorkspaceInEditor(args); + await getDesktopRunnerBridge().openWorkspaceInEditor(args); } export async function promptDesktopRunnerTask(args: { - backendBaseUrl: string; - callbackToken: string; - directory: string; - executionId: string; - model?: string; - prompt: string; - provider?: string; - sessionId: string; + backendBaseUrl: string; + callbackToken: string; + directory: string; + executionId: string; + model?: string; + prompt: string; + provider?: string; + sessionId: string; }): Promise { - await getDesktopRunnerBridge().promptRunnerTask(args); + await getDesktopRunnerBridge().promptRunnerTask(args); } diff --git a/src/lib/fail-task-run.ts b/src/lib/fail-task-run.ts index 8647f6b..1e5e0fa 100644 --- a/src/lib/fail-task-run.ts +++ b/src/lib/fail-task-run.ts @@ -1,24 +1,24 @@ export async function failTaskRun(args: { - backendBaseUrl: string; - callbackToken: string; - errorMessage: string; - executionId: string; + backendBaseUrl: string; + callbackToken: string; + errorMessage: string; + executionId: string; }): Promise { - const response = await fetch( - `${args.backendBaseUrl}/api/internal/task-runs/${args.executionId}/fail`, - { - method: "POST", - headers: { - Authorization: `Bearer ${args.callbackToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - error: args.errorMessage, - }), - }, - ); + const response = await fetch( + `${args.backendBaseUrl}/api/internal/task-runs/${args.executionId}/fail`, + { + method: "POST", + headers: { + Authorization: `Bearer ${args.callbackToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + error: args.errorMessage, + }), + }, + ); - if (!response.ok) { - throw new Error("Failed to report task-run failure"); - } + if (!response.ok) { + throw new Error("Failed to report task-run failure"); + } } diff --git a/src/lib/format-duration.ts b/src/lib/format-duration.ts index 8a3b260..543244e 100644 --- a/src/lib/format-duration.ts +++ b/src/lib/format-duration.ts @@ -1,16 +1,16 @@ export function formatDuration(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; - if (hours > 0) { - return `${hours}h ${minutes}m ${seconds}s`; - } + if (hours > 0) { + return `${hours}h ${minutes}m ${seconds}s`; + } - if (minutes > 0) { - return `${minutes}m ${seconds}s`; - } + if (minutes > 0) { + return `${minutes}m ${seconds}s`; + } - return `${seconds}s`; + return `${seconds}s`; } diff --git a/src/lib/hotkeys.ts b/src/lib/hotkeys.ts index 0e72ac3..9ab304b 100644 --- a/src/lib/hotkeys.ts +++ b/src/lib/hotkeys.ts @@ -1,22 +1,22 @@ import { detectPlatform } from "@tanstack/hotkeys"; interface HotkeyDef { - keys: string; - label: string; + keys: string; + label: string; } export const hotkeys = { - newTask: { keys: "Mod+N", label: "New task" }, - createPr: { keys: "Mod+Shift+P", label: "Create PR" }, + newTask: { keys: "Mod+N", label: "New task" }, + createPr: { keys: "Mod+Shift+P", label: "Create PR" }, } as const satisfies Record; const isMac = detectPlatform() === "mac"; export function formatKeys(keys: string): string[] { - return keys.split("+").map((part) => { - if (part === "Mod") return isMac ? "⌘" : "Ctrl"; - if (part === "Shift") return isMac ? "⇧" : "Shift"; - if (part === "Alt") return isMac ? "⌥" : "Alt"; - return part.toUpperCase(); - }); + return keys.split("+").map((part) => { + if (part === "Mod") return isMac ? "⌘" : "Ctrl"; + if (part === "Shift") return isMac ? "⇧" : "Shift"; + if (part === "Alt") return isMac ? "⌥" : "Alt"; + return part.toUpperCase(); + }); } diff --git a/src/lib/is-desktop-app.ts b/src/lib/is-desktop-app.ts index 45ffad1..01214d4 100644 --- a/src/lib/is-desktop-app.ts +++ b/src/lib/is-desktop-app.ts @@ -1,7 +1,7 @@ export function isDesktopApp(): boolean { - if (typeof window === "undefined") { - return false; - } + if (typeof window === "undefined") { + return false; + } - return "clankiDesktop" in (window as Window & { clankiDesktop?: unknown }); + return "clankiDesktop" in (window as Window & { clankiDesktop?: unknown }); } diff --git a/src/lib/pull-request.ts b/src/lib/pull-request.ts index 05d455a..d761075 100644 --- a/src/lib/pull-request.ts +++ b/src/lib/pull-request.ts @@ -1,131 +1,131 @@ export type PullRequestStatus = "open" | "merged" | "closed" | "draft"; export function getPullRequestStatus(pr: { - state?: string; - merged_at: bigint | null; - ready_at: bigint | null; + state?: string; + merged_at: bigint | null; + ready_at: bigint | null; }): PullRequestStatus { - switch (pr.state) { - case "draft": - return "draft"; - case "closed": - return "closed"; - case "merged": - return "merged"; - case "open": - return "open"; - default: { - if (pr.merged_at !== null) { - return "merged"; - } - return pr.ready_at === null ? "draft" : "open"; + switch (pr.state) { + case "draft": + return "draft"; + case "closed": + return "closed"; + case "merged": + return "merged"; + case "open": + return "open"; + default: { + if (pr.merged_at !== null) { + return "merged"; + } + return pr.ready_at === null ? "draft" : "open"; + } } - } } export function getPullRequestButtonClasses(status: PullRequestStatus): string { - switch (status) { - case "merged": - return "border-[#8250df] bg-[#8250df] text-white hover:border-[#6f42c1] hover:bg-[#6f42c1]"; - case "closed": - return "border-[#cf222e] bg-[#cf222e] text-white hover:border-[#a40e26] hover:bg-[#a40e26]"; - case "draft": - return "border-[#6e7781] bg-[#6e7781] text-white hover:border-[#57606a] hover:bg-[#57606a]"; - default: - return ""; - } + switch (status) { + case "merged": + return "border-[#8250df] bg-[#8250df] text-white hover:border-[#6f42c1] hover:bg-[#6f42c1]"; + case "closed": + return "border-[#cf222e] bg-[#cf222e] text-white hover:border-[#a40e26] hover:bg-[#a40e26]"; + case "draft": + return "border-[#6e7781] bg-[#6e7781] text-white hover:border-[#57606a] hover:bg-[#57606a]"; + default: + return ""; + } } export function humanizePullRequestStatus(status: string | null): string { - if (!status) { - return "unknown"; - } + if (!status) { + return "unknown"; + } - return status.replaceAll("_", " "); + return status.replaceAll("_", " "); } export function getReviewStatusClasses(reviewState: string | null): string { - switch (reviewState) { - case "approved": - return "border-emerald-300 bg-emerald-100 text-emerald-900"; - case "changes_requested": - return "border-red-300 bg-red-100 text-red-900"; - case "dismissed": - return "border-zinc-300 bg-zinc-100 text-zinc-800"; - default: - return "border-border bg-card text-muted-foreground"; - } + switch (reviewState) { + case "approved": + return "border-emerald-300 bg-emerald-100 text-emerald-900"; + case "changes_requested": + return "border-red-300 bg-red-100 text-red-900"; + case "dismissed": + return "border-zinc-300 bg-zinc-100 text-zinc-800"; + default: + return "border-border bg-card text-muted-foreground"; + } } export function getChecksStatusClasses( - checksState: string | null, - checksConclusion: string | null, + checksState: string | null, + checksConclusion: string | null, ): string { - switch (checksConclusion) { - case "success": - return "border-emerald-300 bg-emerald-100 text-emerald-900"; - case "failure": - case "cancelled": - case "timed_out": - case "action_required": - case "startup_failure": - case "stale": - return "border-red-300 bg-red-100 text-red-900"; - } + switch (checksConclusion) { + case "success": + return "border-emerald-300 bg-emerald-100 text-emerald-900"; + case "failure": + case "cancelled": + case "timed_out": + case "action_required": + case "startup_failure": + case "stale": + return "border-red-300 bg-red-100 text-red-900"; + } - switch (checksState) { - case "queued": - case "in_progress": - case "requested": - case "pending": - case "waiting": - return "border-amber-300 bg-amber-100 text-amber-900"; - default: - return "border-border bg-card text-muted-foreground"; - } + switch (checksState) { + case "queued": + case "in_progress": + case "requested": + case "pending": + case "waiting": + return "border-amber-300 bg-amber-100 text-amber-900"; + default: + return "border-border bg-card text-muted-foreground"; + } } function formatChecksStatus(checksState: string | null, checksConclusion: string | null): string { - if (checksState && checksConclusion) { - return `${humanizePullRequestStatus(checksState)} (${humanizePullRequestStatus(checksConclusion)})`; - } + if (checksState && checksConclusion) { + return `${humanizePullRequestStatus(checksState)} (${humanizePullRequestStatus(checksConclusion)})`; + } - return humanizePullRequestStatus(checksState ?? checksConclusion); + return humanizePullRequestStatus(checksState ?? checksConclusion); } export function formatChecksProgress( - checksCompletedCount: number | null, - checksCount: number | null, - checksState: string | null, - checksConclusion: string | null, + checksCompletedCount: number | null, + checksCount: number | null, + checksState: string | null, + checksConclusion: string | null, ): string { - if (checksCount != null && checksCount > 0) { - const completedChecks = Math.min(checksCompletedCount ?? 0, checksCount); - const pendingChecks = Math.max(0, checksCount - completedChecks); + if (checksCount != null && checksCount > 0) { + const completedChecks = Math.min(checksCompletedCount ?? 0, checksCount); + const pendingChecks = Math.max(0, checksCount - completedChecks); - if (pendingChecks > 0) { - return `${completedChecks}/${checksCount} checks done, ${pendingChecks} pending`; - } + if (pendingChecks > 0) { + return `${completedChecks}/${checksCount} checks done, ${pendingChecks} pending`; + } - return `${completedChecks}/${checksCount} checks done`; - } + return `${completedChecks}/${checksCount} checks done`; + } - return formatChecksStatus(checksState, checksConclusion); + return formatChecksStatus(checksState, checksConclusion); } export function extractOrgRepoFromUrl(url: string | null | undefined): string | null { - if (!url) { - return null; - } + if (!url) { + return null; + } - try { - const parsed = new URL(url); - const pathParts = parsed.pathname.split("/"); - if (pathParts.length < 3) { - return null; + try { + const parsed = new URL(url); + const pathParts = parsed.pathname.split("/"); + if (pathParts.length < 3) { + return null; + } + return pathParts.slice(1, 3).join("/"); + } catch { + return null; } - return pathParts.slice(1, 3).join("/"); - } catch { - return null; - } } diff --git a/src/lib/runner-diffs.ts b/src/lib/runner-diffs.ts index 01f605d..09c5cbf 100644 --- a/src/lib/runner-diffs.ts +++ b/src/lib/runner-diffs.ts @@ -1,37 +1,41 @@ import { useQuery } from "@tanstack/react-query"; -import type { DesktopRunnerDiff } from "@/lib/desktop-runner"; import { getDesktopRunnerDiff } from "@/lib/desktop-runner"; import { isDesktopApp } from "@/lib/is-desktop-app"; +import type { DesktopRunnerDiff } from "@/lib/desktop-runner"; + type UseRunnerDiffArgs = { - directory: string | null; - enabled?: boolean; - refetchIntervalMs?: number; - sessionId: string | null; + directory: string | null; + enabled?: boolean; + refetchIntervalMs?: number; + sessionId: string | null; }; export function useRunnerDiff({ - directory, - enabled = true, - refetchIntervalMs, - sessionId, + directory, + enabled = true, + refetchIntervalMs, + sessionId, }: UseRunnerDiffArgs) { - const desktopApp = isDesktopApp(); - const normalizedDirectory = directory?.trim() ?? ""; - const normalizedSessionId = sessionId?.trim() ?? ""; + const desktopApp = isDesktopApp(); + const normalizedDirectory = directory?.trim() ?? ""; + const normalizedSessionId = sessionId?.trim() ?? ""; - return useQuery({ - queryKey: ["runner-diff", normalizedDirectory, normalizedSessionId], - queryFn: async () => - await getDesktopRunnerDiff({ - directory: normalizedDirectory, - sessionId: normalizedSessionId, - }), - enabled: - enabled && desktopApp && normalizedDirectory.length > 0 && normalizedSessionId.length > 0, - gcTime: Number.POSITIVE_INFINITY, - refetchInterval: refetchIntervalMs, - refetchOnWindowFocus: false, - staleTime: 2_000, - }); + return useQuery({ + queryKey: ["runner-diff", normalizedDirectory, normalizedSessionId], + queryFn: async () => + await getDesktopRunnerDiff({ + directory: normalizedDirectory, + sessionId: normalizedSessionId, + }), + enabled: + enabled && + desktopApp && + normalizedDirectory.length > 0 && + normalizedSessionId.length > 0, + gcTime: Number.POSITIVE_INFINITY, + refetchInterval: refetchIntervalMs, + refetchOnWindowFocus: false, + staleTime: 2_000, + }); } diff --git a/src/lib/runner-models.ts b/src/lib/runner-models.ts index d81eaf3..7fd0b3e 100644 --- a/src/lib/runner-models.ts +++ b/src/lib/runner-models.ts @@ -1,161 +1,163 @@ import { useQuery } from "@tanstack/react-query"; import { - listDesktopRunnerModels, - type DesktopRunnerModelSelection, - type ListDesktopRunnerModelsResponse, + listDesktopRunnerModels, + type DesktopRunnerModelSelection, + type ListDesktopRunnerModelsResponse, } from "@/lib/desktop-runner"; import { isDesktopApp } from "@/lib/is-desktop-app"; export type RunnerModelOption = { - label: string; - model: string; - modelName: string; - provider: string; - providerName: string; - value: string; + label: string; + model: string; + modelName: string; + provider: string; + providerName: string; + value: string; }; export type RunnerModelOptionGroup = { - options: RunnerModelOption[]; - provider: string; - providerName: string; + options: RunnerModelOption[]; + provider: string; + providerName: string; }; const RUNNER_MODELS_QUERY_KEY = ["runner-models"] as const; export function useRunnerModels(directory: string | null) { - const desktopApp = isDesktopApp(); - const normalizedDirectory = directory?.trim() ?? ""; - - return useQuery({ - queryKey: RUNNER_MODELS_QUERY_KEY, - queryFn: async () => - await listDesktopRunnerModels({ - directory: normalizedDirectory, - }), - enabled: desktopApp && normalizedDirectory.length > 0, - gcTime: Number.POSITIVE_INFINITY, - refetchOnWindowFocus: false, - staleTime: Number.POSITIVE_INFINITY, - }); + const desktopApp = isDesktopApp(); + const normalizedDirectory = directory?.trim() ?? ""; + + return useQuery({ + queryKey: RUNNER_MODELS_QUERY_KEY, + queryFn: async () => + await listDesktopRunnerModels({ + directory: normalizedDirectory, + }), + enabled: desktopApp && normalizedDirectory.length > 0, + gcTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + staleTime: Number.POSITIVE_INFINITY, + }); } export function getDefaultRunnerModelSelection( - response: ListDesktopRunnerModelsResponse | undefined, + response: ListDesktopRunnerModelsResponse | undefined, ): DesktopRunnerModelSelection | null { - const options = getRunnerModelOptions(response); - - if (options.length === 0) { - return null; - } - - const defaultProviderId = response?.connected.find((providerId) => - options.some((option) => option.provider === providerId), - ); - - if (defaultProviderId) { - const defaultModelId = response?.default[defaultProviderId]; - if (defaultModelId) { - const defaultOption = options.find( - (option) => option.provider === defaultProviderId && option.model === defaultModelId, - ); - - if (defaultOption) { - return toRunnerModelSelection(defaultOption); - } + const options = getRunnerModelOptions(response); + + if (options.length === 0) { + return null; + } + + const defaultProviderId = response?.connected.find((providerId) => + options.some((option) => option.provider === providerId), + ); + + if (defaultProviderId) { + const defaultModelId = response?.default[defaultProviderId]; + if (defaultModelId) { + const defaultOption = options.find( + (option) => + option.provider === defaultProviderId && option.model === defaultModelId, + ); + + if (defaultOption) { + return toRunnerModelSelection(defaultOption); + } + } } - } - return toRunnerModelSelection(options[0]); + return toRunnerModelSelection(options[0]); } export function getRunnerModelOptions( - response: ListDesktopRunnerModelsResponse | undefined, + response: ListDesktopRunnerModelsResponse | undefined, ): RunnerModelOption[] { - if (!response || response.connected.length === 0) { - return []; - } - - const connectedProviderIds = new Set(response.connected); - - return response.providers - .filter((provider) => connectedProviderIds.has(provider.id)) - .toSorted( - (left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id), - ) - .flatMap((provider) => - Object.values(provider.models) + if (!response || response.connected.length === 0) { + return []; + } + + const connectedProviderIds = new Set(response.connected); + + return response.providers + .filter((provider) => connectedProviderIds.has(provider.id)) .toSorted( - (left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id), + (left, right) => left.name.localeCompare(right.name) || left.id.localeCompare(right.id), ) - .map((model) => ({ - label: `${provider.name} · ${model.name}`, - model: model.id, - modelName: model.name, - provider: provider.id, - providerName: provider.name, - value: serializeRunnerModelSelection({ - model: model.id, - provider: provider.id, - }), - })), - ); + .flatMap((provider) => + Object.values(provider.models) + .toSorted( + (left, right) => + left.name.localeCompare(right.name) || left.id.localeCompare(right.id), + ) + .map((model) => ({ + label: `${provider.name} · ${model.name}`, + model: model.id, + modelName: model.name, + provider: provider.id, + providerName: provider.name, + value: serializeRunnerModelSelection({ + model: model.id, + provider: provider.id, + }), + })), + ); } export function getRunnerModelOptionGroups(options: RunnerModelOption[]): RunnerModelOptionGroup[] { - const groups = new Map(); - - for (const option of options) { - const existingGroup = groups.get(option.provider); - if (existingGroup) { - existingGroup.options.push(option); - continue; + const groups = new Map(); + + for (const option of options) { + const existingGroup = groups.get(option.provider); + if (existingGroup) { + existingGroup.options.push(option); + continue; + } + + groups.set(option.provider, { + options: [option], + provider: option.provider, + providerName: option.providerName, + }); } - groups.set(option.provider, { - options: [option], - provider: option.provider, - providerName: option.providerName, - }); - } - - return [...groups.values()]; + return [...groups.values()]; } export function isRunnerModelSelectionAvailable( - selection: DesktopRunnerModelSelection | null, - options: RunnerModelOption[], + selection: DesktopRunnerModelSelection | null, + options: RunnerModelOption[], ): boolean { - if (!selection) { - return false; - } + if (!selection) { + return false; + } - return options.some( - (option) => option.model === selection.model && option.provider === selection.provider, - ); + return options.some( + (option) => option.model === selection.model && option.provider === selection.provider, + ); } export function parseRunnerModelSelection( - value: string, - options: RunnerModelOption[], + value: string, + options: RunnerModelOption[], ): DesktopRunnerModelSelection | null { - const option = options.find((candidate) => candidate.value === value); - return option ? toRunnerModelSelection(option) : null; + const option = options.find((candidate) => candidate.value === value); + return option ? toRunnerModelSelection(option) : null; } export function serializeRunnerModelSelection( - selection: DesktopRunnerModelSelection | null, + selection: DesktopRunnerModelSelection | null, ): string { - if (!selection) { - return ""; - } + if (!selection) { + return ""; + } - return `${selection.provider}:${selection.model}`; + return `${selection.provider}:${selection.model}`; } function toRunnerModelSelection(option: RunnerModelOption): DesktopRunnerModelSelection { - return { - model: option.model, - provider: option.provider, - }; + return { + model: option.model, + provider: option.provider, + }; } diff --git a/src/lib/session-state.ts b/src/lib/session-state.ts index d4c5687..e266da2 100644 --- a/src/lib/session-state.ts +++ b/src/lib/session-state.ts @@ -5,149 +5,149 @@ type SetStateAction = T | ((previousState: T) => T); type BrowserStorageKind = "local" | "session"; export type StorageStateKey = { - storage: BrowserStorageKind; - storageKey: string; - parse: (rawValue: string) => T; - serialize: (value: T) => string; + storage: BrowserStorageKind; + storageKey: string; + parse: (rawValue: string) => T; + serialize: (value: T) => string; }; function createStorageStateKey( - storage: BrowserStorageKind, - storageKey: string, - options?: { - parse?: (rawValue: string) => T; - serialize?: (value: T) => string; - }, + storage: BrowserStorageKind, + storageKey: string, + options?: { + parse?: (rawValue: string) => T; + serialize?: (value: T) => string; + }, ): StorageStateKey { - return { - storage, - storageKey, - parse: options?.parse ?? defaultParse, - serialize: options?.serialize ?? defaultSerialize, - }; + return { + storage, + storageKey, + parse: options?.parse ?? defaultParse, + serialize: options?.serialize ?? defaultSerialize, + }; } function useStorageState( - key: StorageStateKey, - initialState: T | (() => T), + key: StorageStateKey, + initialState: T | (() => T), ): [T, (nextState: SetStateAction) => void] { - const { parse, serialize, storage, storageKey } = key; - const prevKeyRef = useRef(storageKey); - const [state, setState] = useState(() => - getInitialStorageState(storage, storageKey, parse, initialState), - ); - - if (prevKeyRef.current !== storageKey) { - prevKeyRef.current = storageKey; - setState(getInitialStorageState(storage, storageKey, parse, initialState)); - } - - const setStorageState = (nextState: SetStateAction) => { - setState((previousState) => { - const resolvedState = - typeof nextState === "function" - ? (nextState as (previousState: T) => T)(previousState) - : nextState; - - const browserStorage = getBrowserStorage(storage); - - if (browserStorage) { - try { - browserStorage.setItem(storageKey, serialize(resolvedState)); - } catch { - return resolvedState; - } - } + const { parse, serialize, storage, storageKey } = key; + const prevKeyRef = useRef(storageKey); + const [state, setState] = useState(() => + getInitialStorageState(storage, storageKey, parse, initialState), + ); + + if (prevKeyRef.current !== storageKey) { + prevKeyRef.current = storageKey; + setState(getInitialStorageState(storage, storageKey, parse, initialState)); + } + + const setStorageState = (nextState: SetStateAction) => { + setState((previousState) => { + const resolvedState = + typeof nextState === "function" + ? (nextState as (previousState: T) => T)(previousState) + : nextState; + + const browserStorage = getBrowserStorage(storage); + + if (browserStorage) { + try { + browserStorage.setItem(storageKey, serialize(resolvedState)); + } catch { + return resolvedState; + } + } - return resolvedState; - }); - }; + return resolvedState; + }); + }; - return [state, setStorageState]; + return [state, setStorageState]; } export function useSessionState( - key: StorageStateKey, - initialState: T | (() => T), + key: StorageStateKey, + initialState: T | (() => T), ): [T, (nextState: SetStateAction) => void] { - return useStorageState(key, initialState); + return useStorageState(key, initialState); } export function useLocalStorageState( - key: StorageStateKey, - initialState: T | (() => T), + key: StorageStateKey, + initialState: T | (() => T), ): [T, (nextState: SetStateAction) => void] { - return useStorageState(key, initialState); + return useStorageState(key, initialState); } function resolveInitialState(initialState: T | (() => T)): T { - return typeof initialState === "function" ? (initialState as () => T)() : initialState; + return typeof initialState === "function" ? (initialState as () => T)() : initialState; } function getInitialStorageState( - storage: BrowserStorageKind, - storageKey: string, - parse: (rawValue: string) => T, - initialState: T | (() => T), + storage: BrowserStorageKind, + storageKey: string, + parse: (rawValue: string) => T, + initialState: T | (() => T), ): T { - const resolvedInitialState = resolveInitialState(initialState); - const browserStorage = getBrowserStorage(storage); + const resolvedInitialState = resolveInitialState(initialState); + const browserStorage = getBrowserStorage(storage); - if (!browserStorage) { - return resolvedInitialState; - } - - try { - const persistedValue = browserStorage.getItem(storageKey); - if (persistedValue === null) { - return resolvedInitialState; + if (!browserStorage) { + return resolvedInitialState; } - return parse(persistedValue); - } catch { - return resolvedInitialState; - } + try { + const persistedValue = browserStorage.getItem(storageKey); + if (persistedValue === null) { + return resolvedInitialState; + } + + return parse(persistedValue); + } catch { + return resolvedInitialState; + } } function defaultParse(rawValue: string): T { - return JSON.parse(rawValue) as T; + return JSON.parse(rawValue) as T; } function defaultSerialize(value: T): string { - return JSON.stringify(value); + return JSON.stringify(value); } function getBrowserStorage(storage: BrowserStorageKind): Storage | null { - if (typeof window === "undefined") { - return null; - } + if (typeof window === "undefined") { + return null; + } - return storage === "local" ? globalThis.localStorage : globalThis.sessionStorage; + return storage === "local" ? globalThis.localStorage : globalThis.sessionStorage; } export const sessionStateKeys = { - taskInput: (taskId: string) => createStorageStateKey("session", `task-input:${taskId}`), - taskModel: (taskId: string) => - createStorageStateKey<{ model: string; provider: string } | null>( - "session", - `task-model:${taskId}`, - ), - taskView: (taskId: string) => - createStorageStateKey<"chat" | "code">("session", `task-view:${taskId}`, { - parse: (value) => (value === "code" ? "code" : "chat"), - serialize: (value) => value, - }), + taskInput: (taskId: string) => createStorageStateKey("session", `task-input:${taskId}`), + taskModel: (taskId: string) => + createStorageStateKey<{ model: string; provider: string } | null>( + "session", + `task-model:${taskId}`, + ), + taskView: (taskId: string) => + createStorageStateKey<"chat" | "code">("session", `task-view:${taskId}`, { + parse: (value) => (value === "code" ? "code" : "chat"), + serialize: (value) => value, + }), }; export const localStorageKeys = { - lastUsedTaskModel: () => - createStorageStateKey<{ model: string; provider: string } | null>( - "local", - "last-used-task-model", - ), - theme: () => - createStorageStateKey<"light" | "dark">("local", "theme", { - parse: (value) => (value === "dark" ? "dark" : "light"), - serialize: (value) => value, - }), + lastUsedTaskModel: () => + createStorageStateKey<{ model: string; provider: string } | null>( + "local", + "last-used-task-model", + ), + theme: () => + createStorageStateKey<"light" | "dark">("local", "theme", { + parse: (value) => (value === "dark" ? "dark" : "light"), + serialize: (value) => value, + }), }; diff --git a/src/lib/task-activity-mapper.ts b/src/lib/task-activity-mapper.ts index 1b09a16..bdb97fb 100644 --- a/src/lib/task-activity-mapper.ts +++ b/src/lib/task-activity-mapper.ts @@ -1,509 +1,514 @@ -import type { TaskStreamActivityIcon } from "@/components/task-stream-activity"; -import type { ChronologicalActivityItem } from "@/lib/task-timeline"; import { - type TaskStreamEvent, - type TaskLifecycleEventPayload, - parseOpenCodeEventPayload, - parseTaskLifecycleEventPayload, + type TaskStreamEvent, + type TaskLifecycleEventPayload, + parseOpenCodeEventPayload, + parseTaskLifecycleEventPayload, } from "@/shared/task-stream-events"; + +import type { TaskStreamActivityIcon } from "@/components/task-stream-activity"; +import type { ChronologicalActivityItem } from "@/lib/task-timeline"; import type { Event as OpenCodeEvent } from "@opencode-ai/sdk"; export function toTaskStreamActivityItem(event: TaskStreamEvent): ChronologicalActivityItem | null { - if (event.kind === "assistant") { - return null; - } - - if (event.kind === "task.lifecycle") { - const lifecycle = parseTaskLifecycleEventPayload(event); - if (!lifecycle) { - return null; + if (event.kind === "assistant") { + return null; } - return taskLifecycleToActivityItem(event, lifecycle); - } + if (event.kind === "task.lifecycle") { + const lifecycle = parseTaskLifecycleEventPayload(event); + if (!lifecycle) { + return null; + } - const parsed = parseOpenCodeEventPayload(event); - if (!parsed) { - return null; - } + return taskLifecycleToActivityItem(event, lifecycle); + } - return openCodeEventToActivityItem(event, parsed); + const parsed = parseOpenCodeEventPayload(event); + if (!parsed) { + return null; + } + + return openCodeEventToActivityItem(event, parsed); } function taskLifecycleToActivityItem( - event: TaskStreamEvent, - lifecycle: TaskLifecycleEventPayload, + event: TaskStreamEvent, + lifecycle: TaskLifecycleEventPayload, ): ChronologicalActivityItem { - const details = lifecycle.details ? [`Command: ${lifecycle.details}`] : undefined; - - return { - id: event.id, - stateKey: `lifecycle:${lifecycle.phase}`, - icon: getLifecycleActivityIcon(lifecycle), - label: `${getLifecyclePhaseLabel(lifecycle.phase)}: ${lifecycle.message}`, - details, - tone: getLifecycleActivityTone(lifecycle.status), - spinning: lifecycle.status === "running", - createdAt: event.createdAt, - }; + const details = lifecycle.details ? [`Command: ${lifecycle.details}`] : undefined; + + return { + id: event.id, + stateKey: `lifecycle:${lifecycle.phase}`, + icon: getLifecycleActivityIcon(lifecycle), + label: `${getLifecyclePhaseLabel(lifecycle.phase)}: ${lifecycle.message}`, + details, + tone: getLifecycleActivityTone(lifecycle.status), + spinning: lifecycle.status === "running", + createdAt: event.createdAt, + }; } function getLifecyclePhaseLabel(phase: TaskLifecycleEventPayload["phase"]): string { - switch (phase) { - case "runner": - return "Runner"; - case "clone": - return "Git clone"; - case "setup": - return "Project setup"; - case "assistant": - return "Assistant"; - } + switch (phase) { + case "runner": + return "Runner"; + case "clone": + return "Git clone"; + case "setup": + return "Project setup"; + case "assistant": + return "Assistant"; + } } function getLifecycleActivityIcon(lifecycle: TaskLifecycleEventPayload): TaskStreamActivityIcon { - switch (lifecycle.phase) { - case "clone": - return "web"; - case "setup": - return "terminal"; - case "assistant": - return lifecycle.status === "completed" ? "success" : "status"; - case "runner": - default: - return "status"; - } + switch (lifecycle.phase) { + case "clone": + return "web"; + case "setup": + return "terminal"; + case "assistant": + return lifecycle.status === "completed" ? "success" : "status"; + case "runner": + default: + return "status"; + } } function getLifecycleActivityTone( - status: TaskLifecycleEventPayload["status"], + status: TaskLifecycleEventPayload["status"], ): ChronologicalActivityItem["tone"] { - switch (status) { - case "completed": - return "success"; - case "error": - return "error"; - default: - return "muted"; - } + switch (status) { + case "completed": + return "success"; + case "error": + return "error"; + default: + return "muted"; + } } function openCodeEventToActivityItem( - event: TaskStreamEvent, - parsed: OpenCodeEvent, + event: TaskStreamEvent, + parsed: OpenCodeEvent, ): ChronologicalActivityItem | null { - if (parsed.type === "message.part.updated") { - return messagePartToActivityItem(event, parsed); - } - - if (parsed.type === "command.executed") { - const { name, arguments: args } = parsed.properties; - const details: string[] = []; - appendDetail(details, "Arguments", args, 300); - - return { - id: event.id, - stateKey: `command:${event.id}`, - icon: "terminal", - label: `Command: ${name}`, - details: details.length > 0 ? details : undefined, - tone: "muted", - createdAt: event.createdAt, - }; - } - - if (parsed.type === "session.status") { - const { sessionID, status } = parsed.properties; - const details: string[] = []; - - if (status.type === "retry") { - appendDetail(details, "Attempt", status.attempt); - appendDetail(details, "Reason", status.message); + if (parsed.type === "message.part.updated") { + return messagePartToActivityItem(event, parsed); } - return { - id: event.id, - stateKey: `session:${sessionID}`, - icon: status.type === "idle" ? "success" : "status", - label: status.type === "idle" ? "Session idle" : `Session ${status.type}`, - details: details.length > 0 ? details : undefined, - tone: "muted", - spinning: status.type === "busy", - createdAt: event.createdAt, - }; - } - - if (parsed.type === "session.error") { - const { sessionID, error } = parsed.properties; - const message = getErrorMessage(error) ?? "Session error"; - const details: string[] = []; - appendDetail(details, "Error type", getErrorName(error)); - - return { - id: event.id, - stateKey: `session:${sessionID ?? "default"}`, - icon: "error", - label: message, - details: details.length > 0 ? details : undefined, - tone: "error", - createdAt: event.createdAt, - }; - } - - if (parsed.type === "permission.updated") { - const permission = parsed.properties; - const details: string[] = []; - appendDetail(details, "Type", permission.type); - appendDetail(details, "Pattern", formatPermissionPattern(permission.pattern)); + if (parsed.type === "command.executed") { + const { name, arguments: args } = parsed.properties; + const details: string[] = []; + appendDetail(details, "Arguments", args, 300); + + return { + id: event.id, + stateKey: `command:${event.id}`, + icon: "terminal", + label: `Command: ${name}`, + details: details.length > 0 ? details : undefined, + tone: "muted", + createdAt: event.createdAt, + }; + } - return { - id: event.id, - stateKey: `permission:${permission.id ?? permission.title ?? "default"}`, - icon: "permission", - label: permission.title ?? "Permission requested", - details: details.length > 0 ? details : undefined, - tone: "muted", - createdAt: event.createdAt, - }; - } + if (parsed.type === "session.status") { + const { sessionID, status } = parsed.properties; + const details: string[] = []; + + if (status.type === "retry") { + appendDetail(details, "Attempt", status.attempt); + appendDetail(details, "Reason", status.message); + } + + return { + id: event.id, + stateKey: `session:${sessionID}`, + icon: status.type === "idle" ? "success" : "status", + label: status.type === "idle" ? "Session idle" : `Session ${status.type}`, + details: details.length > 0 ? details : undefined, + tone: "muted", + spinning: status.type === "busy", + createdAt: event.createdAt, + }; + } - if (parsed.type === "permission.replied") { - const { permissionID, response } = parsed.properties; + if (parsed.type === "session.error") { + const { sessionID, error } = parsed.properties; + const message = getErrorMessage(error) ?? "Session error"; + const details: string[] = []; + appendDetail(details, "Error type", getErrorName(error)); + + return { + id: event.id, + stateKey: `session:${sessionID ?? "default"}`, + icon: "error", + label: message, + details: details.length > 0 ? details : undefined, + tone: "error", + createdAt: event.createdAt, + }; + } - return { - id: event.id, - stateKey: `permission:${permissionID ?? "default"}`, - icon: "permission", - label: `Permission: ${response}`, - tone: "muted", - createdAt: event.createdAt, - }; - } + if (parsed.type === "permission.updated") { + const permission = parsed.properties; + const details: string[] = []; + appendDetail(details, "Type", permission.type); + appendDetail(details, "Pattern", formatPermissionPattern(permission.pattern)); + + return { + id: event.id, + stateKey: `permission:${permission.id ?? permission.title ?? "default"}`, + icon: "permission", + label: permission.title ?? "Permission requested", + details: details.length > 0 ? details : undefined, + tone: "muted", + createdAt: event.createdAt, + }; + } - if (parsed.type === "todo.updated") { - const { todos } = parsed.properties; - const details: string[] = []; - appendDetail(details, "Status", summarizeTodoStatusCounts(todos)); - const focusTodo = - todos.find((todo) => todo.status === "in_progress") ?? - todos.find((todo) => todo.status === "pending"); - appendDetail(details, "Focus", focusTodo?.content); + if (parsed.type === "permission.replied") { + const { permissionID, response } = parsed.properties; + + return { + id: event.id, + stateKey: `permission:${permissionID ?? "default"}`, + icon: "permission", + label: `Permission: ${response}`, + tone: "muted", + createdAt: event.createdAt, + }; + } - return { - id: event.id, - stateKey: "todo-list", - icon: "tool", - label: `Todo list updated (${todos.length})`, - details: details.length > 0 ? details : undefined, - tone: "muted", - createdAt: event.createdAt, - }; - } + if (parsed.type === "todo.updated") { + const { todos } = parsed.properties; + const details: string[] = []; + appendDetail(details, "Status", summarizeTodoStatusCounts(todos)); + const focusTodo = + todos.find((todo) => todo.status === "in_progress") ?? + todos.find((todo) => todo.status === "pending"); + appendDetail(details, "Focus", focusTodo?.content); + + return { + id: event.id, + stateKey: "todo-list", + icon: "tool", + label: `Todo list updated (${todos.length})`, + details: details.length > 0 ? details : undefined, + tone: "muted", + createdAt: event.createdAt, + }; + } - return null; + return null; } function messagePartToActivityItem( - event: TaskStreamEvent, - parsed: Extract, + event: TaskStreamEvent, + parsed: Extract, ): ChronologicalActivityItem | null { - const { part } = parsed.properties; - - if (part.type === "reasoning") { - const isComplete = typeof part.time.end === "number"; - - return { - id: event.id, - stateKey: `reasoning:${part.id ?? part.messageID}`, - icon: "thinking", - label: isComplete ? "Thought complete" : "Thinking", - tone: "muted", - spinning: !isComplete, - createdAt: event.createdAt, - }; - } - - if (part.type === "step-start") { - const stepKey = part.snapshot ?? part.messageID; - - return { - id: event.id, - stateKey: `step:${stepKey}`, - icon: "thinking", - label: "Step: started", - tone: "muted", - createdAt: event.createdAt, - }; - } - - if (part.type === "step-finish") { - const stepKey = part.snapshot ?? part.messageID; - - return { - id: event.id, - stateKey: `step:${stepKey}`, - icon: "thinking", - label: "Step: complete", - tone: "muted", - createdAt: event.createdAt, - }; - } - - if (part.type === "tool") { - const toolName = part.tool; - const status = part.state.status; - const details: string[] = []; - const callId = part.callID ?? part.id; - appendDetail(details, "Input", part.state.input, 300); - - if (status === "pending") { - appendDetail(details, "Request", part.state.raw, 300); + const { part } = parsed.properties; + + if (part.type === "reasoning") { + const isComplete = typeof part.time.end === "number"; + + return { + id: event.id, + stateKey: `reasoning:${part.id ?? part.messageID}`, + icon: "thinking", + label: isComplete ? "Thought complete" : "Thinking", + tone: "muted", + spinning: !isComplete, + createdAt: event.createdAt, + }; } - if (status === "running") { - appendDetail(details, "Action", part.state.title); + if (part.type === "step-start") { + const stepKey = part.snapshot ?? part.messageID; + + return { + id: event.id, + stateKey: `step:${stepKey}`, + icon: "thinking", + label: "Step: started", + tone: "muted", + createdAt: event.createdAt, + }; } - if (status === "completed") { - appendDetail(details, "Result", part.state.title); - appendDetail(details, "Output", part.state.output, 320); - - const attachmentCount = part.state.attachments?.length ?? 0; - if (attachmentCount > 0) { - details.push(`Attachments: ${attachmentCount}`); - } + if (part.type === "step-finish") { + const stepKey = part.snapshot ?? part.messageID; + + return { + id: event.id, + stateKey: `step:${stepKey}`, + icon: "thinking", + label: "Step: complete", + tone: "muted", + createdAt: event.createdAt, + }; } - if (status === "error") { - appendDetail(details, "Error", part.state.error, 320); + if (part.type === "tool") { + const toolName = part.tool; + const status = part.state.status; + const details: string[] = []; + const callId = part.callID ?? part.id; + appendDetail(details, "Input", part.state.input, 300); + + if (status === "pending") { + appendDetail(details, "Request", part.state.raw, 300); + } + + if (status === "running") { + appendDetail(details, "Action", part.state.title); + } + + if (status === "completed") { + appendDetail(details, "Result", part.state.title); + appendDetail(details, "Output", part.state.output, 320); + + const attachmentCount = part.state.attachments?.length ?? 0; + if (attachmentCount > 0) { + details.push(`Attachments: ${attachmentCount}`); + } + } + + if (status === "error") { + appendDetail(details, "Error", part.state.error, 320); + } + + return { + id: event.id, + stateKey: `tool:${callId}`, + icon: getToolActivityIcon(toolName), + label: `${toolName}: ${status}`, + details: details.length > 0 ? details : undefined, + tone: status === "error" ? "error" : status === "completed" ? "success" : "muted", + spinning: status === "running", + createdAt: event.createdAt, + }; } - return { - id: event.id, - stateKey: `tool:${callId}`, - icon: getToolActivityIcon(toolName), - label: `${toolName}: ${status}`, - details: details.length > 0 ? details : undefined, - tone: status === "error" ? "error" : status === "completed" ? "success" : "muted", - spinning: status === "running", - createdAt: event.createdAt, - }; - } - - return null; + return null; } function appendDetail(details: string[], key: string, value: unknown, maxLength = 220): void { - const formatted = formatDetailValue(value, maxLength); - if (!formatted) { - return; - } + const formatted = formatDetailValue(value, maxLength); + if (!formatted) { + return; + } - details.push(`${key}: ${formatted}`); + details.push(`${key}: ${formatted}`); } function formatDetailValue(value: unknown, maxLength = 220): string | null { - if (value === null || value === undefined) { - return null; - } - - if (typeof value === "string") { - const normalized = value.trim().replace(/\s+/g, " "); - if (normalized.length === 0) { - return null; + if (value === null || value === undefined) { + return null; } - return truncateText(normalized, maxLength); - } - if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { - return String(value); - } - - if (Array.isArray(value)) { - if (value.length === 0) { - return null; + if (typeof value === "string") { + const normalized = value.trim().replace(/\s+/g, " "); + if (normalized.length === 0) { + return null; + } + return truncateText(normalized, maxLength); } - const values = value - .map((item) => formatDetailValue(item, Math.floor(maxLength / 2)) ?? "") - .filter((item) => item.length > 0); - - if (values.length === 0) { - return null; + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return String(value); } - const rendered = values.slice(0, 5).join(", "); - const suffix = values.length > 5 ? ", ..." : ""; - return truncateText(`${rendered}${suffix}`, maxLength); - } + if (Array.isArray(value)) { + if (value.length === 0) { + return null; + } - if (typeof value === "object") { - return formatObjectDetailValue(value as Record, maxLength); - } + const values = value + .map((item) => formatDetailValue(item, Math.floor(maxLength / 2)) ?? "") + .filter((item) => item.length > 0); - return null; -} + if (values.length === 0) { + return null; + } -function formatObjectDetailValue(value: Record, maxLength: number): string | null { - const entries = Object.entries(value).filter(([key, entryValue]) => { - if (entryValue === null || entryValue === undefined) { - return false; + const rendered = values.slice(0, 5).join(", "); + const suffix = values.length > 5 ? ", ..." : ""; + return truncateText(`${rendered}${suffix}`, maxLength); } - return !isHiddenDetailKey(key); - }); + if (typeof value === "object") { + return formatObjectDetailValue(value as Record, maxLength); + } - if (entries.length === 0) { return null; - } +} + +function formatObjectDetailValue(value: Record, maxLength: number): string | null { + const entries = Object.entries(value).filter(([key, entryValue]) => { + if (entryValue === null || entryValue === undefined) { + return false; + } + + return !isHiddenDetailKey(key); + }); - const summary = entries - .slice(0, 5) - .map(([key, entryValue]) => { - const formattedValue = formatDetailValue(entryValue, 90); - if (!formattedValue) { + if (entries.length === 0) { return null; - } + } - return `${toDisplayKey(key)}: ${formattedValue}`; - }) - .filter((entry): entry is string => entry !== null); + const summary = entries + .slice(0, 5) + .map(([key, entryValue]) => { + const formattedValue = formatDetailValue(entryValue, 90); + if (!formattedValue) { + return null; + } - if (summary.length === 0) { - return null; - } + return `${toDisplayKey(key)}: ${formattedValue}`; + }) + .filter((entry): entry is string => entry !== null); + + if (summary.length === 0) { + return null; + } - const suffix = entries.length > 5 ? ", ..." : ""; - return truncateText(`${summary.join(", ")}${suffix}`, maxLength); + const suffix = entries.length > 5 ? ", ..." : ""; + return truncateText(`${summary.join(", ")}${suffix}`, maxLength); } function isHiddenDetailKey(key: string): boolean { - const normalized = key.trim().toLowerCase(); - return ( - normalized === "id" || - normalized === "sessionid" || - normalized === "messageid" || - normalized === "callid" - ); + const normalized = key.trim().toLowerCase(); + return ( + normalized === "id" || + normalized === "sessionid" || + normalized === "messageid" || + normalized === "callid" + ); } function toDisplayKey(key: string): string { - const separated = key - .replace(/([a-z0-9])([A-Z])/g, "$1 $2") - .replaceAll("_", " ") - .trim(); - const normalized = separated.toLowerCase(); - if (normalized.length === 0) { - return ""; - } - - return `${normalized[0]?.toUpperCase() ?? ""}${normalized.slice(1)}`; + const separated = key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replaceAll("_", " ") + .trim(); + const normalized = separated.toLowerCase(); + if (normalized.length === 0) { + return ""; + } + + return `${normalized[0]?.toUpperCase() ?? ""}${normalized.slice(1)}`; } function truncateText(value: string, maxLength = 220): string { - if (value.length <= maxLength) { - return value; - } + if (value.length <= maxLength) { + return value; + } - if (maxLength <= 3) { - return value.slice(0, maxLength); - } + if (maxLength <= 3) { + return value.slice(0, maxLength); + } - return `${value.slice(0, maxLength - 3)}...`; + return `${value.slice(0, maxLength - 3)}...`; } function getErrorMessage(error: unknown): string | null { - if (!error || typeof error !== "object") { - return null; - } + if (!error || typeof error !== "object") { + return null; + } - const candidateData = (error as Record).data; - if (!candidateData || typeof candidateData !== "object") { - return null; - } + const candidateData = (error as Record).data; + if (!candidateData || typeof candidateData !== "object") { + return null; + } - const candidateMessage = (candidateData as Record).message; - return typeof candidateMessage === "string" ? candidateMessage : null; + const candidateMessage = (candidateData as Record).message; + return typeof candidateMessage === "string" ? candidateMessage : null; } function getErrorName(error: unknown): string | null { - if (!error || typeof error !== "object") { - return null; - } + if (!error || typeof error !== "object") { + return null; + } - const candidateName = (error as Record).name; - return typeof candidateName === "string" ? candidateName : null; + const candidateName = (error as Record).name; + return typeof candidateName === "string" ? candidateName : null; } function formatPermissionPattern(pattern: string | string[] | undefined): string | null { - if (!pattern) { - return null; - } + if (!pattern) { + return null; + } - if (typeof pattern === "string") { - return truncateText(pattern, 220); - } + if (typeof pattern === "string") { + return truncateText(pattern, 220); + } - if (pattern.length === 0) { - return null; - } + if (pattern.length === 0) { + return null; + } - return truncateText(pattern.join(", "), 220); + return truncateText(pattern.join(", "), 220); } function summarizeTodoStatusCounts(todos: Array<{ status: string }>): string { - const counts = new Map(); - for (const todo of todos) { - counts.set(todo.status, (counts.get(todo.status) ?? 0) + 1); - } - - const orderedStatuses = ["in_progress", "pending", "completed", "cancelled"]; - const summary: string[] = []; - - for (const status of orderedStatuses) { - const count = counts.get(status) ?? 0; - if (count > 0) { - summary.push(`${status} ${count}`); - counts.delete(status); - } - } - - const remainingEntries = Array.from(counts.entries()).toSorted((a, b) => - a[0].localeCompare(b[0]), - ); - for (const [status, count] of remainingEntries) { - summary.push(`${status} ${count}`); - } - - return summary.join(", "); + const counts = new Map(); + for (const todo of todos) { + counts.set(todo.status, (counts.get(todo.status) ?? 0) + 1); + } + + const orderedStatuses = ["in_progress", "pending", "completed", "cancelled"]; + const summary: string[] = []; + + for (const status of orderedStatuses) { + const count = counts.get(status) ?? 0; + if (count > 0) { + summary.push(`${status} ${count}`); + counts.delete(status); + } + } + + const remainingEntries = Array.from(counts.entries()).toSorted((a, b) => + a[0].localeCompare(b[0]), + ); + for (const [status, count] of remainingEntries) { + summary.push(`${status} ${count}`); + } + + return summary.join(", "); } function getToolActivityIcon(toolName: string): TaskStreamActivityIcon { - const normalized = toolName.toLowerCase(); - - if ( - normalized.includes("read") || - normalized.includes("file") || - normalized.includes("glob") || - normalized.includes("grep") || - normalized.includes("find") || - normalized.includes("ls") - ) { - return "file"; - } - - if (normalized.includes("web") || normalized.includes("search") || normalized.includes("fetch")) { - return "web"; - } - - if ( - normalized.includes("command") || - normalized.includes("exec") || - normalized.includes("bash") || - normalized.includes("shell") - ) { - return "terminal"; - } - - return "tool"; + const normalized = toolName.toLowerCase(); + + if ( + normalized.includes("read") || + normalized.includes("file") || + normalized.includes("glob") || + normalized.includes("grep") || + normalized.includes("find") || + normalized.includes("ls") + ) { + return "file"; + } + + if ( + normalized.includes("web") || + normalized.includes("search") || + normalized.includes("fetch") + ) { + return "web"; + } + + if ( + normalized.includes("command") || + normalized.includes("exec") || + normalized.includes("bash") || + normalized.includes("shell") + ) { + return "terminal"; + } + + return "tool"; } diff --git a/src/lib/task-sidebar.ts b/src/lib/task-sidebar.ts index ce37b9b..2fb130a 100644 --- a/src/lib/task-sidebar.ts +++ b/src/lib/task-sidebar.ts @@ -1,134 +1,135 @@ -import type { Project, PullRequest, Task } from "@/lib/collections"; import { - extractOrgRepoFromUrl, - getPullRequestStatus, - type PullRequestStatus, + extractOrgRepoFromUrl, + getPullRequestStatus, + type PullRequestStatus, } from "@/lib/pull-request"; +import type { Project, PullRequest, Task } from "@/lib/collections"; + export type TaskSidebarGroup = "merged" | "needsAction" | "openNoPr" | "awaitingReview" | "running"; export const TASK_SIDEBAR_GROUPS: Array<{ key: TaskSidebarGroup; label: string }> = [ - { key: "merged", label: "Merged" }, - { key: "needsAction", label: "Needs action" }, - { key: "openNoPr", label: "Idle" }, - { key: "awaitingReview", label: "Awaiting review" }, - { key: "running", label: "Running" }, + { key: "merged", label: "Merged" }, + { key: "needsAction", label: "Needs action" }, + { key: "openNoPr", label: "Idle" }, + { key: "awaitingReview", label: "Awaiting review" }, + { key: "running", label: "Running" }, ]; const FAILING_CHECK_CONCLUSIONS = new Set([ - "failure", - "cancelled", - "timed_out", - "action_required", - "startup_failure", - "stale", + "failure", + "cancelled", + "timed_out", + "action_required", + "startup_failure", + "stale", ]); function hasFailingChecks(checksConclusion: string | null | undefined): boolean { - if (!checksConclusion) { - return false; - } + if (!checksConclusion) { + return false; + } - return FAILING_CHECK_CONCLUSIONS.has(checksConclusion); + return FAILING_CHECK_CONCLUSIONS.has(checksConclusion); } function getSidebarGroupKey(params: { - checksConclusion: string | null | undefined; - hasError: boolean; - pullRequestStatus: PullRequestStatus | null; - reviewState: string | null | undefined; - taskStatus: string; + checksConclusion: string | null | undefined; + hasError: boolean; + pullRequestStatus: PullRequestStatus | null; + reviewState: string | null | undefined; + taskStatus: string; }): TaskSidebarGroup { - const { checksConclusion, hasError, pullRequestStatus, reviewState, taskStatus } = params; - - if (taskStatus === "running") { - return "running"; - } - - if (pullRequestStatus === "merged") { - return "merged"; - } - - if ( - hasError || - reviewState === "changes_requested" || - hasFailingChecks(checksConclusion) || - pullRequestStatus === "closed" || - pullRequestStatus === "draft" - ) { - return "needsAction"; - } - - if (!pullRequestStatus) { - return "openNoPr"; - } - - return "awaitingReview"; + const { checksConclusion, hasError, pullRequestStatus, reviewState, taskStatus } = params; + + if (taskStatus === "running") { + return "running"; + } + + if (pullRequestStatus === "merged") { + return "merged"; + } + + if ( + hasError || + reviewState === "changes_requested" || + hasFailingChecks(checksConclusion) || + pullRequestStatus === "closed" || + pullRequestStatus === "draft" + ) { + return "needsAction"; + } + + if (!pullRequestStatus) { + return "openNoPr"; + } + + return "awaitingReview"; } export function buildTaskSidebarGroups(params: { - projects: Project[]; - pullRequests: PullRequest[]; - tasks: Task[]; + projects: Project[]; + pullRequests: PullRequest[]; + tasks: Task[]; }): Record { - const { projects, pullRequests, tasks } = params; - const projectsById = new Map(projects.map((project) => [project.id, project])); - const latestPullRequestByKey = new Map(); - - for (const pullRequest of pullRequests) { - if (!pullRequest.branch) { - continue; + const { projects, pullRequests, tasks } = params; + const projectsById = new Map(projects.map((project) => [project.id, project])); + const latestPullRequestByKey = new Map(); + + for (const pullRequest of pullRequests) { + if (!pullRequest.branch) { + continue; + } + + const pullRequestKey = `${pullRequest.repository}::${pullRequest.branch}`; + if (!latestPullRequestByKey.has(pullRequestKey)) { + latestPullRequestByKey.set(pullRequestKey, pullRequest); + } } - const pullRequestKey = `${pullRequest.repository}::${pullRequest.branch}`; - if (!latestPullRequestByKey.has(pullRequestKey)) { - latestPullRequestByKey.set(pullRequestKey, pullRequest); + const groupedTasks: Record = { + merged: [], + needsAction: [], + openNoPr: [], + awaitingReview: [], + running: [], + }; + + for (const task of tasks) { + const projectRepository = extractOrgRepoFromUrl( + task.project_id ? projectsById.get(task.project_id)?.repo_url : null, + ); + const pullRequest = + projectRepository && task.branch + ? (latestPullRequestByKey.get(`${projectRepository}::${task.branch}`) ?? null) + : null; + const pullRequestStatus = pullRequest ? getPullRequestStatus(pullRequest) : null; + const groupKey = getSidebarGroupKey({ + taskStatus: task.status, + pullRequestStatus, + reviewState: pullRequest?.review_state, + checksConclusion: pullRequest?.checks_conclusion, + hasError: (task.error?.trim().length ?? 0) > 0, + }); + groupedTasks[groupKey].push(task); } - } - - const groupedTasks: Record = { - merged: [], - needsAction: [], - openNoPr: [], - awaitingReview: [], - running: [], - }; - - for (const task of tasks) { - const projectRepository = extractOrgRepoFromUrl( - task.project_id ? projectsById.get(task.project_id)?.repo_url : null, - ); - const pullRequest = - projectRepository && task.branch - ? (latestPullRequestByKey.get(`${projectRepository}::${task.branch}`) ?? null) - : null; - const pullRequestStatus = pullRequest ? getPullRequestStatus(pullRequest) : null; - const groupKey = getSidebarGroupKey({ - taskStatus: task.status, - pullRequestStatus, - reviewState: pullRequest?.review_state, - checksConclusion: pullRequest?.checks_conclusion, - hasError: (task.error?.trim().length ?? 0) > 0, - }); - groupedTasks[groupKey].push(task); - } - - return groupedTasks; + + return groupedTasks; } export function getFirstSidebarTaskId(params: { - projects: Project[]; - pullRequests: PullRequest[]; - tasks: Task[]; + projects: Project[]; + pullRequests: PullRequest[]; + tasks: Task[]; }): string | null { - const groupedTasks = buildTaskSidebarGroups(params); + const groupedTasks = buildTaskSidebarGroups(params); - for (const group of TASK_SIDEBAR_GROUPS) { - const firstTask = groupedTasks[group.key][0]; - if (firstTask) { - return firstTask.id; + for (const group of TASK_SIDEBAR_GROUPS) { + const firstTask = groupedTasks[group.key][0]; + if (firstTask) { + return firstTask.id; + } } - } - return null; + return null; } diff --git a/src/lib/task-stream-cache.ts b/src/lib/task-stream-cache.ts index 8f7260d..cd7f257 100644 --- a/src/lib/task-stream-cache.ts +++ b/src/lib/task-stream-cache.ts @@ -7,139 +7,146 @@ const DB_VERSION = 1; const STORE_NAME = "streams"; type TaskStreamCacheRecord = { - key: string; - offset: string | null; - events: TaskStreamEvent[]; - updatedAt: number; + key: string; + offset: string | null; + events: TaskStreamEvent[]; + updatedAt: number; }; type TaskStreamCacheSnapshot = { - offset: string | null; - events: TaskStreamEvent[]; + offset: string | null; + events: TaskStreamEvent[]; }; function canUseIndexedDb(): boolean { - return typeof indexedDB !== "undefined"; + return typeof indexedDB !== "undefined"; } function openDatabase(): Promise { - return new Promise((resolve, reject) => { - if (!canUseIndexedDb()) { - reject(new Error("IndexedDB is unavailable")); - return; - } + return new Promise((resolve, reject) => { + if (!canUseIndexedDb()) { + reject(new Error("IndexedDB is unavailable")); + return; + } - const request = indexedDB.open(DB_NAME, DB_VERSION); + const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { - const db = request.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME, { keyPath: "key" }); - } - }; + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: "key" }); + } + }; - request.onsuccess = () => { - resolve(request.result); - }; + request.onsuccess = () => { + resolve(request.result); + }; - request.addEventListener("error", () => { - reject(request.error ?? new Error("Failed to open IndexedDB")); + request.addEventListener("error", () => { + reject(request.error ?? new Error("Failed to open IndexedDB")); + }); }); - }); } function isTaskStreamEvent(value: unknown): value is TaskStreamEvent { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Record; - return ( - typeof candidate.id === "string" && - typeof candidate.taskId === "string" && - typeof candidate.runId === "string" && - typeof candidate.createdAt === "number" && - typeof candidate.kind === "string" && - typeof candidate.payload === "string" - ); + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.id === "string" && + typeof candidate.taskId === "string" && + typeof candidate.runId === "string" && + typeof candidate.createdAt === "number" && + typeof candidate.kind === "string" && + typeof candidate.payload === "string" + ); } function parseTaskStreamEvents(value: unknown): TaskStreamEvent[] { - if (!Array.isArray(value)) { - return []; - } + if (!Array.isArray(value)) { + return []; + } - return value.filter(isTaskStreamEvent); + return value.filter(isTaskStreamEvent); } export async function readTaskStreamCache(key: string): Promise { - if (!canUseIndexedDb()) { - return { offset: null, events: [] }; - } - - try { - const db = await openDatabase(); - const snapshot = await new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readonly"); - const store = tx.objectStore(STORE_NAME); - const request = store.get(key); - - request.onsuccess = () => { - const record = request.result as TaskStreamCacheRecord | undefined; - if (!record) { - resolve({ offset: null, events: [] }); - return; - } + if (!canUseIndexedDb()) { + return { offset: null, events: [] }; + } - resolve({ - offset: - typeof record.offset === "string" && record.offset.length > 0 ? record.offset : null, - events: parseTaskStreamEvents(record.events), + try { + const db = await openDatabase(); + const snapshot = await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readonly"); + const store = tx.objectStore(STORE_NAME); + const request = store.get(key); + + request.onsuccess = () => { + const record = request.result as TaskStreamCacheRecord | undefined; + if (!record) { + resolve({ offset: null, events: [] }); + return; + } + + resolve({ + offset: + typeof record.offset === "string" && record.offset.length > 0 + ? record.offset + : null, + events: parseTaskStreamEvents(record.events), + }); + }; + + request.addEventListener("error", () => { + reject(request.error ?? new Error("Failed to read stream cache")); + }); }); - }; - request.addEventListener("error", () => { - reject(request.error ?? new Error("Failed to read stream cache")); - }); - }); - - db.close(); - return snapshot; - } catch { - return { offset: null, events: [] }; - } + db.close(); + return snapshot; + } catch { + return { offset: null, events: [] }; + } } export async function writeTaskStreamCache( - key: string, - offset: string | null, - events: TaskStreamEvent[], + key: string, + offset: string | null, + events: TaskStreamEvent[], ): Promise { - if (!canUseIndexedDb()) { - return; - } - - try { - const db = await openDatabase(); - await new Promise((resolve, reject) => { - const tx = db.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - store.put({ key, offset, events, updatedAt: Date.now() } satisfies TaskStreamCacheRecord); - - tx.oncomplete = () => { - resolve(); - }; - - tx.addEventListener("error", () => { - reject(tx.error ?? new Error("Failed to persist stream cache")); - }); - - tx.addEventListener("abort", () => { - reject(tx.error ?? new Error("Failed to persist stream cache")); - }); - }); - db.close(); - } catch { - return; - } + if (!canUseIndexedDb()) { + return; + } + + try { + const db = await openDatabase(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, "readwrite"); + const store = tx.objectStore(STORE_NAME); + store.put({ + key, + offset, + events, + updatedAt: Date.now(), + } satisfies TaskStreamCacheRecord); + + tx.oncomplete = () => { + resolve(); + }; + + tx.addEventListener("error", () => { + reject(tx.error ?? new Error("Failed to persist stream cache")); + }); + + tx.addEventListener("abort", () => { + reject(tx.error ?? new Error("Failed to persist stream cache")); + }); + }); + db.close(); + } catch { + return; + } } diff --git a/src/lib/task-timeline.ts b/src/lib/task-timeline.ts index 2570c41..ba8c91a 100644 --- a/src/lib/task-timeline.ts +++ b/src/lib/task-timeline.ts @@ -1,287 +1,288 @@ +import { toTaskStreamActivityItem } from "@/lib/task-activity-mapper"; +import { parseOpenCodeEventPayload } from "@/shared/task-stream-events"; + import type { TaskStreamActivityItem } from "@/components/task-stream-activity"; import type { TaskStreamEvent } from "@/shared/task-stream-events"; -import { parseOpenCodeEventPayload } from "@/shared/task-stream-events"; -import { toTaskStreamActivityItem } from "@/lib/task-activity-mapper"; export type AssistantMessageSnapshot = { - content: string; - createdAt: number; + content: string; + createdAt: number; }; export type ChronologicalActivityItem = TaskStreamActivityItem & { - stateKey: string; - createdAt: number; + stateKey: string; + createdAt: number; }; export type TimelineEntry = - | { - type: "message"; - id: string; - createdAt: number; - role: string; - content: string; - } - | { - type: "activity"; - id: string; - createdAt: number; - item: TaskStreamActivityItem; - } - | { - type: "activity-group"; - id: string; - createdAt: number; - items: TaskStreamActivityItem[]; - } - | { - type: "assistant-draft"; - id: string; - createdAt: number; - content: string; - }; + | { + type: "message"; + id: string; + createdAt: number; + role: string; + content: string; + } + | { + type: "activity"; + id: string; + createdAt: number; + item: TaskStreamActivityItem; + } + | { + type: "activity-group"; + id: string; + createdAt: number; + items: TaskStreamActivityItem[]; + } + | { + type: "assistant-draft"; + id: string; + createdAt: number; + content: string; + }; export function getLatestAssistantMessage( - messages: Array<{ role: string; content: string; created_at: unknown }>, + messages: Array<{ role: string; content: string; created_at: unknown }>, ): AssistantMessageSnapshot | null { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message.role !== "assistant") { - continue; - } + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "assistant") { + continue; + } - const content = message.content.trim(); - const createdAt = toTimestampOrNull(message.created_at); - if (content.length > 0 && createdAt !== null) { - return { content, createdAt }; + const content = message.content.trim(); + const createdAt = toTimestampOrNull(message.created_at); + if (content.length > 0 && createdAt !== null) { + return { content, createdAt }; + } } - } - return null; + return null; } export function getLatestStreamAssistantPreview( - events: TaskStreamEvent[], + events: TaskStreamEvent[], ): AssistantMessageSnapshot | null { - const messageRoleById = new Map(); - let latest: AssistantMessageSnapshot | null = null; - - for (const event of events) { - if (event.kind === "assistant") { - const content = event.payload.trim(); - if (content.length > 0) { - latest = { content, createdAt: event.createdAt }; - } - continue; - } + const messageRoleById = new Map(); + let latest: AssistantMessageSnapshot | null = null; + + for (const event of events) { + if (event.kind === "assistant") { + const content = event.payload.trim(); + if (content.length > 0) { + latest = { content, createdAt: event.createdAt }; + } + continue; + } - const parsed = parseOpenCodeEventPayload(event); - if (!parsed) { - continue; - } + const parsed = parseOpenCodeEventPayload(event); + if (!parsed) { + continue; + } - if (parsed.type === "message.updated") { - const { info } = parsed.properties; - if (info.id && info.role) { - messageRoleById.set(info.id, info.role); - } - continue; - } + if (parsed.type === "message.updated") { + const { info } = parsed.properties; + if (info.id && info.role) { + messageRoleById.set(info.id, info.role); + } + continue; + } - if (parsed.type === "message.part.updated") { - const { part } = parsed.properties; - if (part.type !== "text") { - continue; - } + if (parsed.type === "message.part.updated") { + const { part } = parsed.properties; + if (part.type !== "text") { + continue; + } - const role = messageRoleById.get(part.messageID); - if (role !== "assistant") { - continue; - } + const role = messageRoleById.get(part.messageID); + if (role !== "assistant") { + continue; + } - if (part.text) { - latest = { content: part.text, createdAt: event.createdAt }; - } + if (part.text) { + latest = { content: part.text, createdAt: event.createdAt }; + } + } } - } - return latest; + return latest; } export function buildTaskStreamActivityItems( - events: TaskStreamEvent[], + events: TaskStreamEvent[], ): ChronologicalActivityItem[] { - const byStateKey = new Map(); - const orderedStateKeys: string[] = []; + const byStateKey = new Map(); + const orderedStateKeys: string[] = []; - for (const event of events) { - const item = toTaskStreamActivityItem(event); - if (!item) { - continue; - } + for (const event of events) { + const item = toTaskStreamActivityItem(event); + if (!item) { + continue; + } - const current = byStateKey.get(item.stateKey); - if (!current) { - byStateKey.set(item.stateKey, item); - orderedStateKeys.push(item.stateKey); - continue; - } + const current = byStateKey.get(item.stateKey); + if (!current) { + byStateKey.set(item.stateKey, item); + orderedStateKeys.push(item.stateKey); + continue; + } - byStateKey.set(item.stateKey, { - ...current, - ...item, - id: current.id, - stateKey: current.stateKey, - createdAt: current.createdAt, - }); - } + byStateKey.set(item.stateKey, { + ...current, + ...item, + id: current.id, + stateKey: current.stateKey, + createdAt: current.createdAt, + }); + } - return orderedStateKeys - .map((stateKey) => byStateKey.get(stateKey)) - .filter((item): item is ChronologicalActivityItem => item !== undefined) - .toSorted((a, b) => a.createdAt - b.createdAt); + return orderedStateKeys + .map((stateKey) => byStateKey.get(stateKey)) + .filter((item): item is ChronologicalActivityItem => item !== undefined) + .toSorted((a, b) => a.createdAt - b.createdAt); } export function buildChronologicalTimeline(args: { - messages: Array<{ id: string; role: string; content: string; created_at: unknown }>; - activityItems: ChronologicalActivityItem[]; - streamAssistantPreview: AssistantMessageSnapshot | null; - persistedAssistantMessage: AssistantMessageSnapshot | null; + messages: Array<{ id: string; role: string; content: string; created_at: unknown }>; + activityItems: ChronologicalActivityItem[]; + streamAssistantPreview: AssistantMessageSnapshot | null; + persistedAssistantMessage: AssistantMessageSnapshot | null; }): TimelineEntry[] { - const sortable: Array<{ order: number; item: TimelineEntry }> = []; - let order = 0; + const sortable: Array<{ order: number; item: TimelineEntry }> = []; + let order = 0; - for (const message of args.messages) { - const createdAt = toTimestampOrNull(message.created_at); - if (createdAt === null) { - continue; + for (const message of args.messages) { + const createdAt = toTimestampOrNull(message.created_at); + if (createdAt === null) { + continue; + } + + sortable.push({ + order, + item: { + type: "message", + id: message.id, + createdAt, + role: message.role, + content: message.content, + }, + }); + order += 1; } - sortable.push({ - order, - item: { - type: "message", - id: message.id, - createdAt, - role: message.role, - content: message.content, - }, - }); - order += 1; - } - - for (const activity of args.activityItems) { - sortable.push({ - order, - item: { - type: "activity", - id: activity.id, - createdAt: activity.createdAt, - item: { - id: activity.id, - icon: activity.icon, - label: activity.label, - details: activity.details, - tone: activity.tone, - spinning: activity.spinning, - }, - }, - }); - order += 1; - } - - if ( - args.streamAssistantPreview && - args.streamAssistantPreview.content !== (args.persistedAssistantMessage?.content ?? "") - ) { - sortable.push({ - order, - item: { - type: "assistant-draft", - id: `stream-assistant-${args.streamAssistantPreview.createdAt}`, - createdAt: args.streamAssistantPreview.createdAt, - content: args.streamAssistantPreview.content, - }, - }); - } + for (const activity of args.activityItems) { + sortable.push({ + order, + item: { + type: "activity", + id: activity.id, + createdAt: activity.createdAt, + item: { + id: activity.id, + icon: activity.icon, + label: activity.label, + details: activity.details, + tone: activity.tone, + spinning: activity.spinning, + }, + }, + }); + order += 1; + } - const sorted = sortable.toSorted((a, b) => { - if (a.item.createdAt === b.item.createdAt) { - return a.order - b.order; + if ( + args.streamAssistantPreview && + args.streamAssistantPreview.content !== (args.persistedAssistantMessage?.content ?? "") + ) { + sortable.push({ + order, + item: { + type: "assistant-draft", + id: `stream-assistant-${args.streamAssistantPreview.createdAt}`, + createdAt: args.streamAssistantPreview.createdAt, + content: args.streamAssistantPreview.content, + }, + }); } - return a.item.createdAt - b.item.createdAt; - }); - return groupTimelineActivities(sorted.map((entry) => entry.item)); + const sorted = sortable.toSorted((a, b) => { + if (a.item.createdAt === b.item.createdAt) { + return a.order - b.order; + } + return a.item.createdAt - b.item.createdAt; + }); + + return groupTimelineActivities(sorted.map((entry) => entry.item)); } function groupTimelineActivities(entries: TimelineEntry[]): TimelineEntry[] { - const result: TimelineEntry[] = []; - let pendingActivities: Array = []; + const result: TimelineEntry[] = []; + let pendingActivities: Array = []; - for (const entry of entries) { - if (entry.type === "activity") { - pendingActivities.push(entry); - continue; - } + for (const entry of entries) { + if (entry.type === "activity") { + pendingActivities.push(entry); + continue; + } - if (pendingActivities.length > 0) { - if (entry.type === "message" && entry.role === "assistant") { - result.push({ - type: "activity-group", - id: `group-${pendingActivities[0].id}`, - createdAt: pendingActivities[0].createdAt, - items: pendingActivities.map((a) => a.item), - }); - } else { - for (const a of pendingActivities) { - result.push(a); + if (pendingActivities.length > 0) { + if (entry.type === "message" && entry.role === "assistant") { + result.push({ + type: "activity-group", + id: `group-${pendingActivities[0].id}`, + createdAt: pendingActivities[0].createdAt, + items: pendingActivities.map((a) => a.item), + }); + } else { + for (const a of pendingActivities) { + result.push(a); + } + } + pendingActivities = []; } - } - pendingActivities = []; - } - result.push(entry); - } + result.push(entry); + } - for (const a of pendingActivities) { - result.push(a); - } + for (const a of pendingActivities) { + result.push(a); + } - return result; + return result; } export function getLatestUserMessageCreatedAt( - messages: Array<{ role: string; created_at: unknown }>, + messages: Array<{ role: string; created_at: unknown }>, ): number | null { - for (let index = messages.length - 1; index >= 0; index -= 1) { - const message = messages[index]; - if (message.role !== "user") { - continue; - } + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== "user") { + continue; + } - const createdAt = toTimestampOrNull(message.created_at); - if (createdAt !== null) { - return createdAt; + const createdAt = toTimestampOrNull(message.created_at); + if (createdAt !== null) { + return createdAt; + } } - } - return null; + return null; } function toTimestampOrNull(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } - if (typeof value === "bigint") { - const asNumber = Number(value); - return Number.isFinite(asNumber) ? asNumber : null; - } + if (typeof value === "bigint") { + const asNumber = Number(value); + return Number.isFinite(asNumber) ? asNumber : null; + } - if (typeof value === "string" && value.length > 0) { - const asNumber = Number(value); - return Number.isFinite(asNumber) ? asNumber : null; - } + if (typeof value === "string" && value.length > 0) { + const asNumber = Number(value); + return Number.isFinite(asNumber) ? asNumber : null; + } - return null; + return null; } diff --git a/src/lib/theme.ts b/src/lib/theme.ts index 75e8d74..5422abb 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -5,46 +5,46 @@ export type Theme = "light" | "dark"; export const defaultTheme: Theme = "light"; const themeColors: Record = { - light: "#eaf1f8", - dark: "#101924", + light: "#eaf1f8", + dark: "#101924", }; const themeStorageKey = localStorageKeys.theme().storageKey; function resolveTheme(value: unknown): Theme { - return value === "dark" ? "dark" : defaultTheme; + return value === "dark" ? "dark" : defaultTheme; } export function applyTheme(theme: Theme) { - if (typeof document === "undefined") { - return; - } + if (typeof document === "undefined") { + return; + } - const root = document.documentElement; - root.classList.toggle("dark", theme === "dark"); - root.style.colorScheme = theme; + const root = document.documentElement; + root.classList.toggle("dark", theme === "dark"); + root.style.colorScheme = theme; - const themeColorMeta = document.querySelector('meta[name="theme-color"]'); + const themeColorMeta = document.querySelector('meta[name="theme-color"]'); - if (themeColorMeta instanceof HTMLMetaElement) { - themeColorMeta.content = themeColors[theme]; - } + if (themeColorMeta instanceof HTMLMetaElement) { + themeColorMeta.content = themeColors[theme]; + } } export function getStoredTheme(): Theme { - if (typeof window === "undefined") { - return defaultTheme; - } - - try { - return resolveTheme(window.localStorage.getItem(themeStorageKey)); - } catch { - return defaultTheme; - } + if (typeof window === "undefined") { + return defaultTheme; + } + + try { + return resolveTheme(window.localStorage.getItem(themeStorageKey)); + } catch { + return defaultTheme; + } } export const themeInitializationScript = `(function(){try{var theme=localStorage.getItem(${JSON.stringify( - themeStorageKey, + themeStorageKey, )});var resolved=theme==="dark"?"dark":"light";var root=document.documentElement;root.classList.toggle("dark",resolved==="dark");root.style.colorScheme=resolved;var meta=document.querySelector('meta[name="theme-color"]');if(meta){meta.setAttribute("content",resolved==="dark"?${JSON.stringify( - themeColors.dark, + themeColors.dark, )}:${JSON.stringify(themeColors.light)})}}catch(_error){document.documentElement.style.colorScheme="light";}})();`; diff --git a/src/lib/use-task-event-stream.ts b/src/lib/use-task-event-stream.ts index 8cf086f..9bde645 100644 --- a/src/lib/use-task-event-stream.ts +++ b/src/lib/use-task-event-stream.ts @@ -1,8 +1,9 @@ -import { useEffect, useState } from "react"; import { stream } from "@durable-streams/client"; +import { useEffect, useState } from "react"; + import type { TaskStreamEvent } from "@/shared/task-stream-events"; function getTaskEventStreamUrl(taskId: string) { - return `${globalThis.location.origin}/api/tasks/${taskId}/stream`; + return `${globalThis.location.origin}/api/tasks/${taskId}/stream`; } import { readTaskStreamCache, writeTaskStreamCache } from "./task-stream-cache"; @@ -10,171 +11,171 @@ const STREAM_STORAGE_PREFIX = "task-event-stream"; const MAX_PERSISTED_EVENTS = 1_000; function isAbortError(error: unknown): boolean { - return error instanceof DOMException && error.name === "AbortError"; + return error instanceof DOMException && error.name === "AbortError"; } function appendUniqueEvents( - previousEvents: TaskStreamEvent[], - nextEvents: readonly TaskStreamEvent[], + previousEvents: TaskStreamEvent[], + nextEvents: readonly TaskStreamEvent[], ): TaskStreamEvent[] { - if (nextEvents.length === 0) { - return previousEvents; - } + if (nextEvents.length === 0) { + return previousEvents; + } - const seenEventIds = new Set(previousEvents.map((event) => event.id)); - const mergedEvents = [...previousEvents]; - let didAddEvent = false; + const seenEventIds = new Set(previousEvents.map((event) => event.id)); + const mergedEvents = [...previousEvents]; + let didAddEvent = false; - for (const event of nextEvents) { - if (seenEventIds.has(event.id)) { - continue; - } + for (const event of nextEvents) { + if (seenEventIds.has(event.id)) { + continue; + } - seenEventIds.add(event.id); - mergedEvents.push(event); - didAddEvent = true; - } + seenEventIds.add(event.id); + mergedEvents.push(event); + didAddEvent = true; + } - return didAddEvent ? mergedEvents : previousEvents; + return didAddEvent ? mergedEvents : previousEvents; } function getTaskStreamStorageKey(taskId: string, streamId: string): string { - return `${STREAM_STORAGE_PREFIX}:${taskId}:${streamId}`; + return `${STREAM_STORAGE_PREFIX}:${taskId}:${streamId}`; } function trimPersistedEvents(events: TaskStreamEvent[]): TaskStreamEvent[] { - if (events.length <= MAX_PERSISTED_EVENTS) { - return events; - } + if (events.length <= MAX_PERSISTED_EVENTS) { + return events; + } - return events.slice(events.length - MAX_PERSISTED_EVENTS); + return events.slice(events.length - MAX_PERSISTED_EVENTS); } function parsePersistedEvents(value: unknown): TaskStreamEvent[] { - if (!Array.isArray(value)) { - return []; - } + if (!Array.isArray(value)) { + return []; + } - return value.filter(isTaskStreamEvent); + return value.filter(isTaskStreamEvent); } function isTaskStreamEvent(value: unknown): value is TaskStreamEvent { - if (!value || typeof value !== "object") { - return false; - } - - const candidate = value as Record; - return ( - typeof candidate.id === "string" && - typeof candidate.taskId === "string" && - typeof candidate.runId === "string" && - typeof candidate.createdAt === "number" && - typeof candidate.kind === "string" && - typeof candidate.payload === "string" - ); + if (!value || typeof value !== "object") { + return false; + } + + const candidate = value as Record; + return ( + typeof candidate.id === "string" && + typeof candidate.taskId === "string" && + typeof candidate.runId === "string" && + typeof candidate.createdAt === "number" && + typeof candidate.kind === "string" && + typeof candidate.payload === "string" + ); } function extractBatchItems(value: unknown): readonly TaskStreamEvent[] { - if (!value || typeof value !== "object") { - return []; - } + if (!value || typeof value !== "object") { + return []; + } - const candidate = value as Record; - const parsedItems = parsePersistedEvents(candidate.items); - return parsedItems; + const candidate = value as Record; + const parsedItems = parsePersistedEvents(candidate.items); + return parsedItems; } function extractBatchOffset(value: unknown): string | null { - if (!value || typeof value !== "object") { - return null; - } + if (!value || typeof value !== "object") { + return null; + } - const candidate = value as Record; - if (typeof candidate.offset === "string" && candidate.offset.length > 0) { - return candidate.offset; - } + const candidate = value as Record; + if (typeof candidate.offset === "string" && candidate.offset.length > 0) { + return candidate.offset; + } - return null; + return null; } interface UseTaskEventStreamArgs { - taskId: string; - streamId: string | null; + taskId: string; + streamId: string | null; } export function useTaskEventStream(args: UseTaskEventStreamArgs): TaskStreamEvent[] { - const { taskId, streamId } = args; - const [runEvents, setRunEvents] = useState([]); + const { taskId, streamId } = args; + const [runEvents, setRunEvents] = useState([]); - useEffect(() => { - if (!streamId) { - return; - } - - const storageKey = getTaskStreamStorageKey(taskId, streamId); - const abortController = new AbortController(); - setRunEvents([]); - - async function connect(): Promise { - const persisted = await readTaskStreamCache(storageKey); - if (abortController.signal.aborted) { - return; - } - - let latestOffset = persisted.offset; - setRunEvents(trimPersistedEvents(persisted.events)); - - try { - const streamUrl = getTaskEventStreamUrl(taskId); - const res = await stream({ - url: streamUrl, - offset: latestOffset ?? "-1", - live: "sse", - json: true, - signal: abortController.signal, - }); - - if (abortController.signal.aborted) { - return; + useEffect(() => { + if (!streamId) { + return; } - res.subscribeJson((batch) => { - const items = extractBatchItems(batch); - const batchOffset = extractBatchOffset(batch); - if (batchOffset !== null) { - latestOffset = batchOffset; - } - - setRunEvents((previousEvents) => { - const mergedEvents = appendUniqueEvents(previousEvents, items); - const trimmedEvents = trimPersistedEvents(mergedEvents); - void writeTaskStreamCache(storageKey, latestOffset, trimmedEvents); - return trimmedEvents; - }); - }); - } catch (error: unknown) { - if (isAbortError(error)) { - return; + const storageKey = getTaskStreamStorageKey(taskId, streamId); + const abortController = new AbortController(); + setRunEvents([]); + + async function connect(): Promise { + const persisted = await readTaskStreamCache(storageKey); + if (abortController.signal.aborted) { + return; + } + + let latestOffset = persisted.offset; + setRunEvents(trimPersistedEvents(persisted.events)); + + try { + const streamUrl = getTaskEventStreamUrl(taskId); + const res = await stream({ + url: streamUrl, + offset: latestOffset ?? "-1", + live: "sse", + json: true, + signal: abortController.signal, + }); + + if (abortController.signal.aborted) { + return; + } + + res.subscribeJson((batch) => { + const items = extractBatchItems(batch); + const batchOffset = extractBatchOffset(batch); + if (batchOffset !== null) { + latestOffset = batchOffset; + } + + setRunEvents((previousEvents) => { + const mergedEvents = appendUniqueEvents(previousEvents, items); + const trimmedEvents = trimPersistedEvents(mergedEvents); + void writeTaskStreamCache(storageKey, latestOffset, trimmedEvents); + return trimmedEvents; + }); + }); + } catch (error: unknown) { + if (isAbortError(error)) { + return; + } + + console.error("Failed to subscribe to task stream", error); + } } - console.error("Failed to subscribe to task stream", error); - } - } - - void connect(); + void connect(); - return () => { - abortController.abort(); - }; - }, [taskId, streamId]); + return () => { + abortController.abort(); + }; + }, [taskId, streamId]); - useEffect(() => { - if (streamId) { - return; - } + useEffect(() => { + if (streamId) { + return; + } - setRunEvents([]); - }, [taskId, streamId]); + setRunEvents([]); + }, [taskId, streamId]); - return runEvents; + return runEvents; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a5ef193..e6a8be0 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,5 +2,5 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } diff --git a/src/pages/login-page.tsx b/src/pages/login-page.tsx index 1d235f2..e962ee1 100644 --- a/src/pages/login-page.tsx +++ b/src/pages/login-page.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; import { Github, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { signIn } from "../lib/auth-client"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { signIn } from "../lib/auth-client"; export function LoginPage() { const [isSigningIn, setIsSigningIn] = useState(false); diff --git a/src/pages/pending-access-page.tsx b/src/pages/pending-access-page.tsx index 7597b5b..df2a3ad 100644 --- a/src/pages/pending-access-page.tsx +++ b/src/pages/pending-access-page.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; import { Github, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { signIn } from "../lib/auth-client"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; -import { signIn } from "../lib/auth-client"; export function PendingAccessPage() { const [isSigningIn, setIsSigningIn] = useState(false); diff --git a/src/pages/settings-page.tsx b/src/pages/settings-page.tsx index 539b73b..b7b980b 100644 --- a/src/pages/settings-page.tsx +++ b/src/pages/settings-page.tsx @@ -1,14 +1,14 @@ -import { useState } from "react"; -import { useNavigate, useSearch } from "@tanstack/react-router"; import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { BookMarked, Loader2, Plus } from "lucide-react"; +import { useState } from "react"; +import { AddProjectDialog } from "../components/add-project-dialog"; +import { useOrganization } from "../components/layout/use-organization"; +import { projectsCollection } from "../lib/collections"; import { ThemeToggle } from "@/components/theme-toggle"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; -import { AddProjectDialog } from "../components/add-project-dialog"; -import { useOrganization } from "../components/layout/use-organization"; -import { projectsCollection } from "../lib/collections"; import { updateProjectRunCommand, updateProjectSetupCommand } from "@/server/functions/projects"; function formatMsTimestamp(msTimestamp: bigint): string { diff --git a/src/pages/task-page.tsx b/src/pages/task-page.tsx index 05abe79..d0e0327 100644 --- a/src/pages/task-page.tsx +++ b/src/pages/task-page.tsx @@ -1,19 +1,15 @@ -import { useEffect, useRef, useState } from "react"; import { useLiveQuery, eq } from "@tanstack/react-db"; import { AlertCircle, Loader2 } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { taskMessagesCollection, tasksCollection } from "../lib/collections"; +import { useTaskEventStream } from "../lib/use-task-event-stream"; import { TaskPageCodeView } from "@/components/task-page-code-view"; import { TaskPageHeader } from "@/components/task-page-header"; -import { TaskPageMessageList } from "@/components/task-page-message-list"; import { TaskPageInput } from "@/components/task-page-input"; +import { TaskPageMessageList } from "@/components/task-page-message-list"; import { promptDesktopRunnerTask } from "@/lib/desktop-runner"; import { failTaskRun } from "@/lib/fail-task-run"; import { isDesktopApp } from "@/lib/is-desktop-app"; -import { - localStorageKeys, - sessionStateKeys, - useLocalStorageState, - useSessionState, -} from "@/lib/session-state"; import { useRunnerDiff } from "@/lib/runner-diffs"; import { getDefaultRunnerModelSelection, @@ -21,6 +17,12 @@ import { isRunnerModelSelectionAvailable, useRunnerModels, } from "@/lib/runner-models"; +import { + localStorageKeys, + sessionStateKeys, + useLocalStorageState, + useSessionState, +} from "@/lib/session-state"; import { buildChronologicalTimeline, buildTaskStreamActivityItems, @@ -29,8 +31,6 @@ import { getLatestUserMessageCreatedAt, } from "@/lib/task-timeline"; import { startTaskRun } from "@/server/functions/task-runs"; -import { taskMessagesCollection, tasksCollection } from "../lib/collections"; -import { useTaskEventStream } from "../lib/use-task-event-stream"; const CREATE_PR_MESSAGE = "Create a PR for me"; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index a2ed160..04f960e 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,11 +1,12 @@ +import { HotkeysProvider } from "@tanstack/react-hotkeys"; /// import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router"; -import { HotkeysProvider } from "@tanstack/react-hotkeys"; -import type { ReactNode } from "react"; import { ThemeProvider } from "@/components/theme-provider"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { themeInitializationScript } from "@/lib/theme"; import appCss from "@/index.css?url"; +import { themeInitializationScript } from "@/lib/theme"; + +import type { ReactNode } from "react"; export const Route = createRootRoute({ head: () => ({ diff --git a/src/routes/_layout.index.tsx b/src/routes/_layout.index.tsx index b797b90..c553ca1 100644 --- a/src/routes/_layout.index.tsx +++ b/src/routes/_layout.index.tsx @@ -1,7 +1,7 @@ -import { useEffect } from "react"; import { useLiveQuery } from "@tanstack/react-db"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { Loader2 } from "lucide-react"; +import { useEffect } from "react"; import { NewTaskButton } from "@/components/new-task-button"; import { projectsCollection, pullRequestsCollection, tasksCollection } from "@/lib/collections"; import { getFirstSidebarTaskId } from "@/lib/task-sidebar"; diff --git a/src/routes/_layout.tasks.$taskId.tsx b/src/routes/_layout.tasks.$taskId.tsx index d87d082..fd5abed 100644 --- a/src/routes/_layout.tasks.$taskId.tsx +++ b/src/routes/_layout.tasks.$taskId.tsx @@ -1,6 +1,5 @@ -import { Navigate, createFileRoute } from "@tanstack/react-router"; -import { TaskPage } from "@/pages/task-page"; import { and, eq, useLiveQuery } from "@tanstack/react-db"; +import { Navigate, createFileRoute } from "@tanstack/react-router"; import { projectsCollection, pullRequestsCollection, @@ -8,6 +7,7 @@ import { tasksCollection, } from "@/lib/collections"; import { extractOrgRepoFromUrl, getPullRequestStatus } from "@/lib/pull-request"; +import { TaskPage } from "@/pages/task-page"; export const Route = createFileRoute("/_layout/tasks/$taskId")({ loader: () => { diff --git a/src/routes/api/auth/$.ts b/src/routes/api/auth/$.ts index 6f674e1..a73d6d9 100644 --- a/src/routes/api/auth/$.ts +++ b/src/routes/api/auth/$.ts @@ -3,18 +3,18 @@ import { createAuth } from "@/server/auth"; import { getEnv } from "@/server/env"; export const Route = createFileRoute("/api/auth/$")({ - server: { - handlers: { - GET: async ({ request }: { request: Request }) => { - const env = getEnv(); - const auth = createAuth(env, request); - return auth.handler(request); - }, - POST: async ({ request }: { request: Request }) => { - const env = getEnv(); - const auth = createAuth(env, request); - return auth.handler(request); - }, + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + const env = getEnv(); + const auth = createAuth(env, request); + return auth.handler(request); + }, + POST: async ({ request }: { request: Request }) => { + const env = getEnv(); + const auth = createAuth(env, request); + return auth.handler(request); + }, + }, }, - }, }); diff --git a/src/routes/api/internal/task-runs/$executionId/branch.ts b/src/routes/api/internal/task-runs/$executionId/branch.ts index 079c708..e986725 100644 --- a/src/routes/api/internal/task-runs/$executionId/branch.ts +++ b/src/routes/api/internal/task-runs/$executionId/branch.ts @@ -6,51 +6,60 @@ import { getEnv } from "@/server/env"; import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; export const Route = createFileRoute("/api/internal/task-runs/$executionId/branch")({ - server: { - handlers: { - POST: async ({ request, params }: { request: Request; params: { executionId: string } }) => { - const callback = verifyTaskRunCallback(request, params.executionId); - if (!callback) { - return Response.json({ error: "Invalid callback token" }, { status: 401 }); - } + server: { + handlers: { + POST: async ({ + request, + params, + }: { + request: Request; + params: { executionId: string }; + }) => { + const callback = verifyTaskRunCallback(request, params.executionId); + if (!callback) { + return Response.json({ error: "Invalid callback token" }, { status: 401 }); + } - let body: { branch?: string | null }; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } + let body: { branch?: string | null }; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } - let branch: string | null = null; - if (body.branch !== null && body.branch !== undefined) { - if (typeof body.branch !== "string") { - return Response.json({ error: "branch must be a string or null" }, { status: 400 }); - } + let branch: string | null = null; + if (body.branch !== null && body.branch !== undefined) { + if (typeof body.branch !== "string") { + return Response.json( + { error: "branch must be a string or null" }, + { status: 400 }, + ); + } - const normalizedBranch = body.branch.trim(); - if (normalizedBranch.length > 255) { - return Response.json( - { error: "branch must be at most 255 characters" }, - { status: 400 }, - ); - } + const normalizedBranch = body.branch.trim(); + if (normalizedBranch.length > 255) { + return Response.json( + { error: "branch must be at most 255 characters" }, + { status: 400 }, + ); + } - branch = normalizedBranch.length > 0 ? normalizedBranch : null; - } + branch = normalizedBranch.length > 0 ? normalizedBranch : null; + } - const db = getDb(getEnv()); - await db - .update(schema.tasks) - .set({ branch, updatedAt: Date.now() }) - .where( - and( - eq(schema.tasks.id, callback.taskId), - eq(schema.tasks.organizationId, callback.organizationId), - ), - ); + const db = getDb(getEnv()); + await db + .update(schema.tasks) + .set({ branch, updatedAt: Date.now() }) + .where( + and( + eq(schema.tasks.id, callback.taskId), + eq(schema.tasks.organizationId, callback.organizationId), + ), + ); - return Response.json({ ok: true }); - }, + return Response.json({ ok: true }); + }, + }, }, - }, }); diff --git a/src/routes/api/internal/task-runs/$executionId/complete.ts b/src/routes/api/internal/task-runs/$executionId/complete.ts index d081116..a40bbd1 100644 --- a/src/routes/api/internal/task-runs/$executionId/complete.ts +++ b/src/routes/api/internal/task-runs/$executionId/complete.ts @@ -1,73 +1,81 @@ import { createFileRoute } from "@tanstack/react-router"; import { getDb } from "@/server/db/client"; import { getEnv } from "@/server/env"; -import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; -import { completeTask, insertAssistantTaskMessage } from "@/server/lib/task-execution/helpers"; import { isSupportedOpencodeProvider, type SupportedOpencodeProvider } from "@/server/lib/opencode"; import { upsertProviderAuthCredential } from "@/server/lib/provider-credentials"; +import { completeTask, insertAssistantTaskMessage } from "@/server/lib/task-execution/helpers"; +import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; export const Route = createFileRoute("/api/internal/task-runs/$executionId/complete")({ - server: { - handlers: { - POST: async ({ request, params }: { request: Request; params: { executionId: string } }) => { - const callback = verifyTaskRunCallback(request, params.executionId); - if (!callback) { - return Response.json({ error: "Invalid callback token" }, { status: 401 }); - } + server: { + handlers: { + POST: async ({ + request, + params, + }: { + request: Request; + params: { executionId: string }; + }) => { + const callback = verifyTaskRunCallback(request, params.executionId); + if (!callback) { + return Response.json({ error: "Invalid callback token" }, { status: 401 }); + } - let body: { - assistantOutput?: string; - refreshedAuth?: { provider?: string; auth?: unknown }; - }; - try { - body = await request.json(); - } catch { - body = {}; - } + let body: { + assistantOutput?: string; + refreshedAuth?: { provider?: string; auth?: unknown }; + }; + try { + body = await request.json(); + } catch { + body = {}; + } - const env = getEnv(); - const db = getDb(env); + const env = getEnv(); + const db = getDb(env); - if ( - body.assistantOutput && - typeof body.assistantOutput === "string" && - body.assistantOutput.trim().length > 0 - ) { - try { - await insertAssistantTaskMessage({ - db, - organizationId: callback.organizationId, - taskId: callback.taskId, - content: body.assistantOutput.trim(), - }); - } catch (error) { - console.warn("Failed to persist assistant output on complete callback", { - executionId: params.executionId, - taskId: callback.taskId, - message: error instanceof Error ? error.message : String(error), - }); - } - } + if ( + body.assistantOutput && + typeof body.assistantOutput === "string" && + body.assistantOutput.trim().length > 0 + ) { + try { + await insertAssistantTaskMessage({ + db, + organizationId: callback.organizationId, + taskId: callback.taskId, + content: body.assistantOutput.trim(), + }); + } catch (error) { + console.warn("Failed to persist assistant output on complete callback", { + executionId: params.executionId, + taskId: callback.taskId, + message: error instanceof Error ? error.message : String(error), + }); + } + } - if (body.refreshedAuth?.provider && body.refreshedAuth.auth) { - const provider = String(body.refreshedAuth.provider); - if (isSupportedOpencodeProvider(provider)) { - try { - await upsertProviderAuthCredential( - db, - env, - callback.userId, - provider as SupportedOpencodeProvider, - body.refreshedAuth.auth as Parameters[4], - ); - } catch {} - } - } + if (body.refreshedAuth?.provider && body.refreshedAuth.auth) { + const provider = String(body.refreshedAuth.provider); + if (isSupportedOpencodeProvider(provider)) { + try { + await upsertProviderAuthCredential( + db, + env, + callback.userId, + provider as SupportedOpencodeProvider, + body.refreshedAuth.auth as Parameters< + typeof upsertProviderAuthCredential + >[4], + ); + } catch {} + } + } - await completeTask({ db, taskId: callback.taskId }); + await completeTask({ db, taskId: callback.taskId }); - return Response.json({ ok: true }); - }, + return Response.json({ ok: true }); + }, + }, }, - }, }); diff --git a/src/routes/api/internal/task-runs/$executionId/event.ts b/src/routes/api/internal/task-runs/$executionId/event.ts index 0880000..be3db2d 100644 --- a/src/routes/api/internal/task-runs/$executionId/event.ts +++ b/src/routes/api/internal/task-runs/$executionId/event.ts @@ -1,67 +1,74 @@ import { createFileRoute } from "@tanstack/react-router"; import { and, eq } from "drizzle-orm"; -import type { Event as OpenCodeEvent } from "@opencode-ai/sdk"; import { getDb } from "@/server/db/client"; import * as schema from "@/server/db/schema"; import { getEnv } from "@/server/env"; import { appendTaskEvent } from "@/server/lib/durable-streams"; import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; + import type { TaskStreamEvent } from "@/shared/task-stream-events"; +import type { Event as OpenCodeEvent } from "@opencode-ai/sdk"; export const Route = createFileRoute("/api/internal/task-runs/$executionId/event")({ - server: { - handlers: { - POST: async ({ request, params }: { request: Request; params: { executionId: string } }) => { - const callback = verifyTaskRunCallback(request, params.executionId); - if (!callback) { - return Response.json({ error: "Invalid callback token" }, { status: 401 }); - } + server: { + handlers: { + POST: async ({ + request, + params, + }: { + request: Request; + params: { executionId: string }; + }) => { + const callback = verifyTaskRunCallback(request, params.executionId); + if (!callback) { + return Response.json({ error: "Invalid callback token" }, { status: 401 }); + } - let body: { event?: OpenCodeEvent }; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } + let body: { event?: OpenCodeEvent }; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } - if (!body.event || typeof body.event.type !== "string") { - return Response.json({ error: "event is required" }, { status: 400 }); - } + if (!body.event || typeof body.event.type !== "string") { + return Response.json({ error: "event is required" }, { status: 400 }); + } - const env = getEnv(); - const db = getDb(env); - const task = await db.query.tasks.findFirst({ - where: and( - eq(schema.tasks.id, callback.taskId), - eq(schema.tasks.organizationId, callback.organizationId), - ), - columns: { - id: true, - streamId: true, - }, - }); + const env = getEnv(); + const db = getDb(env); + const task = await db.query.tasks.findFirst({ + where: and( + eq(schema.tasks.id, callback.taskId), + eq(schema.tasks.organizationId, callback.organizationId), + ), + columns: { + id: true, + streamId: true, + }, + }); - if (!task) { - return Response.json({ error: "Task not found" }, { status: 404 }); - } + if (!task) { + return Response.json({ error: "Task not found" }, { status: 404 }); + } - const streamEvent: TaskStreamEvent = { - id: crypto.randomUUID(), - taskId: task.id, - runId: params.executionId, - createdAt: Date.now(), - kind: `opencode.${body.event.type}`, - payload: JSON.stringify(body.event), - }; + const streamEvent: TaskStreamEvent = { + id: crypto.randomUUID(), + taskId: task.id, + runId: params.executionId, + createdAt: Date.now(), + kind: `opencode.${body.event.type}`, + payload: JSON.stringify(body.event), + }; - await appendTaskEvent({ - env, - event: streamEvent, - streamId: task.streamId, - }); + await appendTaskEvent({ + env, + event: streamEvent, + streamId: task.streamId, + }); - return Response.json({ ok: true }); - }, + return Response.json({ ok: true }); + }, + }, }, - }, }); diff --git a/src/routes/api/internal/task-runs/$executionId/fail.ts b/src/routes/api/internal/task-runs/$executionId/fail.ts index 481b8ad..83ddd32 100644 --- a/src/routes/api/internal/task-runs/$executionId/fail.ts +++ b/src/routes/api/internal/task-runs/$executionId/fail.ts @@ -1,35 +1,41 @@ import { createFileRoute } from "@tanstack/react-router"; import { getDb } from "@/server/db/client"; import { getEnv } from "@/server/env"; -import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; import { markTaskFailed } from "@/server/lib/task-execution/helpers"; +import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; export const Route = createFileRoute("/api/internal/task-runs/$executionId/fail")({ - server: { - handlers: { - POST: async ({ request, params }: { request: Request; params: { executionId: string } }) => { - const callback = verifyTaskRunCallback(request, params.executionId); - if (!callback) { - return Response.json({ error: "Invalid callback token" }, { status: 401 }); - } + server: { + handlers: { + POST: async ({ + request, + params, + }: { + request: Request; + params: { executionId: string }; + }) => { + const callback = verifyTaskRunCallback(request, params.executionId); + if (!callback) { + return Response.json({ error: "Invalid callback token" }, { status: 401 }); + } - let body: { error?: string }; - try { - body = await request.json(); - } catch { - body = {}; - } + let body: { error?: string }; + try { + body = await request.json(); + } catch { + body = {}; + } - const errorMessage = - typeof body.error === "string" && body.error.trim().length > 0 - ? body.error.trim() - : "Task failed (reported by runner)"; + const errorMessage = + typeof body.error === "string" && body.error.trim().length > 0 + ? body.error.trim() + : "Task failed (reported by runner)"; - const db = getDb(getEnv()); - await markTaskFailed({ db, taskId: callback.taskId, message: errorMessage }); + const db = getDb(getEnv()); + await markTaskFailed({ db, taskId: callback.taskId, message: errorMessage }); - return Response.json({ ok: true }); - }, + return Response.json({ ok: true }); + }, + }, }, - }, }); diff --git a/src/routes/api/internal/task-runs/$executionId/heartbeat.ts b/src/routes/api/internal/task-runs/$executionId/heartbeat.ts index cf70cb9..c7d4901 100644 --- a/src/routes/api/internal/task-runs/$executionId/heartbeat.ts +++ b/src/routes/api/internal/task-runs/$executionId/heartbeat.ts @@ -2,16 +2,22 @@ import { createFileRoute } from "@tanstack/react-router"; import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; export const Route = createFileRoute("/api/internal/task-runs/$executionId/heartbeat")({ - server: { - handlers: { - POST: async ({ request, params }: { request: Request; params: { executionId: string } }) => { - const callback = verifyTaskRunCallback(request, params.executionId); - if (!callback) { - return Response.json({ error: "Invalid callback token" }, { status: 401 }); - } + server: { + handlers: { + POST: async ({ + request, + params, + }: { + request: Request; + params: { executionId: string }; + }) => { + const callback = verifyTaskRunCallback(request, params.executionId); + if (!callback) { + return Response.json({ error: "Invalid callback token" }, { status: 401 }); + } - return Response.json({ ok: true }); - }, + return Response.json({ ok: true }); + }, + }, }, - }, }); diff --git a/src/routes/api/internal/task-runs/$executionId/message.ts b/src/routes/api/internal/task-runs/$executionId/message.ts index 44ff6b2..56df3e5 100644 --- a/src/routes/api/internal/task-runs/$executionId/message.ts +++ b/src/routes/api/internal/task-runs/$executionId/message.ts @@ -1,39 +1,49 @@ import { createFileRoute } from "@tanstack/react-router"; import { getDb } from "@/server/db/client"; import { getEnv } from "@/server/env"; -import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; import { insertAssistantTaskMessage } from "@/server/lib/task-execution/helpers"; +import { verifyTaskRunCallback } from "@/server/lib/task-run-callback"; export const Route = createFileRoute("/api/internal/task-runs/$executionId/message")({ - server: { - handlers: { - POST: async ({ request, params }: { request: Request; params: { executionId: string } }) => { - const callback = verifyTaskRunCallback(request, params.executionId); - if (!callback) { - return Response.json({ error: "Invalid callback token" }, { status: 401 }); - } + server: { + handlers: { + POST: async ({ + request, + params, + }: { + request: Request; + params: { executionId: string }; + }) => { + const callback = verifyTaskRunCallback(request, params.executionId); + if (!callback) { + return Response.json({ error: "Invalid callback token" }, { status: 401 }); + } - let body: { content?: string }; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body" }, { status: 400 }); - } + let body: { content?: string }; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } - if (!body.content || typeof body.content !== "string" || body.content.trim().length === 0) { - return Response.json({ error: "content is required" }, { status: 400 }); - } + if ( + !body.content || + typeof body.content !== "string" || + body.content.trim().length === 0 + ) { + return Response.json({ error: "content is required" }, { status: 400 }); + } - const db = getDb(getEnv()); - const taskMessageId = await insertAssistantTaskMessage({ - db, - organizationId: callback.organizationId, - taskId: callback.taskId, - content: body.content.trim(), - }); + const db = getDb(getEnv()); + const taskMessageId = await insertAssistantTaskMessage({ + db, + organizationId: callback.organizationId, + taskId: callback.taskId, + content: body.content.trim(), + }); - return Response.json({ id: taskMessageId }, { status: 201 }); - }, + return Response.json({ id: taskMessageId }, { status: 201 }); + }, + }, }, - }, }); diff --git a/src/routes/api/projects/shape.ts b/src/routes/api/projects/shape.ts index ef357a2..9a72c41 100644 --- a/src/routes/api/projects/shape.ts +++ b/src/routes/api/projects/shape.ts @@ -6,22 +6,22 @@ import { electricFn } from "@/server/lib/electric"; import { requireSession } from "@/server/requireSession"; export const Route = createFileRoute("/api/projects/shape")({ - server: { - handlers: { - GET: async ({ request }: { request: Request }) => { - const session = await requireSession(request); - const orgId = session.session.activeOrganizationId ?? null; + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + const session = await requireSession(request); + const orgId = session.session.activeOrganizationId ?? null; - if (!orgId) { - return Response.json({ error: "No active organization" }, { status: 400 }); - } + if (!orgId) { + return Response.json({ error: "No active organization" }, { status: 400 }); + } - return electricFn({ - request, - table: "projects", - where: clauseToString(eq(schema.projects.organizationId, orgId)), - }); - }, + return electricFn({ + request, + table: "projects", + where: clauseToString(eq(schema.projects.organizationId, orgId)), + }); + }, + }, }, - }, }); diff --git a/src/routes/api/pull-requests/shape.ts b/src/routes/api/pull-requests/shape.ts index e05b86b..803ea54 100644 --- a/src/routes/api/pull-requests/shape.ts +++ b/src/routes/api/pull-requests/shape.ts @@ -1,56 +1,58 @@ import { createFileRoute } from "@tanstack/react-router"; import { eq, inArray } from "drizzle-orm"; import { getDb } from "@/server/db/client"; -import { getEnv } from "@/server/env"; import * as schema from "@/server/db/schema"; +import { getEnv } from "@/server/env"; import { clauseToString } from "@/server/lib/clause-to-string"; import { electricFn } from "@/server/lib/electric"; import { requireSession } from "@/server/requireSession"; export const Route = createFileRoute("/api/pull-requests/shape")({ - server: { - handlers: { - GET: async ({ request }: { request: Request }) => { - const session = await requireSession(request); - const orgId = session.session.activeOrganizationId ?? null; + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + const session = await requireSession(request); + const orgId = session.session.activeOrganizationId ?? null; - if (!orgId) { - return Response.json({ error: "No active organization" }, { status: 400 }); - } + if (!orgId) { + return Response.json({ error: "No active organization" }, { status: 400 }); + } - const env = getEnv(); - const db = getDb(env); - const orgProjects = await db.query.projects.findMany({ - where: eq(schema.projects.organizationId, orgId), - columns: { installationId: true }, - }); - const installationIds = [ - ...new Set( - orgProjects - .map((project) => project.installationId) - .filter((id): id is number => id !== null), - ), - ]; + const env = getEnv(); + const db = getDb(env); + const orgProjects = await db.query.projects.findMany({ + where: eq(schema.projects.organizationId, orgId), + columns: { installationId: true }, + }); + const installationIds = [ + ...new Set( + orgProjects + .map((project) => project.installationId) + .filter((id): id is number => id !== null), + ), + ]; - if (installationIds.length === 0) { - return electricFn({ - request, - table: "pull_requests", - where: "1 = 0", - }); - } + if (installationIds.length === 0) { + return electricFn({ + request, + table: "pull_requests", + where: "1 = 0", + }); + } - const whereClause = - installationIds.length === 1 - ? clauseToString(eq(schema.pullRequests.installationId, installationIds[0])) - : clauseToString(inArray(schema.pullRequests.installationId, installationIds)); + const whereClause = + installationIds.length === 1 + ? clauseToString(eq(schema.pullRequests.installationId, installationIds[0])) + : clauseToString( + inArray(schema.pullRequests.installationId, installationIds), + ); - return electricFn({ - request, - table: "pull_requests", - where: whereClause, - }); - }, + return electricFn({ + request, + table: "pull_requests", + where: whereClause, + }); + }, + }, }, - }, }); diff --git a/src/routes/api/tasks/$taskId/stream.ts b/src/routes/api/tasks/$taskId/stream.ts index 0b18be5..1778817 100644 --- a/src/routes/api/tasks/$taskId/stream.ts +++ b/src/routes/api/tasks/$taskId/stream.ts @@ -1,106 +1,110 @@ import { createFileRoute } from "@tanstack/react-router"; import { and, eq } from "drizzle-orm"; import { getDb } from "@/server/db/client"; -import { getEnv } from "@/server/env"; import * as schema from "@/server/db/schema"; +import { getEnv } from "@/server/env"; import { openTaskEventsSse } from "@/server/lib/durable-streams"; import { requireSession } from "@/server/requireSession"; function isValidStreamOffset(value: string): boolean { - if (value === "-1" || value === "now") { - return true; - } + if (value === "-1" || value === "now") { + return true; + } - if (value.length === 0 || value.length > 512) { - return false; - } + if (value.length === 0 || value.length > 512) { + return false; + } - for (let i = 0; i < value.length; i++) { - const code = value.charCodeAt(i); - if ((code >= 0 && code <= 31) || code === 127) { - return false; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if ((code >= 0 && code <= 31) || code === 127) { + return false; + } } - } - return true; + return true; } export const Route = createFileRoute("/api/tasks/$taskId/stream")({ - server: { - handlers: { - GET: async ({ request, params }: { request: Request; params: { taskId: string } }) => { - const session = await requireSession(request); - const orgId = session.session.activeOrganizationId ?? null; + server: { + handlers: { + GET: async ({ request, params }: { request: Request; params: { taskId: string } }) => { + const session = await requireSession(request); + const orgId = session.session.activeOrganizationId ?? null; - if (!orgId) { - return Response.json({ error: "No active organization" }, { status: 400 }); - } + if (!orgId) { + return Response.json({ error: "No active organization" }, { status: 400 }); + } - const env = getEnv(); - const db = getDb(env); - const task = await db.query.tasks.findFirst({ - where: and(eq(schema.tasks.id, params.taskId), eq(schema.tasks.organizationId, orgId)), - columns: { id: true, streamId: true }, - }); + const env = getEnv(); + const db = getDb(env); + const task = await db.query.tasks.findFirst({ + where: and( + eq(schema.tasks.id, params.taskId), + eq(schema.tasks.organizationId, orgId), + ), + columns: { id: true, streamId: true }, + }); - if (!task) { - return Response.json({ error: "Task not found" }, { status: 404 }); - } + if (!task) { + return Response.json({ error: "Task not found" }, { status: 404 }); + } - const url = new URL(request.url); - const offset = url.searchParams.get("offset")?.trim() ?? "-1"; - if (!isValidStreamOffset(offset)) { - return Response.json( - { error: "offset must be -1, now, or a durable stream offset" }, - { status: 400 }, - ); - } + const url = new URL(request.url); + const offset = url.searchParams.get("offset")?.trim() ?? "-1"; + if (!isValidStreamOffset(offset)) { + return Response.json( + { error: "offset must be -1, now, or a durable stream offset" }, + { status: 400 }, + ); + } - try { - const upstream = await openTaskEventsSse({ - env, - streamId: task.streamId, - offset, - }); + try { + const upstream = await openTaskEventsSse({ + env, + streamId: task.streamId, + offset, + }); - if (!upstream.ok || !upstream.body) { - const details = (await upstream.text()).trim(); - const status: number = - upstream.status === 404 - ? 404 - : upstream.status >= 400 && upstream.status < 500 - ? 400 - : 502; - return Response.json( - { - error: `Failed to open durable task stream (${upstream.status} ${upstream.statusText})${ - details.length > 0 ? `: ${details}` : "" - }`, - }, - { status }, - ); - } + if (!upstream.ok || !upstream.body) { + const details = (await upstream.text()).trim(); + const status: number = + upstream.status === 404 + ? 404 + : upstream.status >= 400 && upstream.status < 500 + ? 400 + : 502; + return Response.json( + { + error: `Failed to open durable task stream (${upstream.status} ${upstream.statusText})${ + details.length > 0 ? `: ${details}` : "" + }`, + }, + { status }, + ); + } - return new Response(upstream.body, { - status: 200, - headers: { - "Content-Type": upstream.headers.get("content-type") ?? "text/event-stream", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - }, - }); - } catch (error) { - return Response.json( - { - error: - error instanceof Error && error.message.trim().length > 0 - ? error.message - : "Failed to connect to durable stream", + return new Response(upstream.body, { + status: 200, + headers: { + "Content-Type": + upstream.headers.get("content-type") ?? "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); + } catch (error) { + return Response.json( + { + error: + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Failed to connect to durable stream", + }, + { status: 502 }, + ); + } }, - { status: 502 }, - ); - } - }, + }, }, - }, }); diff --git a/src/routes/api/tasks/messages/shape.ts b/src/routes/api/tasks/messages/shape.ts index 6d823d0..ed2f8de 100644 --- a/src/routes/api/tasks/messages/shape.ts +++ b/src/routes/api/tasks/messages/shape.ts @@ -6,22 +6,22 @@ import { electricFn } from "@/server/lib/electric"; import { requireSession } from "@/server/requireSession"; export const Route = createFileRoute("/api/tasks/messages/shape")({ - server: { - handlers: { - GET: async ({ request }: { request: Request }) => { - const session = await requireSession(request); - const orgId = session.session.activeOrganizationId ?? null; + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + const session = await requireSession(request); + const orgId = session.session.activeOrganizationId ?? null; - if (!orgId) { - return Response.json({ error: "No active organization" }, { status: 400 }); - } + if (!orgId) { + return Response.json({ error: "No active organization" }, { status: 400 }); + } - return electricFn({ - request, - table: "task_messages", - where: clauseToString(eq(schema.taskMessages.organizationId, orgId)), - }); - }, + return electricFn({ + request, + table: "task_messages", + where: clauseToString(eq(schema.taskMessages.organizationId, orgId)), + }); + }, + }, }, - }, }); diff --git a/src/routes/api/tasks/shape.ts b/src/routes/api/tasks/shape.ts index faa1af1..ebd1839 100644 --- a/src/routes/api/tasks/shape.ts +++ b/src/routes/api/tasks/shape.ts @@ -6,22 +6,22 @@ import { electricFn } from "@/server/lib/electric"; import { requireSession } from "@/server/requireSession"; export const Route = createFileRoute("/api/tasks/shape")({ - server: { - handlers: { - GET: async ({ request }: { request: Request }) => { - const session = await requireSession(request); - const orgId = session.session.activeOrganizationId ?? null; + server: { + handlers: { + GET: async ({ request }: { request: Request }) => { + const session = await requireSession(request); + const orgId = session.session.activeOrganizationId ?? null; - if (!orgId) { - return Response.json({ error: "No active organization" }, { status: 400 }); - } + if (!orgId) { + return Response.json({ error: "No active organization" }, { status: 400 }); + } - return electricFn({ - request, - table: "tasks", - where: clauseToString(eq(schema.tasks.organizationId, orgId)), - }); - }, + return electricFn({ + request, + table: "tasks", + where: clauseToString(eq(schema.tasks.organizationId, orgId)), + }); + }, + }, }, - }, }); diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 8111d55..ea948c0 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -1,6 +1,6 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { LoginPage } from "@/pages/login-page"; import { authClient } from "@/lib/auth-client"; +import { LoginPage } from "@/pages/login-page"; export const Route = createFileRoute("/login")({ beforeLoad: async () => { diff --git a/src/routes/webhook.ts b/src/routes/webhook.ts index 79a5efa..c04525b 100644 --- a/src/routes/webhook.ts +++ b/src/routes/webhook.ts @@ -1,5 +1,5 @@ -import { createFileRoute } from "@tanstack/react-router"; import { Webhooks } from "@octokit/webhooks"; +import { createFileRoute } from "@tanstack/react-router"; import { getDb } from "@/server/db/client"; import { getEnv } from "@/server/env"; import { handleCheckRun } from "@/server/webhook/github/check-run"; @@ -10,72 +10,72 @@ import { handlePullRequest } from "@/server/webhook/github/pull-request"; import { handlePullRequestReview } from "@/server/webhook/github/pull-request-review"; function createWebhooks(secret: string, db: ReturnType): Webhooks { - const webhooks = new Webhooks({ secret }); + const webhooks = new Webhooks({ secret }); - webhooks.on("pull_request", async (event) => { - await handlePullRequest(event, db); - }); + webhooks.on("pull_request", async (event) => { + await handlePullRequest(event, db); + }); - webhooks.on("pull_request_review", async (event) => { - await handlePullRequestReview(event, db); - }); + webhooks.on("pull_request_review", async (event) => { + await handlePullRequestReview(event, db); + }); - webhooks.on("check_suite", async (event) => { - await handleCheckSuite(event, db); - }); + webhooks.on("check_suite", async (event) => { + await handleCheckSuite(event, db); + }); - webhooks.on("check_run", async (event) => { - await handleCheckRun(event, db); - }); + webhooks.on("check_run", async (event) => { + await handleCheckRun(event, db); + }); - webhooks.on("installation", async (event) => { - await handleInstallation(event, db); - }); + webhooks.on("installation", async (event) => { + await handleInstallation(event, db); + }); - webhooks.on("ping", async (event) => { - await handlePing(event); - }); + webhooks.on("ping", async (event) => { + await handlePing(event); + }); - return webhooks; + return webhooks; } export const Route = createFileRoute("/webhook")({ - server: { - handlers: { - POST: async ({ request }: { request: Request }) => { - try { - const env = getEnv(); - const signature = request.headers.get("x-hub-signature-256"); - const event = request.headers.get("x-github-event"); - const delivery = request.headers.get("x-github-delivery"); - const body = await request.text(); + server: { + handlers: { + POST: async ({ request }: { request: Request }) => { + try { + const env = getEnv(); + const signature = request.headers.get("x-hub-signature-256"); + const event = request.headers.get("x-github-event"); + const delivery = request.headers.get("x-github-delivery"); + const body = await request.text(); - if (!signature || !event || !delivery) { - return new Response("Missing required headers", { status: 400 }); - } + if (!signature || !event || !delivery) { + return new Response("Missing required headers", { status: 400 }); + } - console.log(`Received GitHub event: ${event} (${delivery})`); + console.log(`Received GitHub event: ${event} (${delivery})`); - const db = getDb(env); - const secret = env.GITHUB_WEBHOOK_SECRET; + const db = getDb(env); + const secret = env.GITHUB_WEBHOOK_SECRET; - const webhooks = createWebhooks(secret, db); - await webhooks.verifyAndReceive({ - id: delivery, - name: event, - signature, - payload: body, - }); + const webhooks = createWebhooks(secret, db); + await webhooks.verifyAndReceive({ + id: delivery, + name: event, + signature, + payload: body, + }); - return Response.json({ message: "Event processed" }); - } catch (error) { - console.error("Webhook error:", error); - if (error instanceof Error && error.message.includes("signature")) { - return new Response("Invalid signature", { status: 401 }); - } - return new Response("Internal server error", { status: 500 }); - } - }, + return Response.json({ message: "Event processed" }); + } catch (error) { + console.error("Webhook error:", error); + if (error instanceof Error && error.message.includes("signature")) { + return new Response("Invalid signature", { status: 401 }); + } + return new Response("Internal server error", { status: 500 }); + } + }, + }, }, - }, }); diff --git a/src/server/auth.ts b/src/server/auth.ts index 9dc72cb..2d2de29 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,11 +1,11 @@ import { betterAuth } from "better-auth"; -import { APIError } from "better-auth/api"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { APIError } from "better-auth/api"; import { organization } from "better-auth/plugins"; import { oAuthProxy } from "better-auth/plugins/oauth-proxy"; import { eq } from "drizzle-orm"; -import * as schema from "./db/schema"; import { getDb } from "./db/client"; +import * as schema from "./db/schema"; import { ensureDefaultOrganizationForUser } from "./lib/ensure-default-organization"; import { USER_ACCESS_STATUS, isApprovedAccessStatus } from "./lib/user-access"; @@ -13,167 +13,173 @@ const PRODUCTION_URL = "https://www.clanki.ai"; const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]); function resolveOrigin(request: Request): string { - const requestOrigin = new URL(request.url).origin; - const originHeader = request.headers.get("origin"); - - let origin: string; - if (originHeader) { - try { - origin = isLocalOrigin(new URL(originHeader).origin) - ? new URL(originHeader).origin - : requestOrigin; - } catch { - origin = requestOrigin; + const requestOrigin = new URL(request.url).origin; + const originHeader = request.headers.get("origin"); + + let origin: string; + if (originHeader) { + try { + origin = isLocalOrigin(new URL(originHeader).origin) + ? new URL(originHeader).origin + : requestOrigin; + } catch { + origin = requestOrigin; + } + } else { + origin = requestOrigin; + } + + const proto = request.headers.get("x-forwarded-proto"); + if (proto) { + const url = new URL(origin); + url.protocol = `${proto}:`; + return url.origin; } - } else { - origin = requestOrigin; - } - - const proto = request.headers.get("x-forwarded-proto"); - if (proto) { - const url = new URL(origin); - url.protocol = `${proto}:`; - return url.origin; - } - - return origin; + + return origin; } function isLocalOrigin(origin: string): boolean { - return LOCAL_HOSTNAMES.has(new URL(origin).hostname); + return LOCAL_HOSTNAMES.has(new URL(origin).hostname); } function isCallbackPath(path: string): boolean { - return path.startsWith("/callback") || path.startsWith("/oauth2/callback"); + return path.startsWith("/callback") || path.startsWith("/oauth2/callback"); } type AuthEnv = { - DATABASE_URL?: string; - ENVIRONMENT?: string; - BETTER_AUTH_SECRET?: string; - GITHUB_CLIENT_ID?: string; - GITHUB_CLIENT_SECRET?: string; + DATABASE_URL?: string; + ENVIRONMENT?: string; + BETTER_AUTH_SECRET?: string; + GITHUB_CLIENT_ID?: string; + GITHUB_CLIENT_SECRET?: string; }; export function createAuth(env: AuthEnv, request: Request) { - if (!env.BETTER_AUTH_SECRET) { - throw new Error("Missing BETTER_AUTH_SECRET"); - } - if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) { - throw new Error("Missing GITHUB_CLIENT_ID or GITHUB_CLIENT_SECRET"); - } - - const origin = resolveOrigin(request); - const isLocal = isLocalOrigin(origin); - const githubRedirectURI = isLocal - ? `${origin}/api/auth/callback/github` - : `${PRODUCTION_URL}/api/auth/callback/github`; - const db = getDb(env); - - const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: "pg", - schema, - }), - secret: env.BETTER_AUTH_SECRET, - baseURL: origin, - user: { - additionalFields: { - accessStatus: { - type: "string", - required: false, - input: false, - returned: false, - fieldName: "access_status", - defaultValue: USER_ACCESS_STATUS.pending, + if (!env.BETTER_AUTH_SECRET) { + throw new Error("Missing BETTER_AUTH_SECRET"); + } + if (!env.GITHUB_CLIENT_ID || !env.GITHUB_CLIENT_SECRET) { + throw new Error("Missing GITHUB_CLIENT_ID or GITHUB_CLIENT_SECRET"); + } + + const origin = resolveOrigin(request); + const isLocal = isLocalOrigin(origin); + const githubRedirectURI = isLocal + ? `${origin}/api/auth/callback/github` + : `${PRODUCTION_URL}/api/auth/callback/github`; + const db = getDb(env); + + const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", + schema, + }), + secret: env.BETTER_AUTH_SECRET, + baseURL: origin, + user: { + additionalFields: { + accessStatus: { + type: "string", + required: false, + input: false, + returned: false, + fieldName: "access_status", + defaultValue: USER_ACCESS_STATUS.pending, + }, + }, }, - }, - }, - socialProviders: { - github: { - clientId: env.GITHUB_CLIENT_ID, - clientSecret: env.GITHUB_CLIENT_SECRET, - redirectURI: githubRedirectURI, - }, - }, - plugins: isLocal - ? [organization()] - : [ - oAuthProxy({ - productionURL: PRODUCTION_URL, - }), - organization(), - ], - trustedOrigins: [origin, "https://localhost:5173", "https://127.0.0.1:5173"], - databaseHooks: { - session: { - create: { - before: async (session, ctx) => { - const user = await db.query.user.findFirst({ - where: eq(schema.user.id, session.userId), - columns: { - id: true, - name: true, - email: true, - accessStatus: true, - }, - }); - - if (!user) { - return; - } - - if (!isApprovedAccessStatus(user.accessStatus)) { - if (ctx && isCallbackPath(ctx.path)) { - const pendingAccessUrl = new URL("/pending-access", origin).toString(); - throw ctx.redirect(pendingAccessUrl); - } - - throw new APIError("FORBIDDEN", { - message: "Your account is pending approval.", - code: "USER_PENDING_APPROVAL", - }); - } - - await ensureDefaultOrganizationForUser({ auth, db, user }); - - if (session.activeOrganizationId) return; - const members = await db - .select({ organizationId: schema.member.organizationId }) - .from(schema.member) - .where(eq(schema.member.userId, session.userId)) - .limit(1); - if (members.length > 0) { - return { - data: { ...session, activeOrganizationId: members[0].organizationId }, - }; - } - }, + socialProviders: { + github: { + clientId: env.GITHUB_CLIENT_ID, + clientSecret: env.GITHUB_CLIENT_SECRET, + redirectURI: githubRedirectURI, + }, }, - }, - user: { - create: { - after: async (user) => { - const createdUser = await db.query.user.findFirst({ - where: eq(schema.user.id, user.id), - columns: { - id: true, - name: true, - email: true, - accessStatus: true, - }, - }); - - if (!createdUser || !isApprovedAccessStatus(createdUser.accessStatus)) { - return; - } - - await ensureDefaultOrganizationForUser({ auth, db, user: createdUser }); - }, + plugins: isLocal + ? [organization()] + : [ + oAuthProxy({ + productionURL: PRODUCTION_URL, + }), + organization(), + ], + trustedOrigins: [origin, "https://localhost:5173", "https://127.0.0.1:5173"], + databaseHooks: { + session: { + create: { + before: async (session, ctx) => { + const user = await db.query.user.findFirst({ + where: eq(schema.user.id, session.userId), + columns: { + id: true, + name: true, + email: true, + accessStatus: true, + }, + }); + + if (!user) { + return; + } + + if (!isApprovedAccessStatus(user.accessStatus)) { + if (ctx && isCallbackPath(ctx.path)) { + const pendingAccessUrl = new URL( + "/pending-access", + origin, + ).toString(); + throw ctx.redirect(pendingAccessUrl); + } + + throw new APIError("FORBIDDEN", { + message: "Your account is pending approval.", + code: "USER_PENDING_APPROVAL", + }); + } + + await ensureDefaultOrganizationForUser({ auth, db, user }); + + if (session.activeOrganizationId) return; + const members = await db + .select({ organizationId: schema.member.organizationId }) + .from(schema.member) + .where(eq(schema.member.userId, session.userId)) + .limit(1); + if (members.length > 0) { + return { + data: { + ...session, + activeOrganizationId: members[0].organizationId, + }, + }; + } + }, + }, + }, + user: { + create: { + after: async (user) => { + const createdUser = await db.query.user.findFirst({ + where: eq(schema.user.id, user.id), + columns: { + id: true, + name: true, + email: true, + accessStatus: true, + }, + }); + + if (!createdUser || !isApprovedAccessStatus(createdUser.accessStatus)) { + return; + } + + await ensureDefaultOrganizationForUser({ auth, db, user: createdUser }); + }, + }, + }, }, - }, - }, - }); + }); - return auth; + return auth; } diff --git a/src/server/db/client.ts b/src/server/db/client.ts index 3cfbe89..90d3a0e 100644 --- a/src/server/db/client.ts +++ b/src/server/db/client.ts @@ -5,45 +5,45 @@ import * as schema from "./schema"; export type AppDb = PostgresJsDatabase; type DbEnv = { - DATABASE_URL?: string; + DATABASE_URL?: string; }; type DbClientCacheEntry = { - url: string; - db: AppDb; + url: string; + db: AppDb; }; type GlobalWithDbCache = typeof globalThis & { - __clankiDbClientCache?: DbClientCacheEntry; + __clankiDbClientCache?: DbClientCacheEntry; }; const globalWithDbCache = globalThis as GlobalWithDbCache; function createDb(url: string): AppDb { - const sql = postgres(url, { - fetch_types: false, - prepare: false, - // Keep per-runtime connection usage predictable in serverless deployments. - max: 1, - idle_timeout: 20, - connect_timeout: 10, - }); - - return drizzle({ client: sql, schema }); + const sql = postgres(url, { + fetch_types: false, + prepare: false, + // Keep per-runtime connection usage predictable in serverless deployments. + max: 1, + idle_timeout: 20, + connect_timeout: 10, + }); + + return drizzle({ client: sql, schema }); } export function getDb(env: DbEnv): AppDb { - const url = env.DATABASE_URL; - if (!url) { - throw new Error("Database connection string is missing"); - } - - const cached = globalWithDbCache.__clankiDbClientCache; - if (cached && cached.url === url) { - return cached.db; - } - - const db = createDb(url); - globalWithDbCache.__clankiDbClientCache = { url, db }; - return db; + const url = env.DATABASE_URL; + if (!url) { + throw new Error("Database connection string is missing"); + } + + const cached = globalWithDbCache.__clankiDbClientCache; + if (cached && cached.url === url) { + return cached.db; + } + + const db = createDb(url); + globalWithDbCache.__clankiDbClientCache = { url, db }; + return db; } diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 5f2cc56..af8bec9 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,12 +1,12 @@ import { - bigint, - boolean, - index, - integer, - pgTable, - text, - timestamp, - uniqueIndex, + bigint, + boolean, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, } from "drizzle-orm/pg-core"; const msTimestamp = (name: string) => bigint(name, { mode: "number" }); @@ -16,55 +16,55 @@ const msTimestamp = (name: string) => bigint(name, { mode: "number" }); // --------------------------------------------------------------------------- export const user = pgTable("user", { - id: text("id").primaryKey(), - name: text("name").notNull(), - email: text("email").notNull().unique(), - emailVerified: boolean("emailVerified").notNull(), - image: text("image"), - accessStatus: text("access_status").notNull().default("pending"), - createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), - updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "date" }).notNull(), + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("emailVerified").notNull(), + image: text("image"), + accessStatus: text("access_status").notNull().default("pending"), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), + updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "date" }).notNull(), }); export const session = pgTable("session", { - id: text("id").primaryKey(), - expiresAt: timestamp("expiresAt", { withTimezone: true, mode: "date" }).notNull(), - token: text("token").notNull().unique(), - ipAddress: text("ipAddress"), - userAgent: text("userAgent"), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - activeOrganizationId: text("activeOrganizationId"), - createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), - updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "date" }).notNull(), + id: text("id").primaryKey(), + expiresAt: timestamp("expiresAt", { withTimezone: true, mode: "date" }).notNull(), + token: text("token").notNull().unique(), + ipAddress: text("ipAddress"), + userAgent: text("userAgent"), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + activeOrganizationId: text("activeOrganizationId"), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), + updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "date" }).notNull(), }); export const account = pgTable("account", { - id: text("id").primaryKey(), - accountId: text("accountId").notNull(), - providerId: text("providerId").notNull(), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - accessToken: text("accessToken"), - refreshToken: text("refreshToken"), - idToken: text("idToken"), - accessTokenExpiresAt: timestamp("accessTokenExpiresAt", { withTimezone: true, mode: "date" }), - refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", { withTimezone: true, mode: "date" }), - scope: text("scope"), - password: text("password"), - createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), - updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "date" }).notNull(), + id: text("id").primaryKey(), + accountId: text("accountId").notNull(), + providerId: text("providerId").notNull(), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("accessToken"), + refreshToken: text("refreshToken"), + idToken: text("idToken"), + accessTokenExpiresAt: timestamp("accessTokenExpiresAt", { withTimezone: true, mode: "date" }), + refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt", { withTimezone: true, mode: "date" }), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), + updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "date" }).notNull(), }); export const verification = pgTable("verification", { - id: text("id").primaryKey(), - identifier: text("identifier").notNull(), - value: text("value").notNull(), - expiresAt: timestamp("expiresAt", { withTimezone: true, mode: "date" }).notNull(), - createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }), - updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "date" }), + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expiresAt", { withTimezone: true, mode: "date" }).notNull(), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }), + updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "date" }), }); // --------------------------------------------------------------------------- @@ -72,23 +72,23 @@ export const verification = pgTable("verification", { // --------------------------------------------------------------------------- export const userProviderCredentials = pgTable( - "user_provider_credentials", - { - id: text("id").primaryKey(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - provider: text("provider").notNull(), - encryptedApiKey: text("encrypted_api_key").notNull(), - authType: text("auth_type").notNull().default("api"), - encryptedAuthJson: text("encrypted_auth_json"), - createdAt: msTimestamp("created_at").notNull(), - updatedAt: msTimestamp("updated_at").notNull(), - }, - (t) => [ - uniqueIndex("user_provider_unique").on(t.userId, t.provider), - index("user_provider_user").on(t.userId), - ], + "user_provider_credentials", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + provider: text("provider").notNull(), + encryptedApiKey: text("encrypted_api_key").notNull(), + authType: text("auth_type").notNull().default("api"), + encryptedAuthJson: text("encrypted_auth_json"), + createdAt: msTimestamp("created_at").notNull(), + updatedAt: msTimestamp("updated_at").notNull(), + }, + (t) => [ + uniqueIndex("user_provider_unique").on(t.userId, t.provider), + index("user_provider_user").on(t.userId), + ], ); // --------------------------------------------------------------------------- @@ -96,39 +96,39 @@ export const userProviderCredentials = pgTable( // --------------------------------------------------------------------------- export const organization = pgTable("organization", { - id: text("id").primaryKey(), - name: text("name").notNull(), - slug: text("slug").unique(), - logo: text("logo"), - createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), - metadata: text("metadata"), + id: text("id").primaryKey(), + name: text("name").notNull(), + slug: text("slug").unique(), + logo: text("logo"), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), + metadata: text("metadata"), }); export const member = pgTable("member", { - id: text("id").primaryKey(), - organizationId: text("organizationId") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - role: text("role").notNull().default("member"), - createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), + id: text("id").primaryKey(), + organizationId: text("organizationId") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + role: text("role").notNull().default("member"), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), }); export const invitation = pgTable("invitation", { - id: text("id").primaryKey(), - organizationId: text("organizationId") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - email: text("email").notNull(), - role: text("role"), - status: text("status").notNull().default("pending"), - expiresAt: timestamp("expiresAt", { withTimezone: true, mode: "date" }).notNull(), - inviterId: text("inviterId") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), + id: text("id").primaryKey(), + organizationId: text("organizationId") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: text("role"), + status: text("status").notNull().default("pending"), + expiresAt: timestamp("expiresAt", { withTimezone: true, mode: "date" }).notNull(), + inviterId: text("inviterId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "date" }).notNull(), }); // --------------------------------------------------------------------------- @@ -136,12 +136,12 @@ export const invitation = pgTable("invitation", { // --------------------------------------------------------------------------- export const installations = pgTable("installations", { - installationId: integer("installation_id").primaryKey(), - accountLogin: text("account_login").notNull(), - accountType: text("account_type").notNull(), - createdAt: msTimestamp("created_at").notNull(), - deletedAt: msTimestamp("deleted_at"), - updatedAt: msTimestamp("updated_at"), + installationId: integer("installation_id").primaryKey(), + accountLogin: text("account_login").notNull(), + accountType: text("account_type").notNull(), + createdAt: msTimestamp("created_at").notNull(), + deletedAt: msTimestamp("deleted_at"), + updatedAt: msTimestamp("updated_at"), }); // --------------------------------------------------------------------------- @@ -149,24 +149,24 @@ export const installations = pgTable("installations", { // --------------------------------------------------------------------------- export const projects = pgTable( - "projects", - { - id: text("id").primaryKey(), - organizationId: text("organization_id") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - name: text("name").notNull(), - repoUrl: text("repo_url"), - installationId: integer("installation_id").references(() => installations.installationId, { - onDelete: "set null", - }), - setupCommand: text("setup_command"), - runCommand: text("run_command"), - runPort: integer("run_port"), - createdAt: msTimestamp("created_at").notNull(), - updatedAt: msTimestamp("updated_at").notNull(), - }, - (t) => [index("project_org").on(t.organizationId, t.createdAt)], + "projects", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + name: text("name").notNull(), + repoUrl: text("repo_url"), + installationId: integer("installation_id").references(() => installations.installationId, { + onDelete: "set null", + }), + setupCommand: text("setup_command"), + runCommand: text("run_command"), + runPort: integer("run_port"), + createdAt: msTimestamp("created_at").notNull(), + updatedAt: msTimestamp("updated_at").notNull(), + }, + (t) => [index("project_org").on(t.organizationId, t.createdAt)], ); // --------------------------------------------------------------------------- @@ -174,49 +174,49 @@ export const projects = pgTable( // --------------------------------------------------------------------------- export const pullRequests = pgTable( - "pull_requests", - { - id: text("id").primaryKey(), - installationId: integer("installation_id") - .notNull() - .references(() => installations.installationId, { onDelete: "cascade" }), - repository: text("repository").notNull(), - branch: text("branch"), - prNumber: integer("pr_number").notNull(), - openedAt: msTimestamp("opened_at").notNull(), - mergedBy: text("merged_by"), - mergedAt: msTimestamp("merged_at"), - readyAt: msTimestamp("ready_at"), - state: text("state").notNull().default("open"), - reviewState: text("review_state"), - reviewUpdatedAt: msTimestamp("review_updated_at"), - checksCount: integer("checks_count"), - checksCompletedCount: integer("checks_completed_count"), - checksState: text("checks_state"), - checksConclusion: text("checks_conclusion"), - checksUpdatedAt: msTimestamp("checks_updated_at"), - }, - (t) => [ - uniqueIndex("pr_repo_number").on(t.repository, t.prNumber), - index("pr_installation").on(t.installationId), - ], + "pull_requests", + { + id: text("id").primaryKey(), + installationId: integer("installation_id") + .notNull() + .references(() => installations.installationId, { onDelete: "cascade" }), + repository: text("repository").notNull(), + branch: text("branch"), + prNumber: integer("pr_number").notNull(), + openedAt: msTimestamp("opened_at").notNull(), + mergedBy: text("merged_by"), + mergedAt: msTimestamp("merged_at"), + readyAt: msTimestamp("ready_at"), + state: text("state").notNull().default("open"), + reviewState: text("review_state"), + reviewUpdatedAt: msTimestamp("review_updated_at"), + checksCount: integer("checks_count"), + checksCompletedCount: integer("checks_completed_count"), + checksState: text("checks_state"), + checksConclusion: text("checks_conclusion"), + checksUpdatedAt: msTimestamp("checks_updated_at"), + }, + (t) => [ + uniqueIndex("pr_repo_number").on(t.repository, t.prNumber), + index("pr_installation").on(t.installationId), + ], ); export const pullRequestCheckRuns = pgTable( - "pull_request_check_runs", - { - id: text("id").primaryKey(), - repository: text("repository").notNull(), - prNumber: integer("pr_number").notNull(), - checkRunId: text("check_run_id").notNull(), - status: text("status").notNull(), - conclusion: text("conclusion"), - updatedAt: msTimestamp("updated_at").notNull(), - }, - (t) => [ - uniqueIndex("pr_check_run_unique").on(t.repository, t.prNumber, t.checkRunId), - index("pr_check_run_lookup").on(t.repository, t.prNumber), - ], + "pull_request_check_runs", + { + id: text("id").primaryKey(), + repository: text("repository").notNull(), + prNumber: integer("pr_number").notNull(), + checkRunId: text("check_run_id").notNull(), + status: text("status").notNull(), + conclusion: text("conclusion"), + updatedAt: msTimestamp("updated_at").notNull(), + }, + (t) => [ + uniqueIndex("pr_check_run_unique").on(t.repository, t.prNumber, t.checkRunId), + index("pr_check_run_lookup").on(t.repository, t.prNumber), + ], ); // --------------------------------------------------------------------------- @@ -224,25 +224,25 @@ export const pullRequestCheckRuns = pgTable( // --------------------------------------------------------------------------- export const tasks = pgTable( - "tasks", - { - id: text("id").primaryKey(), - organizationId: text("organization_id") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }), - title: text("title").notNull(), - status: text("status").notNull().default("open"), - branch: text("branch"), - runnerType: text("runner_type"), - runnerSessionId: text("runner_session_id"), - streamId: text("stream_id").notNull(), - workspacePath: text("workspace_path"), - error: text("error"), - createdAt: msTimestamp("created_at").notNull(), - updatedAt: msTimestamp("updated_at").notNull(), - }, - (t) => [index("task_org").on(t.organizationId, t.createdAt)], + "tasks", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + projectId: text("project_id").references(() => projects.id, { onDelete: "set null" }), + title: text("title").notNull(), + status: text("status").notNull().default("open"), + branch: text("branch"), + runnerType: text("runner_type"), + runnerSessionId: text("runner_session_id"), + streamId: text("stream_id").notNull(), + workspacePath: text("workspace_path"), + error: text("error"), + createdAt: msTimestamp("created_at").notNull(), + updatedAt: msTimestamp("updated_at").notNull(), + }, + (t) => [index("task_org").on(t.organizationId, t.createdAt)], ); // --------------------------------------------------------------------------- @@ -250,21 +250,21 @@ export const tasks = pgTable( // --------------------------------------------------------------------------- export const taskMessages = pgTable( - "task_messages", - { - id: text("id").primaryKey(), - organizationId: text("organization_id") - .notNull() - .references(() => organization.id, { onDelete: "cascade" }), - taskId: text("task_id") - .notNull() - .references(() => tasks.id, { onDelete: "cascade" }), - role: text("role").notNull(), - content: text("content").notNull(), - createdAt: msTimestamp("created_at").notNull(), - }, - (t) => [ - index("task_message_org").on(t.organizationId, t.createdAt), - index("task_message_task").on(t.taskId, t.createdAt), - ], + "task_messages", + { + id: text("id").primaryKey(), + organizationId: text("organization_id") + .notNull() + .references(() => organization.id, { onDelete: "cascade" }), + taskId: text("task_id") + .notNull() + .references(() => tasks.id, { onDelete: "cascade" }), + role: text("role").notNull(), + content: text("content").notNull(), + createdAt: msTimestamp("created_at").notNull(), + }, + (t) => [ + index("task_message_org").on(t.organizationId, t.createdAt), + index("task_message_task").on(t.taskId, t.createdAt), + ], ); diff --git a/src/server/db/transaction.ts b/src/server/db/transaction.ts index 2eb91ab..3ed0619 100644 --- a/src/server/db/transaction.ts +++ b/src/server/db/transaction.ts @@ -1,41 +1,42 @@ import { type ExtractTablesWithRelations, sql } from "drizzle-orm"; + +import type { AppDb } from "./client"; import type { PgTransaction } from "drizzle-orm/pg-core"; import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; -import type { AppDb } from "./client"; type DbSchema = typeof import("./schema"); export type Tx = PgTransaction< - PostgresJsQueryResultHKT, - DbSchema, - ExtractTablesWithRelations + PostgresJsQueryResultHKT, + DbSchema, + ExtractTablesWithRelations >; async function getTxId(tx: Tx): Promise { - // We need the raw 32-bit xid so it matches values used by Electric replication. - const result = await tx.execute( - sql<{ txid: string }>`select pg_current_xact_id()::xid::text as txid`, - ); - const txid = (result[0] as { txid: string } | undefined)?.txid; - if (txid === undefined) { - throw new Error("Failed to resolve postgres txid"); - } + // We need the raw 32-bit xid so it matches values used by Electric replication. + const result = await tx.execute( + sql<{ txid: string }>`select pg_current_xact_id()::xid::text as txid`, + ); + const txid = (result[0] as { txid: string } | undefined)?.txid; + if (txid === undefined) { + throw new Error("Failed to resolve postgres txid"); + } - const parsedTxid = Number.parseInt(txid, 10); - if (!Number.isFinite(parsedTxid)) { - throw new Error("Failed to resolve postgres txid"); - } + const parsedTxid = Number.parseInt(txid, 10); + if (!Number.isFinite(parsedTxid)) { + throw new Error("Failed to resolve postgres txid"); + } - return parsedTxid; + return parsedTxid; } export async function withTransaction( - db: AppDb, - callback: (tx: Tx, txid: number) => Promise, + db: AppDb, + callback: (tx: Tx, txid: number) => Promise, ): Promise { - return db.transaction(async (tx) => { - const typedTx = tx as Tx; - const txid = await getTxId(typedTx); - return callback(typedTx, txid); - }); + return db.transaction(async (tx) => { + const typedTx = tx as Tx; + const txid = await getTxId(typedTx); + return callback(typedTx, txid); + }); } diff --git a/src/server/env.ts b/src/server/env.ts index a5023b2..f28d1a2 100644 --- a/src/server/env.ts +++ b/src/server/env.ts @@ -1,68 +1,68 @@ export type AppEnv = { - ENVIRONMENT?: string; - DATABASE_URL?: string; + ENVIRONMENT?: string; + DATABASE_URL?: string; - BETTER_AUTH_SECRET: string; - CREDENTIALS_ENCRYPTION_KEY: string; + BETTER_AUTH_SECRET: string; + CREDENTIALS_ENCRYPTION_KEY: string; - GITHUB_CLIENT_ID: string; - GITHUB_CLIENT_SECRET: string; - GITHUB_WEBHOOK_SECRET: string; - GITHUB_APP_ID?: string; - GITHUB_APP_PRIVATE_KEY?: string; + GITHUB_CLIENT_ID: string; + GITHUB_CLIENT_SECRET: string; + GITHUB_WEBHOOK_SECRET: string; + GITHUB_APP_ID?: string; + GITHUB_APP_PRIVATE_KEY?: string; - ELECTRIC_SOURCE_ID: string; - ELECTRIC_SECRET: string; + ELECTRIC_SOURCE_ID: string; + ELECTRIC_SECRET: string; - DURABLE_STREAMS_SERVICE_ID?: string; - DURABLE_STREAMS_SECRET?: string; + DURABLE_STREAMS_SERVICE_ID?: string; + DURABLE_STREAMS_SECRET?: string; - TASK_RUNNER_CALLBACK_SECRET?: string; + TASK_RUNNER_CALLBACK_SECRET?: string; }; function requireEnv(name: keyof AppEnv): string { - const value = process.env[name]; - if (typeof value !== "string" || value.trim().length === 0) { - throw new Error(`Missing required env var: ${name}`); - } + const value = process.env[name]; + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Missing required env var: ${name}`); + } - return value; + return value; } export function getEnv(): AppEnv { - return { - ENVIRONMENT: process.env.ENVIRONMENT, - DATABASE_URL: process.env.DATABASE_URL, + return { + ENVIRONMENT: process.env.ENVIRONMENT, + DATABASE_URL: process.env.DATABASE_URL, - BETTER_AUTH_SECRET: requireEnv("BETTER_AUTH_SECRET"), - CREDENTIALS_ENCRYPTION_KEY: requireEnv("CREDENTIALS_ENCRYPTION_KEY"), + BETTER_AUTH_SECRET: requireEnv("BETTER_AUTH_SECRET"), + CREDENTIALS_ENCRYPTION_KEY: requireEnv("CREDENTIALS_ENCRYPTION_KEY"), - GITHUB_CLIENT_ID: requireEnv("GITHUB_CLIENT_ID"), - GITHUB_CLIENT_SECRET: requireEnv("GITHUB_CLIENT_SECRET"), - GITHUB_WEBHOOK_SECRET: requireEnv("GITHUB_WEBHOOK_SECRET"), - GITHUB_APP_ID: process.env.GITHUB_APP_ID, - GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY, + GITHUB_CLIENT_ID: requireEnv("GITHUB_CLIENT_ID"), + GITHUB_CLIENT_SECRET: requireEnv("GITHUB_CLIENT_SECRET"), + GITHUB_WEBHOOK_SECRET: requireEnv("GITHUB_WEBHOOK_SECRET"), + GITHUB_APP_ID: process.env.GITHUB_APP_ID, + GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY, - ELECTRIC_SOURCE_ID: requireEnv("ELECTRIC_SOURCE_ID"), - ELECTRIC_SECRET: requireEnv("ELECTRIC_SECRET"), + ELECTRIC_SOURCE_ID: requireEnv("ELECTRIC_SOURCE_ID"), + ELECTRIC_SECRET: requireEnv("ELECTRIC_SECRET"), - DURABLE_STREAMS_SERVICE_ID: process.env.DURABLE_STREAMS_SERVICE_ID, - DURABLE_STREAMS_SECRET: process.env.DURABLE_STREAMS_SECRET, + DURABLE_STREAMS_SERVICE_ID: process.env.DURABLE_STREAMS_SERVICE_ID, + DURABLE_STREAMS_SECRET: process.env.DURABLE_STREAMS_SECRET, - TASK_RUNNER_CALLBACK_SECRET: process.env.TASK_RUNNER_CALLBACK_SECRET, - }; + TASK_RUNNER_CALLBACK_SECRET: process.env.TASK_RUNNER_CALLBACK_SECRET, + }; } export function getTaskRunnerCallbackSecret(env: AppEnv): string { - const explicitSecret = env.TASK_RUNNER_CALLBACK_SECRET?.trim(); - if (explicitSecret && explicitSecret.length > 0) { - return explicitSecret; - } + const explicitSecret = env.TASK_RUNNER_CALLBACK_SECRET?.trim(); + if (explicitSecret && explicitSecret.length > 0) { + return explicitSecret; + } - const fallbackSecret = env.BETTER_AUTH_SECRET?.trim(); - if (fallbackSecret && fallbackSecret.length > 0) { - return fallbackSecret; - } + const fallbackSecret = env.BETTER_AUTH_SECRET?.trim(); + if (fallbackSecret && fallbackSecret.length > 0) { + return fallbackSecret; + } - throw new Error("Missing TASK_RUNNER_CALLBACK_SECRET or BETTER_AUTH_SECRET"); + throw new Error("Missing TASK_RUNNER_CALLBACK_SECRET or BETTER_AUTH_SECRET"); } diff --git a/src/server/functions/common.ts b/src/server/functions/common.ts index 972deca..6b1d418 100644 --- a/src/server/functions/common.ts +++ b/src/server/functions/common.ts @@ -1,46 +1,46 @@ import type { SessionContext } from "../middleware"; export function getOrgId(context: { session: SessionContext }): string | null { - return context.session.session.activeOrganizationId ?? null; + return context.session.session.activeOrganizationId ?? null; } export function parseOptionalId(value: unknown): string | undefined { - if (typeof value !== "string") { - return undefined; - } + if (typeof value !== "string") { + return undefined; + } - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; } export function parseOptionalTimestamp(value: unknown): number | undefined { - if (typeof value !== "number" || !Number.isFinite(value)) { - return undefined; - } + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } - if (value < 0) { - return undefined; - } + if (value < 0) { + return undefined; + } - return Math.trunc(value); + return Math.trunc(value); } export function badRequest(message: string): never { - throw new Error(message); + throw new Error(message); } export function notFound(message: string): never { - throw new Error(message); + throw new Error(message); } export function forbidden(message: string): never { - throw new Error(message); + throw new Error(message); } export function conflict(message: string): never { - throw new Error(message); + throw new Error(message); } export function badGateway(message: string): never { - throw new Error(message); + throw new Error(message); } diff --git a/src/server/functions/installations.ts b/src/server/functions/installations.ts index 5f366bd..89b9869 100644 --- a/src/server/functions/installations.ts +++ b/src/server/functions/installations.ts @@ -1,120 +1,121 @@ import { createServerFn } from "@tanstack/react-start"; -import { z } from "zod"; import { and, eq, inArray, isNull } from "drizzle-orm"; -import * as schema from "@/server/db/schema"; -import { badGateway, badRequest, forbidden } from "./common"; -import { authMiddleware } from "../middleware"; +import { z } from "zod"; import { fetchGitHubAppInstallUrl } from "../lib/github-app"; +import { authMiddleware } from "../middleware"; +import { badGateway, badRequest, forbidden } from "./common"; +import * as schema from "@/server/db/schema"; export const fetchInstallations = createServerFn({ method: "GET" }) - .middleware([authMiddleware]) - .handler(async ({ context }) => { - const { db, session } = context; - const userId = session.session.userId; + .middleware([authMiddleware]) + .handler(async ({ context }) => { + const { db, session } = context; + const userId = session.session.userId; - const githubAccount = await db.query.account.findFirst({ - where: and(eq(schema.account.providerId, "github"), eq(schema.account.userId, userId)), - }); + const githubAccount = await db.query.account.findFirst({ + where: and(eq(schema.account.providerId, "github"), eq(schema.account.userId, userId)), + }); - if (!githubAccount?.accessToken) return []; + if (!githubAccount?.accessToken) return []; - const ghRes = await fetch("https://api.github.com/user/installations", { - headers: { - Authorization: `Bearer ${githubAccount.accessToken}`, - Accept: "application/vnd.github+json", - "User-Agent": "clanki-worker", - }, - }); + const ghRes = await fetch("https://api.github.com/user/installations", { + headers: { + Authorization: `Bearer ${githubAccount.accessToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "clanki-worker", + }, + }); - if (!ghRes.ok) badGateway("Failed to fetch GitHub installations"); + if (!ghRes.ok) badGateway("Failed to fetch GitHub installations"); - const ghData = (await ghRes.json()) as { - installations: Array<{ id: number; account: { login: string; type: string } }>; - }; + const ghData = (await ghRes.json()) as { + installations: Array<{ id: number; account: { login: string; type: string } }>; + }; - const ghInstallationIds = ghData.installations.map((i) => i.id); + const ghInstallationIds = ghData.installations.map((i) => i.id); - if (ghInstallationIds.length === 0) return []; + if (ghInstallationIds.length === 0) return []; - return db.query.installations.findMany({ - where: and( - inArray(schema.installations.installationId, ghInstallationIds), - isNull(schema.installations.deletedAt), - ), + return db.query.installations.findMany({ + where: and( + inArray(schema.installations.installationId, ghInstallationIds), + isNull(schema.installations.deletedAt), + ), + }); }); - }); export const fetchInstallationRepos = createServerFn({ method: "GET" }) - .middleware([authMiddleware]) - .inputValidator(z.object({ installationId: z.number().int() })) - .handler(async ({ data: input, context }) => { - const { db, session } = context; - const userId = session.session.userId; - - const githubAccount = await db.query.account.findFirst({ - where: and(eq(schema.account.providerId, "github"), eq(schema.account.userId, userId)), - }); - - if (!githubAccount?.accessToken) badRequest("No GitHub account linked"); - - const repos: Array<{ - id: number; - fullName: string; - name: string; - htmlUrl: string; - private: boolean; - }> = []; - let page = 1; - - while (true) { - const ghRes = await fetch( - `https://api.github.com/user/installations/${input.installationId}/repositories?per_page=100&page=${page}`, - { - headers: { - Authorization: `Bearer ${githubAccount.accessToken}`, - Accept: "application/vnd.github+json", - "User-Agent": "clanki-worker", - }, - }, - ); - - if (!ghRes.ok) { - if (ghRes.status === 403 || ghRes.status === 404) forbidden("Installation not accessible"); - badGateway("Failed to fetch repos from GitHub"); - } - - const data = (await ghRes.json()) as { - repositories: Array<{ - id: number; - full_name: string; - name: string; - html_url: string; - private: boolean; - }>; - total_count: number; - }; - - for (const r of data.repositories) { - repos.push({ - id: r.id, - fullName: r.full_name, - name: r.name, - htmlUrl: r.html_url, - private: r.private, + .middleware([authMiddleware]) + .inputValidator(z.object({ installationId: z.number().int() })) + .handler(async ({ data: input, context }) => { + const { db, session } = context; + const userId = session.session.userId; + + const githubAccount = await db.query.account.findFirst({ + where: and(eq(schema.account.providerId, "github"), eq(schema.account.userId, userId)), }); - } - - if (repos.length >= data.total_count) break; - page++; - } - return repos; - }); + if (!githubAccount?.accessToken) badRequest("No GitHub account linked"); + + const repos: Array<{ + id: number; + fullName: string; + name: string; + htmlUrl: string; + private: boolean; + }> = []; + let page = 1; + + while (true) { + const ghRes = await fetch( + `https://api.github.com/user/installations/${input.installationId}/repositories?per_page=100&page=${page}`, + { + headers: { + Authorization: `Bearer ${githubAccount.accessToken}`, + Accept: "application/vnd.github+json", + "User-Agent": "clanki-worker", + }, + }, + ); + + if (!ghRes.ok) { + if (ghRes.status === 403 || ghRes.status === 404) + forbidden("Installation not accessible"); + badGateway("Failed to fetch repos from GitHub"); + } + + const data = (await ghRes.json()) as { + repositories: Array<{ + id: number; + full_name: string; + name: string; + html_url: string; + private: boolean; + }>; + total_count: number; + }; + + for (const r of data.repositories) { + repos.push({ + id: r.id, + fullName: r.full_name, + name: r.name, + htmlUrl: r.html_url, + private: r.private, + }); + } + + if (repos.length >= data.total_count) break; + page++; + } + + return repos; + }); export const fetchInstallAppUrl = createServerFn({ method: "GET" }) - .middleware([authMiddleware]) - .handler(async ({ context }) => { - return { - url: await fetchGitHubAppInstallUrl(context.env), - }; - }); + .middleware([authMiddleware]) + .handler(async ({ context }) => { + return { + url: await fetchGitHubAppInstallUrl(context.env), + }; + }); diff --git a/src/server/functions/projects.ts b/src/server/functions/projects.ts index 439d765..9290309 100644 --- a/src/server/functions/projects.ts +++ b/src/server/functions/projects.ts @@ -1,218 +1,224 @@ import { createServerFn } from "@tanstack/react-start"; import { and, eq, inArray } from "drizzle-orm"; import { z } from "zod"; -import * as schema from "@/server/db/schema"; -import { withTransaction } from "@/server/db/transaction"; import { authMiddleware } from "../middleware"; import { - badRequest, - conflict, - getOrgId, - notFound, - parseOptionalId, - parseOptionalTimestamp, + badRequest, + conflict, + getOrgId, + notFound, + parseOptionalId, + parseOptionalTimestamp, } from "./common"; +import * as schema from "@/server/db/schema"; +import { withTransaction } from "@/server/db/transaction"; export const createProjects = createServerFn({ method: "POST" }) - .middleware([authMiddleware]) - .inputValidator( - z.object({ - repos: z - .array( - z.object({ - id: z.string().optional(), - name: z.string(), - repoUrl: z.string(), - installationId: z.number(), - createdAt: z.number().optional(), - updatedAt: z.number().optional(), - }), - ) - .min(1), - }), - ) - .handler(async ({ data: input, context }) => { - const db = context.db; - const orgId = getOrgId(context); - - if (!orgId) { - badRequest("No active organization"); - } - - const result = await withTransaction(db, async (tx, txid) => { - const repoUrls = input.repos.map((repo) => repo.repoUrl); - const existing = await tx.query.projects.findMany({ - where: and( - eq(schema.projects.organizationId, orgId), - inArray(schema.projects.repoUrl, repoUrls), - ), - columns: { repoUrl: true }, - }); - const existingUrls = new Set(existing.map((project) => project.repoUrl)); - - const newRepos = input.repos.filter((repo) => !existingUrls.has(repo.repoUrl)); - if (newRepos.length === 0) { - return { conflict: true as const }; - } - - const now = Date.now(); - const created = newRepos.map((repo) => { - const createdAt = parseOptionalTimestamp(repo.createdAt) ?? now; - const updatedAt = parseOptionalTimestamp(repo.updatedAt) ?? createdAt; - - return { - id: parseOptionalId(repo.id) ?? crypto.randomUUID(), - organizationId: orgId, - name: repo.name, - repoUrl: repo.repoUrl, - installationId: repo.installationId, - setupCommand: null, - runCommand: null, - runPort: null, - createdAt, - updatedAt, - }; - }); - - await tx.insert(schema.projects).values(created); - return { data: created, txid }; + .middleware([authMiddleware]) + .inputValidator( + z.object({ + repos: z + .array( + z.object({ + id: z.string().optional(), + name: z.string(), + repoUrl: z.string(), + installationId: z.number(), + createdAt: z.number().optional(), + updatedAt: z.number().optional(), + }), + ) + .min(1), + }), + ) + .handler(async ({ data: input, context }) => { + const db = context.db; + const orgId = getOrgId(context); + + if (!orgId) { + badRequest("No active organization"); + } + + const result = await withTransaction(db, async (tx, txid) => { + const repoUrls = input.repos.map((repo) => repo.repoUrl); + const existing = await tx.query.projects.findMany({ + where: and( + eq(schema.projects.organizationId, orgId), + inArray(schema.projects.repoUrl, repoUrls), + ), + columns: { repoUrl: true }, + }); + const existingUrls = new Set(existing.map((project) => project.repoUrl)); + + const newRepos = input.repos.filter((repo) => !existingUrls.has(repo.repoUrl)); + if (newRepos.length === 0) { + return { conflict: true as const }; + } + + const now = Date.now(); + const created = newRepos.map((repo) => { + const createdAt = parseOptionalTimestamp(repo.createdAt) ?? now; + const updatedAt = parseOptionalTimestamp(repo.updatedAt) ?? createdAt; + + return { + id: parseOptionalId(repo.id) ?? crypto.randomUUID(), + organizationId: orgId, + name: repo.name, + repoUrl: repo.repoUrl, + installationId: repo.installationId, + setupCommand: null, + runCommand: null, + runPort: null, + createdAt, + updatedAt, + }; + }); + + await tx.insert(schema.projects).values(created); + return { data: created, txid }; + }); + + if ("conflict" in result) { + conflict("All selected repos already have projects"); + } + + return result; }); - if ("conflict" in result) { - conflict("All selected repos already have projects"); - } - - return result; - }); - export const updateProjectSetupCommand = createServerFn({ method: "POST" }) - .middleware([authMiddleware]) - .inputValidator( - z.object({ - projectId: z.string(), - setupCommand: z.string().nullable(), - }), - ) - .handler(async ({ data: input, context }) => { - const db = context.db; - const orgId = getOrgId(context); - - if (!orgId) { - badRequest("No active organization"); - } - - const setupCommand = - typeof input.setupCommand === "string" && input.setupCommand.trim().length > 0 - ? input.setupCommand.trim() - : null; - - const result = await withTransaction(db, async (tx, txid) => { - const existing = await tx.query.projects.findFirst({ - where: and( - eq(schema.projects.id, input.projectId), - eq(schema.projects.organizationId, orgId), - ), - columns: { id: true }, - }); - - if (!existing) { - return { notFound: true as const }; - } - - const updatedAt = Date.now(); - await tx - .update(schema.projects) - .set({ setupCommand, updatedAt }) - .where( - and(eq(schema.projects.id, input.projectId), eq(schema.projects.organizationId, orgId)), - ); - - const updated = await tx.query.projects.findFirst({ - where: and( - eq(schema.projects.id, input.projectId), - eq(schema.projects.organizationId, orgId), - ), - }); - - if (!updated) { - return { notFound: true as const }; - } - - return { data: updated, txid }; + .middleware([authMiddleware]) + .inputValidator( + z.object({ + projectId: z.string(), + setupCommand: z.string().nullable(), + }), + ) + .handler(async ({ data: input, context }) => { + const db = context.db; + const orgId = getOrgId(context); + + if (!orgId) { + badRequest("No active organization"); + } + + const setupCommand = + typeof input.setupCommand === "string" && input.setupCommand.trim().length > 0 + ? input.setupCommand.trim() + : null; + + const result = await withTransaction(db, async (tx, txid) => { + const existing = await tx.query.projects.findFirst({ + where: and( + eq(schema.projects.id, input.projectId), + eq(schema.projects.organizationId, orgId), + ), + columns: { id: true }, + }); + + if (!existing) { + return { notFound: true as const }; + } + + const updatedAt = Date.now(); + await tx + .update(schema.projects) + .set({ setupCommand, updatedAt }) + .where( + and( + eq(schema.projects.id, input.projectId), + eq(schema.projects.organizationId, orgId), + ), + ); + + const updated = await tx.query.projects.findFirst({ + where: and( + eq(schema.projects.id, input.projectId), + eq(schema.projects.organizationId, orgId), + ), + }); + + if (!updated) { + return { notFound: true as const }; + } + + return { data: updated, txid }; + }); + + if ("notFound" in result) { + notFound("Project not found"); + } + + return result; }); - if ("notFound" in result) { - notFound("Project not found"); - } - - return result; - }); - export const updateProjectRunCommand = createServerFn({ method: "POST" }) - .middleware([authMiddleware]) - .inputValidator( - z.object({ - projectId: z.string(), - runCommand: z.string().nullable(), - runPort: z.number().int().min(1).max(65535).nullable(), - }), - ) - .handler(async ({ data: input, context }) => { - const db = context.db; - const orgId = getOrgId(context); - - if (!orgId) { - badRequest("No active organization"); - } - - const runCommand = - typeof input.runCommand === "string" && input.runCommand.trim().length > 0 - ? input.runCommand.trim() - : null; - const runPort = input.runPort ?? null; - - if ((runCommand === null) !== (runPort === null)) { - badRequest("Run command and run port must both be provided"); - } - - const result = await withTransaction(db, async (tx, txid) => { - const existing = await tx.query.projects.findFirst({ - where: and( - eq(schema.projects.id, input.projectId), - eq(schema.projects.organizationId, orgId), - ), - columns: { id: true }, - }); - - if (!existing) { - return { notFound: true as const }; - } - - const updatedAt = Date.now(); - await tx - .update(schema.projects) - .set({ runCommand, runPort, updatedAt }) - .where( - and(eq(schema.projects.id, input.projectId), eq(schema.projects.organizationId, orgId)), - ); - - const updated = await tx.query.projects.findFirst({ - where: and( - eq(schema.projects.id, input.projectId), - eq(schema.projects.organizationId, orgId), - ), - }); - - if (!updated) { - return { notFound: true as const }; - } - - return { data: updated, txid }; + .middleware([authMiddleware]) + .inputValidator( + z.object({ + projectId: z.string(), + runCommand: z.string().nullable(), + runPort: z.number().int().min(1).max(65535).nullable(), + }), + ) + .handler(async ({ data: input, context }) => { + const db = context.db; + const orgId = getOrgId(context); + + if (!orgId) { + badRequest("No active organization"); + } + + const runCommand = + typeof input.runCommand === "string" && input.runCommand.trim().length > 0 + ? input.runCommand.trim() + : null; + const runPort = input.runPort ?? null; + + if ((runCommand === null) !== (runPort === null)) { + badRequest("Run command and run port must both be provided"); + } + + const result = await withTransaction(db, async (tx, txid) => { + const existing = await tx.query.projects.findFirst({ + where: and( + eq(schema.projects.id, input.projectId), + eq(schema.projects.organizationId, orgId), + ), + columns: { id: true }, + }); + + if (!existing) { + return { notFound: true as const }; + } + + const updatedAt = Date.now(); + await tx + .update(schema.projects) + .set({ runCommand, runPort, updatedAt }) + .where( + and( + eq(schema.projects.id, input.projectId), + eq(schema.projects.organizationId, orgId), + ), + ); + + const updated = await tx.query.projects.findFirst({ + where: and( + eq(schema.projects.id, input.projectId), + eq(schema.projects.organizationId, orgId), + ), + }); + + if (!updated) { + return { notFound: true as const }; + } + + return { data: updated, txid }; + }); + + if ("notFound" in result) { + notFound("Project not found"); + } + + return result; }); - - if ("notFound" in result) { - notFound("Project not found"); - } - - return result; - }); diff --git a/src/server/functions/task-runs.ts b/src/server/functions/task-runs.ts index c50f5b1..0cf3456 100644 --- a/src/server/functions/task-runs.ts +++ b/src/server/functions/task-runs.ts @@ -1,71 +1,71 @@ import { createServerFn } from "@tanstack/react-start"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; +import { authMiddleware } from "../middleware"; +import { badRequest, getOrgId, notFound } from "./common"; import * as schema from "@/server/db/schema"; import { DEFAULT_OPENCODE_PROVIDER } from "@/server/lib/opencode"; import { createTaskRunCallbackToken } from "@/server/lib/task-run-callback-token"; -import { authMiddleware } from "../middleware"; -import { badRequest, getOrgId, notFound } from "./common"; export const startTaskRun = createServerFn({ method: "POST" }) - .middleware([authMiddleware]) - .inputValidator( - z.object({ - taskId: z.string(), - }), - ) - .handler(async ({ data: input, context }) => { - const orgId = getOrgId(context); + .middleware([authMiddleware]) + .inputValidator( + z.object({ + taskId: z.string(), + }), + ) + .handler(async ({ data: input, context }) => { + const orgId = getOrgId(context); - if (!orgId) { - badRequest("No active organization"); - } + if (!orgId) { + badRequest("No active organization"); + } - const task = await context.db.query.tasks.findFirst({ - where: and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId)), - columns: { - id: true, - runnerSessionId: true, - runnerType: true, - workspacePath: true, - }, - }); + const task = await context.db.query.tasks.findFirst({ + where: and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId)), + columns: { + id: true, + runnerSessionId: true, + runnerType: true, + workspacePath: true, + }, + }); - if (!task) { - notFound("Task not found"); - } + if (!task) { + notFound("Task not found"); + } - if (task.runnerType !== "local-worktree" || !task.runnerSessionId || !task.workspacePath) { - badRequest("Task is not linked to a local runner session"); - } + if (task.runnerType !== "local-worktree" || !task.runnerSessionId || !task.workspacePath) { + badRequest("Task is not linked to a local runner session"); + } - const executionId = crypto.randomUUID(); - const issuedAt = Date.now(); + const executionId = crypto.randomUUID(); + const issuedAt = Date.now(); - await context.db - .update(schema.tasks) - .set({ - error: null, - status: "running", - updatedAt: issuedAt, - }) - .where(and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId))); + await context.db + .update(schema.tasks) + .set({ + error: null, + status: "running", + updatedAt: issuedAt, + }) + .where(and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId))); - return { - callbackToken: createTaskRunCallbackToken( - { - executionId, - taskId: task.id, - organizationId: orgId, - userId: context.session.user.id, - provider: DEFAULT_OPENCODE_PROVIDER, - issuedAt, - }, - context.env, - ), - executionId, - runnerSessionId: task.runnerSessionId, - runnerType: task.runnerType, - workspacePath: task.workspacePath, - }; - }); + return { + callbackToken: createTaskRunCallbackToken( + { + executionId, + taskId: task.id, + organizationId: orgId, + userId: context.session.user.id, + provider: DEFAULT_OPENCODE_PROVIDER, + issuedAt, + }, + context.env, + ), + executionId, + runnerSessionId: task.runnerSessionId, + runnerType: task.runnerType, + workspacePath: task.workspacePath, + }; + }); diff --git a/src/server/functions/tasks.ts b/src/server/functions/tasks.ts index 1770bb0..3c487ac 100644 --- a/src/server/functions/tasks.ts +++ b/src/server/functions/tasks.ts @@ -1,270 +1,279 @@ import { createServerFn } from "@tanstack/react-start"; import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; -import type { AppDb } from "@/server/db/client"; +import { authMiddleware } from "../middleware"; +import { badRequest, getOrgId, notFound, parseOptionalId, parseOptionalTimestamp } from "./common"; import * as schema from "@/server/db/schema"; import { withTransaction } from "@/server/db/transaction"; import { createStream } from "@/server/lib/durable-streams"; -import { authMiddleware } from "../middleware"; -import { badRequest, getOrgId, notFound, parseOptionalId, parseOptionalTimestamp } from "./common"; + +import type { AppDb } from "@/server/db/client"; type TaskForOrg = { id: string; title: string; projectId: string | null }; async function getTaskForOrg( - db: AppDb, - taskId: string, - orgId: string, + db: AppDb, + taskId: string, + orgId: string, ): Promise { - return db.query.tasks.findFirst({ - where: and(eq(schema.tasks.id, taskId), eq(schema.tasks.organizationId, orgId)), - columns: { id: true, title: true, projectId: true }, - }); + return db.query.tasks.findFirst({ + where: and(eq(schema.tasks.id, taskId), eq(schema.tasks.organizationId, orgId)), + columns: { id: true, title: true, projectId: true }, + }); } async function getLatestTaskMessageTimestamp(db: AppDb, taskId: string): Promise { - const latest = await db.query.taskMessages.findFirst({ - where: eq(schema.taskMessages.taskId, taskId), - columns: { createdAt: true }, - orderBy: desc(schema.taskMessages.createdAt), - }); + const latest = await db.query.taskMessages.findFirst({ + where: eq(schema.taskMessages.taskId, taskId), + columns: { createdAt: true }, + orderBy: desc(schema.taskMessages.createdAt), + }); - return latest?.createdAt ?? null; + return latest?.createdAt ?? null; } export const createTask = createServerFn({ method: "POST" }) - .middleware([authMiddleware]) - .inputValidator( - z.object({ - id: z.string().optional(), - title: z.string(), - projectId: z.string(), - runnerSessionId: z.string().optional(), - runnerType: z.string().optional(), - status: z.string().optional(), - workspacePath: z.string().optional(), - createdAt: z.number().optional(), - updatedAt: z.number().optional(), - }), - ) - .handler(async ({ data: input, context }) => { - const db = context.db; - const orgId = getOrgId(context); - - if (!orgId) { - badRequest("No active organization"); - } - - if (input.title.trim().length === 0) { - badRequest("title is required"); - } - - const project = await db.query.projects.findFirst({ - where: and( - eq(schema.projects.id, input.projectId), - eq(schema.projects.organizationId, orgId), - ), - columns: { id: true }, - }); - - if (!project) { - notFound("Project not found"); - } - - const result = await withTransaction(db, async (tx, txid) => { - const now = Date.now(); - const createdAt = parseOptionalTimestamp(input.createdAt) ?? now; - const updatedAt = parseOptionalTimestamp(input.updatedAt) ?? createdAt; - const status = - typeof input.status === "string" && input.status.trim().length > 0 - ? input.status.trim() - : "open"; - const taskId = parseOptionalId(input.id) ?? crypto.randomUUID(); - - const streamId = await createStream({ - env: context.env, - organizationId: orgId, - taskId: taskId, - }); - - const task = { - id: taskId, - organizationId: orgId, - projectId: input.projectId, - title: input.title.trim(), - status, - runnerSessionId: parseOptionalId(input.runnerSessionId) ?? null, - runnerType: - typeof input.runnerType === "string" && input.runnerType.trim().length > 0 - ? input.runnerType.trim() - : null, - streamId, - workspacePath: - typeof input.workspacePath === "string" && input.workspacePath.trim().length > 0 - ? input.workspacePath.trim() - : null, - createdAt, - updatedAt, - }; - - await tx.insert(schema.tasks).values(task); - return { data: task, txid }; + .middleware([authMiddleware]) + .inputValidator( + z.object({ + id: z.string().optional(), + title: z.string(), + projectId: z.string(), + runnerSessionId: z.string().optional(), + runnerType: z.string().optional(), + status: z.string().optional(), + workspacePath: z.string().optional(), + createdAt: z.number().optional(), + updatedAt: z.number().optional(), + }), + ) + .handler(async ({ data: input, context }) => { + const db = context.db; + const orgId = getOrgId(context); + + if (!orgId) { + badRequest("No active organization"); + } + + if (input.title.trim().length === 0) { + badRequest("title is required"); + } + + const project = await db.query.projects.findFirst({ + where: and( + eq(schema.projects.id, input.projectId), + eq(schema.projects.organizationId, orgId), + ), + columns: { id: true }, + }); + + if (!project) { + notFound("Project not found"); + } + + const result = await withTransaction(db, async (tx, txid) => { + const now = Date.now(); + const createdAt = parseOptionalTimestamp(input.createdAt) ?? now; + const updatedAt = parseOptionalTimestamp(input.updatedAt) ?? createdAt; + const status = + typeof input.status === "string" && input.status.trim().length > 0 + ? input.status.trim() + : "open"; + const taskId = parseOptionalId(input.id) ?? crypto.randomUUID(); + + const streamId = await createStream({ + env: context.env, + organizationId: orgId, + taskId: taskId, + }); + + const task = { + id: taskId, + organizationId: orgId, + projectId: input.projectId, + title: input.title.trim(), + status, + runnerSessionId: parseOptionalId(input.runnerSessionId) ?? null, + runnerType: + typeof input.runnerType === "string" && input.runnerType.trim().length > 0 + ? input.runnerType.trim() + : null, + streamId, + workspacePath: + typeof input.workspacePath === "string" && input.workspacePath.trim().length > 0 + ? input.workspacePath.trim() + : null, + createdAt, + updatedAt, + }; + + await tx.insert(schema.tasks).values(task); + return { data: task, txid }; + }); + + return result; }); - return result; - }); - export const updateTask = createServerFn({ method: "POST" }) - .middleware([authMiddleware]) - .inputValidator( - z.object({ - taskId: z.string(), - title: z.string().optional(), - runnerSessionId: z.string().nullable().optional(), - runnerType: z.string().nullable().optional(), - workspacePath: z.string().nullable().optional(), - error: z.string().nullable().optional(), - }), - ) - .handler(async ({ data: input, context }) => { - const db = context.db; - const orgId = getOrgId(context); - - if (!orgId) { - badRequest("No active organization"); - } - - const task = await getTaskForOrg(db, input.taskId, orgId); - if (!task) { - notFound("Task not found"); - } - - const { taskId: _, ...fields } = input; - const updates = Object.fromEntries( - Object.entries(fields).filter(([, v]) => v !== undefined), - ) as Partial; - - if (Object.keys(updates).length === 0) { - badRequest("No task fields to update"); - } - - const result = await withTransaction(db, async (tx, txid) => { - const updatedAt = Date.now(); - await tx - .update(schema.tasks) - .set({ ...updates, updatedAt }) - .where(and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId))); - - const updatedTask = await tx.query.tasks.findFirst({ - where: and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId)), - }); - - if (!updatedTask) { - return { notFound: true as const }; - } - - return { data: updatedTask, txid }; + .middleware([authMiddleware]) + .inputValidator( + z.object({ + taskId: z.string(), + title: z.string().optional(), + runnerSessionId: z.string().nullable().optional(), + runnerType: z.string().nullable().optional(), + workspacePath: z.string().nullable().optional(), + error: z.string().nullable().optional(), + }), + ) + .handler(async ({ data: input, context }) => { + const db = context.db; + const orgId = getOrgId(context); + + if (!orgId) { + badRequest("No active organization"); + } + + const task = await getTaskForOrg(db, input.taskId, orgId); + if (!task) { + notFound("Task not found"); + } + + const { taskId: _, ...fields } = input; + const updates = Object.fromEntries( + Object.entries(fields).filter(([, v]) => v !== undefined), + ) as Partial; + + if (Object.keys(updates).length === 0) { + badRequest("No task fields to update"); + } + + const result = await withTransaction(db, async (tx, txid) => { + const updatedAt = Date.now(); + await tx + .update(schema.tasks) + .set({ ...updates, updatedAt }) + .where( + and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId)), + ); + + const updatedTask = await tx.query.tasks.findFirst({ + where: and( + eq(schema.tasks.id, input.taskId), + eq(schema.tasks.organizationId, orgId), + ), + }); + + if (!updatedTask) { + return { notFound: true as const }; + } + + return { data: updatedTask, txid }; + }); + + if ("notFound" in result) { + notFound("Task not found"); + } + + return result; }); - if ("notFound" in result) { - notFound("Task not found"); - } - - return result; - }); - export const deleteTask = createServerFn({ method: "POST" }) - .middleware([authMiddleware]) - .inputValidator(z.object({ taskId: z.string() })) - .handler(async ({ data: input, context }) => { - const db = context.db; - const orgId = getOrgId(context); - - if (!orgId) { - badRequest("No active organization"); - } - - const task = await getTaskForOrg(db, input.taskId, orgId); - if (!task) { - notFound("Task not found"); - } - - const txid = await withTransaction(db, async (tx, currentTxid) => { - await tx - .delete(schema.tasks) - .where(and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId))); - return currentTxid; + .middleware([authMiddleware]) + .inputValidator(z.object({ taskId: z.string() })) + .handler(async ({ data: input, context }) => { + const db = context.db; + const orgId = getOrgId(context); + + if (!orgId) { + badRequest("No active organization"); + } + + const task = await getTaskForOrg(db, input.taskId, orgId); + if (!task) { + notFound("Task not found"); + } + + const txid = await withTransaction(db, async (tx, currentTxid) => { + await tx + .delete(schema.tasks) + .where( + and(eq(schema.tasks.id, input.taskId), eq(schema.tasks.organizationId, orgId)), + ); + return currentTxid; + }); + + return { txid }; }); - return { txid }; - }); - export const createTaskMessage = createServerFn({ method: "POST" }) - .middleware([authMiddleware]) - .inputValidator( - z.object({ - taskId: z.string(), - message: z.object({ - id: z.string().optional(), - role: z.string(), - content: z.string(), - createdAt: z.number().optional(), - }), - }), - ) - .handler(async ({ data: input, context }) => { - const db = context.db; - const orgId = getOrgId(context); - - if (!orgId) { - badRequest("No active organization"); - } - - const task = await getTaskForOrg(db, input.taskId, orgId); - if (!task) { - notFound("Task not found"); - } - - const content = input.message.content.trim(); - if (content.length === 0) { - badRequest("content is required"); - } - - if (!["user", "assistant"].includes(input.message.role)) { - badRequest("role must be 'user' or 'assistant'"); - } - - const result = await withTransaction(db, async (tx, txid) => { - const requestedCreatedAt = parseOptionalTimestamp(input.message.createdAt) ?? Date.now(); - const latestCreatedAt = await getLatestTaskMessageTimestamp( - tx as unknown as AppDb, - input.taskId, - ); - const createdAt = - latestCreatedAt !== null && latestCreatedAt >= requestedCreatedAt - ? latestCreatedAt + 1 - : requestedCreatedAt; - - const message = { - id: parseOptionalId(input.message.id) ?? crypto.randomUUID(), - organizationId: orgId, - taskId: input.taskId, - role: input.message.role, - content, - createdAt, - }; - - await tx.insert(schema.taskMessages).values(message); - await tx - .update(schema.tasks) - .set( - input.message.role === "user" - ? { status: "open", error: null, updatedAt: createdAt } - : { updatedAt: createdAt }, - ) - .where(eq(schema.tasks.id, input.taskId)); - - return { data: message, txid }; + .middleware([authMiddleware]) + .inputValidator( + z.object({ + taskId: z.string(), + message: z.object({ + id: z.string().optional(), + role: z.string(), + content: z.string(), + createdAt: z.number().optional(), + }), + }), + ) + .handler(async ({ data: input, context }) => { + const db = context.db; + const orgId = getOrgId(context); + + if (!orgId) { + badRequest("No active organization"); + } + + const task = await getTaskForOrg(db, input.taskId, orgId); + if (!task) { + notFound("Task not found"); + } + + const content = input.message.content.trim(); + if (content.length === 0) { + badRequest("content is required"); + } + + if (!["user", "assistant"].includes(input.message.role)) { + badRequest("role must be 'user' or 'assistant'"); + } + + const result = await withTransaction(db, async (tx, txid) => { + const requestedCreatedAt = + parseOptionalTimestamp(input.message.createdAt) ?? Date.now(); + const latestCreatedAt = await getLatestTaskMessageTimestamp( + tx as unknown as AppDb, + input.taskId, + ); + const createdAt = + latestCreatedAt !== null && latestCreatedAt >= requestedCreatedAt + ? latestCreatedAt + 1 + : requestedCreatedAt; + + const message = { + id: parseOptionalId(input.message.id) ?? crypto.randomUUID(), + organizationId: orgId, + taskId: input.taskId, + role: input.message.role, + content, + createdAt, + }; + + await tx.insert(schema.taskMessages).values(message); + await tx + .update(schema.tasks) + .set( + input.message.role === "user" + ? { status: "open", error: null, updatedAt: createdAt } + : { updatedAt: createdAt }, + ) + .where(eq(schema.tasks.id, input.taskId)); + + return { data: message, txid }; + }); + + return result; }); - - return result; - }); diff --git a/src/server/lib/clause-to-string.ts b/src/server/lib/clause-to-string.ts index 92b54ae..78fae0c 100644 --- a/src/server/lib/clause-to-string.ts +++ b/src/server/lib/clause-to-string.ts @@ -1,28 +1,29 @@ -import type { SQL } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; +import type { SQL } from "drizzle-orm"; + export const clauseToString = (clause?: SQL): string => { - if (!clause) { - throw new Error("No clause provided"); - } + if (!clause) { + throw new Error("No clause provided"); + } - const pgDialect = new PgDialect(); - const { sql, params } = pgDialect.sqlToQuery(clause); + const pgDialect = new PgDialect(); + const { sql, params } = pgDialect.sqlToQuery(clause); - // Use Drizzle's built-in parameter injection - replace PostgreSQL placeholders with actual values - let finalSql = sql.replace(/\$(\d+)/g, (_, paramIndex) => { - const param = params[Number(paramIndex) - 1]; - if (typeof param === "string") { - return `'${param.replace(/'/g, "''")}'`; - } - return String(param); - }); + // Use Drizzle's built-in parameter injection - replace PostgreSQL placeholders with actual values + let finalSql = sql.replace(/\$(\d+)/g, (_, paramIndex) => { + const param = params[Number(paramIndex) - 1]; + if (typeof param === "string") { + return `'${param.replace(/'/g, "''")}'`; + } + return String(param); + }); - // Remove table prefixes for Electric SQL compatibility (e.g., "chore"."family_id" -> "family_id") - finalSql = finalSql.replace(/"[^"]+"\./g, ""); + // Remove table prefixes for Electric SQL compatibility (e.g., "chore"."family_id" -> "family_id") + finalSql = finalSql.replace(/"[^"]+"\./g, ""); - // Remove outer parentheses if present - finalSql = finalSql.replace(/^\((.*)\)$/, "$1"); + // Remove outer parentheses if present + finalSql = finalSql.replace(/^\((.*)\)$/, "$1"); - return finalSql; + return finalSql; }; diff --git a/src/server/lib/durable-streams.ts b/src/server/lib/durable-streams.ts index c1e8874..d0189ec 100644 --- a/src/server/lib/durable-streams.ts +++ b/src/server/lib/durable-streams.ts @@ -1,116 +1,117 @@ import { DurableStream, DurableStreamError } from "@durable-streams/client"; + import type { TaskStreamEvent } from "@/shared/task-stream-events"; export type DurableStreamsEnv = { - DURABLE_STREAMS_SERVICE_ID?: string; - DURABLE_STREAMS_SECRET?: string; + DURABLE_STREAMS_SERVICE_ID?: string; + DURABLE_STREAMS_SECRET?: string; }; const DURABLE_STREAMS_BASE_URL = "https://api.electric-sql.cloud"; function isDurableStreamsConfigured(env: DurableStreamsEnv): boolean { - return ( - typeof env.DURABLE_STREAMS_SERVICE_ID === "string" && - env.DURABLE_STREAMS_SERVICE_ID.trim().length > 0 && - typeof env.DURABLE_STREAMS_SECRET === "string" && - env.DURABLE_STREAMS_SECRET.trim().length > 0 - ); + return ( + typeof env.DURABLE_STREAMS_SERVICE_ID === "string" && + env.DURABLE_STREAMS_SERVICE_ID.trim().length > 0 && + typeof env.DURABLE_STREAMS_SECRET === "string" && + env.DURABLE_STREAMS_SECRET.trim().length > 0 + ); } function buildStreamUrl(env: DurableStreamsEnv, streamId: string): string { - const serviceId = env.DURABLE_STREAMS_SERVICE_ID?.trim(); - if (!serviceId) { - throw new Error("Missing DURABLE_STREAMS_SERVICE_ID"); - } + const serviceId = env.DURABLE_STREAMS_SERVICE_ID?.trim(); + if (!serviceId) { + throw new Error("Missing DURABLE_STREAMS_SERVICE_ID"); + } - const encodedPath = streamId - .split("/") - .map((segment) => encodeURIComponent(segment)) - .join("/"); + const encodedPath = streamId + .split("/") + .map((segment) => encodeURIComponent(segment)) + .join("/"); - return `${DURABLE_STREAMS_BASE_URL}/v1/stream/${encodeURIComponent(serviceId)}/${encodedPath}`; + return `${DURABLE_STREAMS_BASE_URL}/v1/stream/${encodeURIComponent(serviceId)}/${encodedPath}`; } function buildHeaders(env: DurableStreamsEnv): Record { - const secret = env.DURABLE_STREAMS_SECRET?.trim(); - if (!secret) { - throw new Error("Missing DURABLE_STREAMS_SECRET"); - } - return { Authorization: `Bearer ${secret}` }; + const secret = env.DURABLE_STREAMS_SECRET?.trim(); + if (!secret) { + throw new Error("Missing DURABLE_STREAMS_SECRET"); + } + return { Authorization: `Bearer ${secret}` }; } function buildTaskEventsStreamId(args: { organizationId: string; taskId: string }): string { - return `org/${args.organizationId}/tasks/${args.taskId}/events`; + return `org/${args.organizationId}/tasks/${args.taskId}/events`; } interface CreateStreamProps { - env: DurableStreamsEnv; - organizationId: string; - taskId: string; + env: DurableStreamsEnv; + organizationId: string; + taskId: string; } export async function createStream({ - env, - organizationId, - taskId, + env, + organizationId, + taskId, }: CreateStreamProps): Promise { - const streamId = buildTaskEventsStreamId({ organizationId, taskId }); - const streamUrl = buildStreamUrl(env, streamId); - const headers = buildHeaders(env); - try { - await DurableStream.create({ url: streamUrl, headers, contentType: "application/json" }); - return streamId; - } catch (error) { - if (error instanceof DurableStreamError && error.code === "CONFLICT_EXISTS") { - return streamId; + const streamId = buildTaskEventsStreamId({ organizationId, taskId }); + const streamUrl = buildStreamUrl(env, streamId); + const headers = buildHeaders(env); + try { + await DurableStream.create({ url: streamUrl, headers, contentType: "application/json" }); + return streamId; + } catch (error) { + if (error instanceof DurableStreamError && error.code === "CONFLICT_EXISTS") { + return streamId; + } + throw error; } - throw error; - } } export async function openTaskEventsSse(args: { - env: DurableStreamsEnv; - streamId: string; - offset: string; + env: DurableStreamsEnv; + streamId: string; + offset: string; }): Promise { - const { env, streamId, offset } = args; - - if (!isDurableStreamsConfigured(env)) { - throw new Error("Durable Streams is not configured"); - } - - const url = buildStreamUrl(env, streamId); - - const readUrl = new URL(url); - readUrl.searchParams.set("offset", offset); - readUrl.searchParams.set("live", "sse"); - - return fetch(readUrl.toString(), { - method: "GET", - headers: { - ...buildHeaders(env), - Accept: "text/event-stream", - "Cache-Control": "no-cache", - }, - }); + const { env, streamId, offset } = args; + + if (!isDurableStreamsConfigured(env)) { + throw new Error("Durable Streams is not configured"); + } + + const url = buildStreamUrl(env, streamId); + + const readUrl = new URL(url); + readUrl.searchParams.set("offset", offset); + readUrl.searchParams.set("live", "sse"); + + return fetch(readUrl.toString(), { + method: "GET", + headers: { + ...buildHeaders(env), + Accept: "text/event-stream", + "Cache-Control": "no-cache", + }, + }); } export async function appendTaskEvent(args: { - env: DurableStreamsEnv; - event: TaskStreamEvent; - streamId: string; + env: DurableStreamsEnv; + event: TaskStreamEvent; + streamId: string; }): Promise { - const { env, event, streamId } = args; + const { env, event, streamId } = args; - if (!isDurableStreamsConfigured(env)) { - throw new Error("Durable Streams is not configured"); - } + if (!isDurableStreamsConfigured(env)) { + throw new Error("Durable Streams is not configured"); + } - const stream = new DurableStream({ - url: buildStreamUrl(env, streamId), - headers: buildHeaders(env), - contentType: "application/json", - }); + const stream = new DurableStream({ + url: buildStreamUrl(env, streamId), + headers: buildHeaders(env), + contentType: "application/json", + }); - await stream.append(JSON.stringify(event)); + await stream.append(JSON.stringify(event)); } diff --git a/src/server/lib/electric.ts b/src/server/lib/electric.ts index dd02583..b70da02 100644 --- a/src/server/lib/electric.ts +++ b/src/server/lib/electric.ts @@ -2,42 +2,42 @@ import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from "@electric-sql/client"; import { getEnv } from "../env"; interface ElectricOptions { - table: string; - where?: string; - request: Request; + table: string; + where?: string; + request: Request; } export const electricFn = async ({ request, table, where }: ElectricOptions) => { - const env = getEnv(); - if (!env.ELECTRIC_SECRET || !env.ELECTRIC_SOURCE_ID) { - throw new Error("Missing ELECTRIC_SECRET or ELECTRIC_SOURCE_ID"); - } - const requestUrl = new URL(request.url); - - const targetUrl = new URL(`https://api.electric-sql.cloud/v1/shape`); - requestUrl.searchParams.forEach((value, key) => { - if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { - targetUrl.searchParams.set(key, value); + const env = getEnv(); + if (!env.ELECTRIC_SECRET || !env.ELECTRIC_SOURCE_ID) { + throw new Error("Missing ELECTRIC_SECRET or ELECTRIC_SOURCE_ID"); } - }); + const requestUrl = new URL(request.url); + + const targetUrl = new URL(`https://api.electric-sql.cloud/v1/shape`); + requestUrl.searchParams.forEach((value, key) => { + if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) { + targetUrl.searchParams.set(key, value); + } + }); - targetUrl.searchParams.set("secret", env.ELECTRIC_SECRET); - targetUrl.searchParams.set("source_id", env.ELECTRIC_SOURCE_ID); - targetUrl.searchParams.set("table", table); + targetUrl.searchParams.set("secret", env.ELECTRIC_SECRET); + targetUrl.searchParams.set("source_id", env.ELECTRIC_SOURCE_ID); + targetUrl.searchParams.set("table", table); - if (where) { - targetUrl.searchParams.set("where", where); - } + if (where) { + targetUrl.searchParams.set("where", where); + } - const response = await fetch(targetUrl); - const headers = new Headers(response.headers); - headers.delete(`content-encoding`); - headers.delete(`content-length`); - headers.set(`cache-control`, `no-store`); + const response = await fetch(targetUrl); + const headers = new Headers(response.headers); + headers.delete(`content-encoding`); + headers.delete(`content-length`); + headers.set(`cache-control`, `no-store`); - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers, - }); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); }; diff --git a/src/server/lib/ensure-default-organization.ts b/src/server/lib/ensure-default-organization.ts index 0fbcc69..1d45720 100644 --- a/src/server/lib/ensure-default-organization.ts +++ b/src/server/lib/ensure-default-organization.ts @@ -2,67 +2,69 @@ import { and, eq } from "drizzle-orm"; import * as schema from "@/server/db/schema"; type OrganizationCreator = { - api: { - createOrganization(args: { - body: { - name: string; - slug: string; - userId: string; - }; - }): Promise; - }; + api: { + createOrganization(args: { + body: { + name: string; + slug: string; + userId: string; + }; + }): Promise; + }; }; type DbLike = { - select: (...args: any[]) => any; + select: (...args: any[]) => any; }; type UserLike = { - id: string; - name: string; - email: string; + id: string; + name: string; + email: string; }; function buildOrganizationSlug(user: UserLike): string { - const slug = user.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - return `${slug}-${user.id.slice(0, 8)}`; + const slug = user.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + return `${slug}-${user.id.slice(0, 8)}`; } export async function ensureDefaultOrganizationForUser(args: { - auth: OrganizationCreator; - db: DbLike; - user: UserLike; + auth: OrganizationCreator; + db: DbLike; + user: UserLike; }) { - const { auth, db, user } = args; + const { auth, db, user } = args; - const existingMembership = await db - .select({ organizationId: schema.member.organizationId }) - .from(schema.member) - .where(eq(schema.member.userId, user.id)) - .limit(1); + const existingMembership = await db + .select({ organizationId: schema.member.organizationId }) + .from(schema.member) + .where(eq(schema.member.userId, user.id)) + .limit(1); - if (existingMembership.length > 0) { - return; - } + if (existingMembership.length > 0) { + return; + } - const pendingInvitation = await db - .select({ id: schema.invitation.id }) - .from(schema.invitation) - .where(and(eq(schema.invitation.email, user.email), eq(schema.invitation.status, "pending"))) - .limit(1); + const pendingInvitation = await db + .select({ id: schema.invitation.id }) + .from(schema.invitation) + .where( + and(eq(schema.invitation.email, user.email), eq(schema.invitation.status, "pending")), + ) + .limit(1); - if (pendingInvitation.length > 0) { - return; - } + if (pendingInvitation.length > 0) { + return; + } - await auth.api.createOrganization({ - body: { - name: `${user.name}'s Organization`, - slug: buildOrganizationSlug(user), - userId: user.id, - }, - }); + await auth.api.createOrganization({ + body: { + name: `${user.name}'s Organization`, + slug: buildOrganizationSlug(user), + userId: user.id, + }, + }); } diff --git a/src/server/lib/github-app.ts b/src/server/lib/github-app.ts index e5dedf8..10b6f4a 100644 --- a/src/server/lib/github-app.ts +++ b/src/server/lib/github-app.ts @@ -1,118 +1,119 @@ import { createSign } from "node:crypto"; + import type { AppEnv } from "@/server/env"; const GITHUB_API_URL = "https://api.github.com"; const JWT_LIFETIME_SECONDS = 9 * 60; type GitHubAppInfo = { - slug: string; + slug: string; }; let cachedAppInfo: { - appId: string; - privateKey: string; - expiresAt: number; - value: GitHubAppInfo; + appId: string; + privateKey: string; + expiresAt: number; + value: GitHubAppInfo; } | null = null; async function fetchGitHubAppInfo(env: AppEnv): Promise { - const appId = env.GITHUB_APP_ID?.trim(); - const privateKey = env.GITHUB_APP_PRIVATE_KEY?.trim(); - - if (!appId || !privateKey) { - return null; - } - - const now = Date.now(); - if ( - cachedAppInfo && - cachedAppInfo.appId === appId && - cachedAppInfo.privateKey === privateKey && - cachedAppInfo.expiresAt > now - ) { - return cachedAppInfo.value; - } - - const token = createGitHubAppJwt(appId, privateKey); - const response = await fetch(`${GITHUB_API_URL}/app`, { - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "User-Agent": "clanki-worker", - "X-GitHub-Api-Version": "2022-11-28", - }, - }); - - if (!response.ok) { - throw new Error("Failed to load GitHub App metadata"); - } - - const data = (await response.json()) as Partial<{ slug: string }>; - const slug = data.slug?.trim(); - if (!slug) { - throw new Error("GitHub App slug is missing"); - } - - const value = { slug }; - cachedAppInfo = { - appId, - privateKey, - expiresAt: now + 5 * 60 * 1000, - value, - }; - - return value; + const appId = env.GITHUB_APP_ID?.trim(); + const privateKey = env.GITHUB_APP_PRIVATE_KEY?.trim(); + + if (!appId || !privateKey) { + return null; + } + + const now = Date.now(); + if ( + cachedAppInfo && + cachedAppInfo.appId === appId && + cachedAppInfo.privateKey === privateKey && + cachedAppInfo.expiresAt > now + ) { + return cachedAppInfo.value; + } + + const token = createGitHubAppJwt(appId, privateKey); + const response = await fetch(`${GITHUB_API_URL}/app`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "User-Agent": "clanki-worker", + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + if (!response.ok) { + throw new Error("Failed to load GitHub App metadata"); + } + + const data = (await response.json()) as Partial<{ slug: string }>; + const slug = data.slug?.trim(); + if (!slug) { + throw new Error("GitHub App slug is missing"); + } + + const value = { slug }; + cachedAppInfo = { + appId, + privateKey, + expiresAt: now + 5 * 60 * 1000, + value, + }; + + return value; } export async function fetchGitHubAppInstallUrl(env: AppEnv): Promise { - const appInfo = await fetchGitHubAppInfo(env); - if (!appInfo) { - return null; - } + const appInfo = await fetchGitHubAppInfo(env); + if (!appInfo) { + return null; + } - return `https://github.com/apps/${appInfo.slug}/installations/new`; + return `https://github.com/apps/${appInfo.slug}/installations/new`; } function createGitHubAppJwt(appId: string, privateKey: string): string { - const issuedAt = Math.floor(Date.now() / 1000); - const header = encodeJwtPart({ alg: "RS256", typ: "JWT" }); - const payload = encodeJwtPart({ - iat: issuedAt - 60, - exp: issuedAt + JWT_LIFETIME_SECONDS, - iss: appId, - }); - const unsignedToken = `${header}.${payload}`; - const signature = createSign("RSA-SHA256") - .update(unsignedToken) - .end() - .sign(normalizePrivateKey(privateKey), "base64url"); - return `${unsignedToken}.${signature}`; + const issuedAt = Math.floor(Date.now() / 1000); + const header = encodeJwtPart({ alg: "RS256", typ: "JWT" }); + const payload = encodeJwtPart({ + iat: issuedAt - 60, + exp: issuedAt + JWT_LIFETIME_SECONDS, + iss: appId, + }); + const unsignedToken = `${header}.${payload}`; + const signature = createSign("RSA-SHA256") + .update(unsignedToken) + .end() + .sign(normalizePrivateKey(privateKey), "base64url"); + return `${unsignedToken}.${signature}`; } function encodeJwtPart(value: Record): string { - return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); } function normalizePrivateKey(privateKey: string): string { - const trimmedKey = privateKey.trim(); - if (trimmedKey.includes("\n")) { - return trimmedKey; - } - - if (trimmedKey.includes("\\n")) { - return trimmedKey.replaceAll("\\n", "\n"); - } - - const beginMarker = "-----BEGIN PRIVATE KEY-----"; - const endMarker = "-----END PRIVATE KEY-----"; - if (!trimmedKey.startsWith(beginMarker) || !trimmedKey.endsWith(endMarker)) { - return trimmedKey; - } - - const body = trimmedKey - .slice(beginMarker.length, trimmedKey.length - endMarker.length) - .replace(/\s+/g, ""); - const lines = body.match(/.{1,64}/g) ?? []; - - return [beginMarker, ...lines, endMarker].join("\n"); + const trimmedKey = privateKey.trim(); + if (trimmedKey.includes("\n")) { + return trimmedKey; + } + + if (trimmedKey.includes("\\n")) { + return trimmedKey.replaceAll("\\n", "\n"); + } + + const beginMarker = "-----BEGIN PRIVATE KEY-----"; + const endMarker = "-----END PRIVATE KEY-----"; + if (!trimmedKey.startsWith(beginMarker) || !trimmedKey.endsWith(endMarker)) { + return trimmedKey; + } + + const body = trimmedKey + .slice(beginMarker.length, trimmedKey.length - endMarker.length) + .replace(/\s+/g, ""); + const lines = body.match(/.{1,64}/g) ?? []; + + return [beginMarker, ...lines, endMarker].join("\n"); } diff --git a/src/server/lib/opencode.ts b/src/server/lib/opencode.ts index 0a9e43d..ffd70fb 100644 --- a/src/server/lib/opencode.ts +++ b/src/server/lib/opencode.ts @@ -5,5 +5,5 @@ export const SUPPORTED_OPENCODE_PROVIDERS = [DEFAULT_OPENCODE_PROVIDER] as const export type SupportedOpencodeProvider = (typeof SUPPORTED_OPENCODE_PROVIDERS)[number]; export function isSupportedOpencodeProvider(value: string): value is SupportedOpencodeProvider { - return SUPPORTED_OPENCODE_PROVIDERS.includes(value as SupportedOpencodeProvider); + return SUPPORTED_OPENCODE_PROVIDERS.includes(value as SupportedOpencodeProvider); } diff --git a/src/server/lib/provider-credentials.ts b/src/server/lib/provider-credentials.ts index 4d2d62d..fea5ad4 100644 --- a/src/server/lib/provider-credentials.ts +++ b/src/server/lib/provider-credentials.ts @@ -1,76 +1,77 @@ -import type { Auth } from "@opencode-ai/sdk"; import { and, eq } from "drizzle-orm"; -import type { AppDb } from "../db/client"; import * as schema from "../db/schema"; -import type { SupportedOpencodeProvider } from "./opencode"; import { encryptSecret, type SecretCryptoEnv } from "./secret-crypto"; +import type { AppDb } from "../db/client"; +import type { SupportedOpencodeProvider } from "./opencode"; +import type { Auth } from "@opencode-ai/sdk"; + type ProviderAuthType = Auth["type"]; type ProviderCredentialStatus = { - provider: string; - configured: boolean; - authType: ProviderAuthType | null; - updatedAt: number | null; + provider: string; + configured: boolean; + authType: ProviderAuthType | null; + updatedAt: number | null; }; export async function upsertProviderAuthCredential( - db: AppDb, - env: SecretCryptoEnv, - userId: string, - provider: SupportedOpencodeProvider, - auth: Auth, + db: AppDb, + env: SecretCryptoEnv, + userId: string, + provider: SupportedOpencodeProvider, + auth: Auth, ): Promise { - const apiKeyValue = auth.type === "api" ? auth.key : ""; - return upsertProviderCredential(db, env, userId, provider, auth, apiKeyValue); + const apiKeyValue = auth.type === "api" ? auth.key : ""; + return upsertProviderCredential(db, env, userId, provider, auth, apiKeyValue); } async function upsertProviderCredential( - db: AppDb, - env: SecretCryptoEnv, - userId: string, - provider: SupportedOpencodeProvider, - auth: Auth, - encryptedApiKeyValue: string, + db: AppDb, + env: SecretCryptoEnv, + userId: string, + provider: SupportedOpencodeProvider, + auth: Auth, + encryptedApiKeyValue: string, ): Promise { - const encryptedApiKey = await encryptSecret(env, encryptedApiKeyValue); - const encryptedAuthJson = await encryptSecret(env, JSON.stringify(auth)); - const authType = auth.type; - const now = Date.now(); - const existing = await db.query.userProviderCredentials.findFirst({ - where: and( - eq(schema.userProviderCredentials.userId, userId), - eq(schema.userProviderCredentials.provider, provider), - ), - columns: { id: true }, - }); + const encryptedApiKey = await encryptSecret(env, encryptedApiKeyValue); + const encryptedAuthJson = await encryptSecret(env, JSON.stringify(auth)); + const authType = auth.type; + const now = Date.now(); + const existing = await db.query.userProviderCredentials.findFirst({ + where: and( + eq(schema.userProviderCredentials.userId, userId), + eq(schema.userProviderCredentials.provider, provider), + ), + columns: { id: true }, + }); + + if (existing) { + await db + .update(schema.userProviderCredentials) + .set({ + authType, + encryptedApiKey, + encryptedAuthJson, + updatedAt: now, + }) + .where(eq(schema.userProviderCredentials.id, existing.id)); + } else { + await db.insert(schema.userProviderCredentials).values({ + id: crypto.randomUUID(), + userId, + provider, + authType, + encryptedApiKey, + encryptedAuthJson, + createdAt: now, + updatedAt: now, + }); + } - if (existing) { - await db - .update(schema.userProviderCredentials) - .set({ + return { + provider, + configured: true, authType, - encryptedApiKey, - encryptedAuthJson, updatedAt: now, - }) - .where(eq(schema.userProviderCredentials.id, existing.id)); - } else { - await db.insert(schema.userProviderCredentials).values({ - id: crypto.randomUUID(), - userId, - provider, - authType, - encryptedApiKey, - encryptedAuthJson, - createdAt: now, - updatedAt: now, - }); - } - - return { - provider, - configured: true, - authType, - updatedAt: now, - }; + }; } diff --git a/src/server/lib/secret-crypto.ts b/src/server/lib/secret-crypto.ts index 5de2492..432bb76 100644 --- a/src/server/lib/secret-crypto.ts +++ b/src/server/lib/secret-crypto.ts @@ -3,66 +3,66 @@ const AES_GCM_IV_BYTES = 12; const AES_256_KEY_BYTES = 32; export type SecretCryptoEnv = { - CREDENTIALS_ENCRYPTION_KEY: string; + CREDENTIALS_ENCRYPTION_KEY: string; }; let cachedKey: CryptoKey | null = null; let cachedRawKey: string | null = null; export async function encryptSecret(env: SecretCryptoEnv, plaintext: string): Promise { - const key = await getCryptoKey(env); - const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES)); - const encoded = new TextEncoder().encode(plaintext); - const cipherBuffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); - const cipher = new Uint8Array(cipherBuffer); + const key = await getCryptoKey(env); + const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES)); + const encoded = new TextEncoder().encode(plaintext); + const cipherBuffer = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, encoded); + const cipher = new Uint8Array(cipherBuffer); - return [ENCRYPTED_VALUE_VERSION, toBase64(iv), toBase64(cipher)].join(":"); + return [ENCRYPTED_VALUE_VERSION, toBase64(iv), toBase64(cipher)].join(":"); } async function getCryptoKey(env: SecretCryptoEnv): Promise { - const rawKey = env.CREDENTIALS_ENCRYPTION_KEY?.trim(); - if (!rawKey) { - throw new Error("CREDENTIALS_ENCRYPTION_KEY is required"); - } + const rawKey = env.CREDENTIALS_ENCRYPTION_KEY?.trim(); + if (!rawKey) { + throw new Error("CREDENTIALS_ENCRYPTION_KEY is required"); + } - if (cachedKey && cachedRawKey === rawKey) { - return cachedKey; - } + if (cachedKey && cachedRawKey === rawKey) { + return cachedKey; + } - const keyBytes = fromBase64(rawKey); - if (keyBytes.byteLength !== AES_256_KEY_BYTES) { - throw new Error("CREDENTIALS_ENCRYPTION_KEY must be a base64-encoded 32-byte key"); - } + const keyBytes = fromBase64(rawKey); + if (keyBytes.byteLength !== AES_256_KEY_BYTES) { + throw new Error("CREDENTIALS_ENCRYPTION_KEY must be a base64-encoded 32-byte key"); + } - const key = await crypto.subtle.importKey("raw", toArrayBuffer(keyBytes), "AES-GCM", false, [ - "encrypt", - "decrypt", - ]); + const key = await crypto.subtle.importKey("raw", toArrayBuffer(keyBytes), "AES-GCM", false, [ + "encrypt", + "decrypt", + ]); - cachedKey = key; - cachedRawKey = rawKey; - return key; + cachedKey = key; + cachedRawKey = rawKey; + return key; } function toBase64(bytes: Uint8Array): string { - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); } function fromBase64(value: string): Uint8Array { - const binary = atob(value); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; } function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { - const copy = new Uint8Array(bytes.byteLength); - copy.set(bytes); - return copy.buffer; + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; } diff --git a/src/server/lib/task-execution/helpers.ts b/src/server/lib/task-execution/helpers.ts index 0a588d6..0f54d2b 100644 --- a/src/server/lib/task-execution/helpers.ts +++ b/src/server/lib/task-execution/helpers.ts @@ -1,61 +1,62 @@ import { desc, eq } from "drizzle-orm"; -import type { AppDb } from "../../db/client"; import * as schema from "../../db/schema"; +import type { AppDb } from "../../db/client"; + export async function completeTask(args: { db: AppDb; taskId: string }): Promise { - await args.db - .update(schema.tasks) - .set({ status: "open", error: null, updatedAt: Date.now() }) - .where(eq(schema.tasks.id, args.taskId)); + await args.db + .update(schema.tasks) + .set({ status: "open", error: null, updatedAt: Date.now() }) + .where(eq(schema.tasks.id, args.taskId)); } export async function markTaskFailed(args: { - db: AppDb; - taskId: string; - message: string; + db: AppDb; + taskId: string; + message: string; }): Promise { - try { - await args.db - .update(schema.tasks) - .set({ status: "open", error: args.message, updatedAt: Date.now() }) - .where(eq(schema.tasks.id, args.taskId)); - } catch {} + try { + await args.db + .update(schema.tasks) + .set({ status: "open", error: args.message, updatedAt: Date.now() }) + .where(eq(schema.tasks.id, args.taskId)); + } catch {} } export async function insertAssistantTaskMessage(args: { - db: AppDb; - organizationId: string; - taskId: string; - content: string; + db: AppDb; + organizationId: string; + taskId: string; + content: string; }): Promise { - const { db, organizationId, taskId, content } = args; + const { db, organizationId, taskId, content } = args; - const taskMessageId = crypto.randomUUID(); - const createdAt = await getNextTaskMessageTimestamp(db, taskId); + const taskMessageId = crypto.randomUUID(); + const createdAt = await getNextTaskMessageTimestamp(db, taskId); - await db.insert(schema.taskMessages).values({ - id: taskMessageId, - organizationId, - taskId, - role: "assistant", - content, - createdAt, - }); + await db.insert(schema.taskMessages).values({ + id: taskMessageId, + organizationId, + taskId, + role: "assistant", + content, + createdAt, + }); - return taskMessageId; + return taskMessageId; } async function getNextTaskMessageTimestamp(db: AppDb, taskId: string): Promise { - const now = Date.now(); - const latest = await db.query.taskMessages.findFirst({ - where: eq(schema.taskMessages.taskId, taskId), - columns: { createdAt: true }, - orderBy: desc(schema.taskMessages.createdAt), - }); - - if (!latest?.createdAt) { - return now; - } - - return latest.createdAt >= now ? latest.createdAt + 1 : now; + const now = Date.now(); + const latest = await db.query.taskMessages.findFirst({ + where: eq(schema.taskMessages.taskId, taskId), + columns: { createdAt: true }, + orderBy: desc(schema.taskMessages.createdAt), + }); + + if (!latest?.createdAt) { + return now; + } + + return latest.createdAt >= now ? latest.createdAt + 1 : now; } diff --git a/src/server/lib/task-run-callback-token.ts b/src/server/lib/task-run-callback-token.ts index ee0d0be..54cb4ef 100644 --- a/src/server/lib/task-run-callback-token.ts +++ b/src/server/lib/task-run-callback-token.ts @@ -2,83 +2,83 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import { getTaskRunnerCallbackSecret, type AppEnv } from "@/server/env"; export type TaskRunCallbackClaims = { - executionId: string; - taskId: string; - organizationId: string; - userId: string; - provider: string; - issuedAt: number; + executionId: string; + taskId: string; + organizationId: string; + userId: string; + provider: string; + issuedAt: number; }; export function createTaskRunCallbackToken(claims: TaskRunCallbackClaims, env: AppEnv): string { - const encodedPayload = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"); - const signature = signPayload(encodedPayload, getTaskRunnerCallbackSecret(env)); - return `${encodedPayload}.${signature}`; + const encodedPayload = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url"); + const signature = signPayload(encodedPayload, getTaskRunnerCallbackSecret(env)); + return `${encodedPayload}.${signature}`; } export function verifyTaskRunCallbackToken( - token: string, - env: AppEnv, + token: string, + env: AppEnv, ): TaskRunCallbackClaims | null { - const parts = token.split("."); - if (parts.length !== 2) { - return null; - } + const parts = token.split("."); + if (parts.length !== 2) { + return null; + } - const [encodedPayload, encodedSignature] = parts; - if (!encodedPayload || !encodedSignature) { - return null; - } + const [encodedPayload, encodedSignature] = parts; + if (!encodedPayload || !encodedSignature) { + return null; + } - const expectedSignature = signPayload(encodedPayload, getTaskRunnerCallbackSecret(env)); - const provided = Buffer.from(encodedSignature); - const expected = Buffer.from(expectedSignature); + const expectedSignature = signPayload(encodedPayload, getTaskRunnerCallbackSecret(env)); + const provided = Buffer.from(encodedSignature); + const expected = Buffer.from(expectedSignature); - if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { - return null; - } + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) { + return null; + } - let parsed: unknown; - try { - parsed = JSON.parse(base64UrlDecode(encodedPayload)); - } catch { - return null; - } + let parsed: unknown; + try { + parsed = JSON.parse(base64UrlDecode(encodedPayload)); + } catch { + return null; + } - if (!isTaskRunCallbackClaims(parsed)) { - return null; - } + if (!isTaskRunCallbackClaims(parsed)) { + return null; + } - return parsed; + return parsed; } function signPayload(payload: string, secret: string): string { - return createHmac("sha256", secret).update(payload).digest("base64url"); + return createHmac("sha256", secret).update(payload).digest("base64url"); } function base64UrlDecode(value: string): string { - return Buffer.from(value, "base64url").toString("utf8"); + return Buffer.from(value, "base64url").toString("utf8"); } function isTaskRunCallbackClaims(value: unknown): value is TaskRunCallbackClaims { - if (!value || typeof value !== "object") { - return false; - } + if (!value || typeof value !== "object") { + return false; + } - const claims = value as Partial; + const claims = value as Partial; - return ( - typeof claims.executionId === "string" && - claims.executionId.trim().length > 0 && - typeof claims.taskId === "string" && - claims.taskId.trim().length > 0 && - typeof claims.organizationId === "string" && - claims.organizationId.trim().length > 0 && - typeof claims.userId === "string" && - claims.userId.trim().length > 0 && - typeof claims.provider === "string" && - claims.provider.trim().length > 0 && - typeof claims.issuedAt === "number" && - Number.isFinite(claims.issuedAt) - ); + return ( + typeof claims.executionId === "string" && + claims.executionId.trim().length > 0 && + typeof claims.taskId === "string" && + claims.taskId.trim().length > 0 && + typeof claims.organizationId === "string" && + claims.organizationId.trim().length > 0 && + typeof claims.userId === "string" && + claims.userId.trim().length > 0 && + typeof claims.provider === "string" && + claims.provider.trim().length > 0 && + typeof claims.issuedAt === "number" && + Number.isFinite(claims.issuedAt) + ); } diff --git a/src/server/lib/task-run-callback.ts b/src/server/lib/task-run-callback.ts index c84cab2..4faef0a 100644 --- a/src/server/lib/task-run-callback.ts +++ b/src/server/lib/task-run-callback.ts @@ -1,38 +1,38 @@ import { getEnv } from "@/server/env"; import { - type TaskRunCallbackClaims, - verifyTaskRunCallbackToken, + type TaskRunCallbackClaims, + verifyTaskRunCallbackToken, } from "@/server/lib/task-run-callback-token"; function getCallbackToken(request: Request): string | null { - const header = request.headers.get("authorization"); - if (!header) { - return null; - } + const header = request.headers.get("authorization"); + if (!header) { + return null; + } - const [scheme, token] = header.split(" "); - if (scheme !== "Bearer") { - return null; - } + const [scheme, token] = header.split(" "); + if (scheme !== "Bearer") { + return null; + } - const normalized = token?.trim(); - return normalized && normalized.length > 0 ? normalized : null; + const normalized = token?.trim(); + return normalized && normalized.length > 0 ? normalized : null; } export function verifyTaskRunCallback( - request: Request, - executionId: string, + request: Request, + executionId: string, ): TaskRunCallbackClaims | null { - const token = getCallbackToken(request); - if (!token) { - return null; - } + const token = getCallbackToken(request); + if (!token) { + return null; + } - const env = getEnv(); - const claims = verifyTaskRunCallbackToken(token, env); - if (!claims || claims.executionId !== executionId) { - return null; - } + const env = getEnv(); + const claims = verifyTaskRunCallbackToken(token, env); + if (!claims || claims.executionId !== executionId) { + return null; + } - return claims; + return claims; } diff --git a/src/server/lib/user-access.ts b/src/server/lib/user-access.ts index 820a557..33e1eee 100644 --- a/src/server/lib/user-access.ts +++ b/src/server/lib/user-access.ts @@ -1,10 +1,10 @@ export const USER_ACCESS_STATUS = { - approved: "approved", - pending: "pending", + approved: "approved", + pending: "pending", } as const; export function isApprovedAccessStatus( - accessStatus: string | null | undefined, + accessStatus: string | null | undefined, ): accessStatus is typeof USER_ACCESS_STATUS.approved { - return accessStatus === USER_ACCESS_STATUS.approved; + return accessStatus === USER_ACCESS_STATUS.approved; } diff --git a/src/server/middleware.ts b/src/server/middleware.ts index 1193e0e..735d9f7 100644 --- a/src/server/middleware.ts +++ b/src/server/middleware.ts @@ -6,38 +6,38 @@ import { getEnv } from "./env"; import { toSessionErrorResponse } from "./session-error-response"; export type SessionContext = { - session: { userId: string; activeOrganizationId?: string | null }; - user: { id: string; name: string; email: string; image?: string | null }; + session: { userId: string; activeOrganizationId?: string | null }; + user: { id: string; name: string; email: string; image?: string | null }; }; export const authMiddleware = createMiddleware().server(async ({ next }) => { - const request = getRequest(); - const env = getEnv(); - const auth = createAuth(env, request); - let result: Awaited>; - try { - result = await auth.api.getSession({ headers: request.headers }); - } catch (error) { - throw toSessionErrorResponse(error); - } + const request = getRequest(); + const env = getEnv(); + const auth = createAuth(env, request); + let result: Awaited>; + try { + result = await auth.api.getSession({ headers: request.headers }); + } catch (error) { + throw toSessionErrorResponse(error); + } - if (!result) { - throw new Response("Unauthorized", { status: 401 }); - } + if (!result) { + throw new Response("Unauthorized", { status: 401 }); + } - const session: SessionContext = { - session: { - userId: result.session.userId, - activeOrganizationId: result.session.activeOrganizationId, - }, - user: { - id: result.user.id, - name: result.user.name, - email: result.user.email, - image: result.user.image, - }, - }; + const session: SessionContext = { + session: { + userId: result.session.userId, + activeOrganizationId: result.session.activeOrganizationId, + }, + user: { + id: result.user.id, + name: result.user.name, + email: result.user.email, + image: result.user.image, + }, + }; - const requestOrigin = new URL(request.url).origin; - return next({ context: { session, db: getDb(env), env, requestOrigin } }); + const requestOrigin = new URL(request.url).origin; + return next({ context: { session, db: getDb(env), env, requestOrigin } }); }); diff --git a/src/server/requireSession.ts b/src/server/requireSession.ts index 6403b85..c8f3b9b 100644 --- a/src/server/requireSession.ts +++ b/src/server/requireSession.ts @@ -8,29 +8,29 @@ import { toSessionErrorResponse } from "./session-error-response"; * Returns the session context or throws a 401 Response. */ export async function requireSession(request: Request): Promise { - const env = getEnv(); - const auth = createAuth(env, request); - let result: Awaited>; - try { - result = await auth.api.getSession({ headers: request.headers }); - } catch (error) { - throw toSessionErrorResponse(error); - } + const env = getEnv(); + const auth = createAuth(env, request); + let result: Awaited>; + try { + result = await auth.api.getSession({ headers: request.headers }); + } catch (error) { + throw toSessionErrorResponse(error); + } - if (!result) { - throw new Response("Unauthorized", { status: 401 }); - } + if (!result) { + throw new Response("Unauthorized", { status: 401 }); + } - return { - session: { - userId: result.session.userId, - activeOrganizationId: result.session.activeOrganizationId, - }, - user: { - id: result.user.id, - name: result.user.name, - email: result.user.email, - image: result.user.image, - }, - }; + return { + session: { + userId: result.session.userId, + activeOrganizationId: result.session.activeOrganizationId, + }, + user: { + id: result.user.id, + name: result.user.name, + email: result.user.email, + image: result.user.image, + }, + }; } diff --git a/src/server/session-error-response.ts b/src/server/session-error-response.ts index 8476552..7fa7316 100644 --- a/src/server/session-error-response.ts +++ b/src/server/session-error-response.ts @@ -2,59 +2,59 @@ const CONNECTION_SLOT_ERROR_CODE = "53300"; const CONNECTION_SLOT_ERROR_TEXT = "remaining connection slots are reserved"; function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === "object" && value !== null; } function isValidHttpStatus(value: unknown): value is number { - return typeof value === "number" && Number.isInteger(value) && value >= 200 && value <= 599; + return typeof value === "number" && Number.isInteger(value) && value >= 200 && value <= 599; } function getNestedStatus(error: unknown): number | null { - if (!isRecord(error)) { - return null; - } + if (!isRecord(error)) { + return null; + } - const status = error.status; - if (isValidHttpStatus(status)) { - return status; - } + const status = error.status; + if (isValidHttpStatus(status)) { + return status; + } - const statusCode = error.statusCode; - if (isValidHttpStatus(statusCode)) { - return statusCode; - } + const statusCode = error.statusCode; + if (isValidHttpStatus(statusCode)) { + return statusCode; + } - return getNestedStatus(error.cause); + return getNestedStatus(error.cause); } function hasConnectionSlotError(error: unknown): boolean { - if (!isRecord(error)) { - return false; - } + if (!isRecord(error)) { + return false; + } - if (error.code === CONNECTION_SLOT_ERROR_CODE) { - return true; - } + if (error.code === CONNECTION_SLOT_ERROR_CODE) { + return true; + } - const message = error.message; - if (typeof message === "string" && message.toLowerCase().includes(CONNECTION_SLOT_ERROR_TEXT)) { - return true; - } + const message = error.message; + if (typeof message === "string" && message.toLowerCase().includes(CONNECTION_SLOT_ERROR_TEXT)) { + return true; + } - return hasConnectionSlotError(error.cause); + return hasConnectionSlotError(error.cause); } export function toSessionErrorResponse(error: unknown): Response { - if (hasConnectionSlotError(error)) { - return new Response("Authentication temporarily unavailable", { status: 503 }); - } - - const status = getNestedStatus(error); - if (status === 401 || status === 403) { - return new Response("Unauthorized", { status: 401 }); - } - - return new Response("Failed to get session", { - status: status && status >= 500 ? status : 500, - }); + if (hasConnectionSlotError(error)) { + return new Response("Authentication temporarily unavailable", { status: 503 }); + } + + const status = getNestedStatus(error); + if (status === 401 || status === 403) { + return new Response("Unauthorized", { status: 401 }); + } + + return new Response("Failed to get session", { + status: status && status >= 500 ? status : 500, + }); } diff --git a/src/server/webhook/github/check-run.ts b/src/server/webhook/github/check-run.ts index 7628f5a..5bedaa5 100644 --- a/src/server/webhook/github/check-run.ts +++ b/src/server/webhook/github/check-run.ts @@ -1,57 +1,58 @@ -import type { EmitterWebhookEvent } from "@octokit/webhooks"; -import type { AppDb } from "../../db/client"; import { pullRequests } from "../../db/schema"; import { upsertPullRequestCheckRunCounts } from "./pull-request-check-runs"; +import type { AppDb } from "../../db/client"; +import type { EmitterWebhookEvent } from "@octokit/webhooks"; + export async function handleCheckRun( - event: EmitterWebhookEvent<"check_run">, - db: AppDb, + event: EmitterWebhookEvent<"check_run">, + db: AppDb, ): Promise { - const { action, check_run: checkRun, repository } = event.payload; - - switch (action) { - case "created": - case "rerequested": - case "completed": - break; - default: - return; - } - - if (!("installation" in event.payload) || event.payload.installation == null) { - throw Error("No installation found"); - } - const installation = event.payload.installation; - - const prNumbers = checkRun.pull_requests.map((pr) => pr.number); - if (prNumbers.length === 0) { - return; - } - - console.log(`PR checks updated via check_run (${action}) for ${repository.full_name}`); - - const now = Date.now(); - await db - .insert(pullRequests) - .values( - prNumbers.map((prNumber) => ({ - id: crypto.randomUUID(), - installationId: installation.id, + const { action, check_run: checkRun, repository } = event.payload; + + switch (action) { + case "created": + case "rerequested": + case "completed": + break; + default: + return; + } + + if (!("installation" in event.payload) || event.payload.installation == null) { + throw Error("No installation found"); + } + const installation = event.payload.installation; + + const prNumbers = checkRun.pull_requests.map((pr) => pr.number); + if (prNumbers.length === 0) { + return; + } + + console.log(`PR checks updated via check_run (${action}) for ${repository.full_name}`); + + const now = Date.now(); + await db + .insert(pullRequests) + .values( + prNumbers.map((prNumber) => ({ + id: crypto.randomUUID(), + installationId: installation.id, + repository: repository.full_name, + prNumber, + openedAt: now, + })), + ) + .onConflictDoNothing({ + target: [pullRequests.repository, pullRequests.prNumber], + }); + + await upsertPullRequestCheckRunCounts({ + db, repository: repository.full_name, - prNumber, - openedAt: now, - })), - ) - .onConflictDoNothing({ - target: [pullRequests.repository, pullRequests.prNumber], + prNumbers, + checkRunId: checkRun.id, + conclusion: checkRun.conclusion, + status: checkRun.status, }); - - await upsertPullRequestCheckRunCounts({ - db, - repository: repository.full_name, - prNumbers, - checkRunId: checkRun.id, - conclusion: checkRun.conclusion, - status: checkRun.status, - }); } diff --git a/src/server/webhook/github/check-suite.ts b/src/server/webhook/github/check-suite.ts index 901b8ae..2c0cf3d 100644 --- a/src/server/webhook/github/check-suite.ts +++ b/src/server/webhook/github/check-suite.ts @@ -1,80 +1,81 @@ -import type { EmitterWebhookEvent } from "@octokit/webhooks"; import { and, eq, inArray } from "drizzle-orm"; -import type { AppDb } from "../../db/client"; import { pullRequests } from "../../db/schema"; import { resetPullRequestCheckRuns } from "./pull-request-check-runs"; +import type { AppDb } from "../../db/client"; +import type { EmitterWebhookEvent } from "@octokit/webhooks"; + export async function handleCheckSuite( - event: EmitterWebhookEvent<"check_suite">, - db: AppDb, + event: EmitterWebhookEvent<"check_suite">, + db: AppDb, ): Promise { - const { action, check_suite: checkSuite, repository } = event.payload; + const { action, check_suite: checkSuite, repository } = event.payload; - switch (action) { - case "requested": - case "rerequested": - case "completed": - break; - default: - return; - } + switch (action) { + case "requested": + case "rerequested": + case "completed": + break; + default: + return; + } - if (!("installation" in event.payload) || event.payload.installation == null) { - throw Error("No installation found"); - } - const installation = event.payload.installation; + if (!("installation" in event.payload) || event.payload.installation == null) { + throw Error("No installation found"); + } + const installation = event.payload.installation; - const prNumbers = checkSuite.pull_requests.map((pr) => pr.number); - if (prNumbers.length === 0) { - return; - } + const prNumbers = checkSuite.pull_requests.map((pr) => pr.number); + if (prNumbers.length === 0) { + return; + } - console.log(`PR checks updated via check_suite (${action}) for ${repository.full_name}`); + console.log(`PR checks updated via check_suite (${action}) for ${repository.full_name}`); - const now = Date.now(); - if (action === "requested" || action === "rerequested") { - await resetPullRequestCheckRuns({ - db, - repository: repository.full_name, - prNumbers, - }); - } + const now = Date.now(); + if (action === "requested" || action === "rerequested") { + await resetPullRequestCheckRuns({ + db, + repository: repository.full_name, + prNumbers, + }); + } - await db - .insert(pullRequests) - .values( - prNumbers.map((prNumber) => ({ - id: crypto.randomUUID(), - installationId: installation.id, - repository: repository.full_name, - branch: checkSuite.head_branch, - prNumber, - openedAt: now, - })), - ) - .onConflictDoNothing({ - target: [pullRequests.repository, pullRequests.prNumber], - }); + await db + .insert(pullRequests) + .values( + prNumbers.map((prNumber) => ({ + id: crypto.randomUUID(), + installationId: installation.id, + repository: repository.full_name, + branch: checkSuite.head_branch, + prNumber, + openedAt: now, + })), + ) + .onConflictDoNothing({ + target: [pullRequests.repository, pullRequests.prNumber], + }); - await db - .update(pullRequests) - .set({ - branch: checkSuite.head_branch ?? undefined, - checksCount: checkSuite.latest_check_runs_count, - checksCompletedCount: - action === "completed" && checkSuite.latest_check_runs_count != null - ? checkSuite.latest_check_runs_count - : action === "requested" || action === "rerequested" - ? null - : undefined, - checksState: checkSuite.status, - checksConclusion: checkSuite.conclusion, - checksUpdatedAt: now, - }) - .where( - and( - eq(pullRequests.repository, repository.full_name), - inArray(pullRequests.prNumber, prNumbers), - ), - ); + await db + .update(pullRequests) + .set({ + branch: checkSuite.head_branch ?? undefined, + checksCount: checkSuite.latest_check_runs_count, + checksCompletedCount: + action === "completed" && checkSuite.latest_check_runs_count != null + ? checkSuite.latest_check_runs_count + : action === "requested" || action === "rerequested" + ? null + : undefined, + checksState: checkSuite.status, + checksConclusion: checkSuite.conclusion, + checksUpdatedAt: now, + }) + .where( + and( + eq(pullRequests.repository, repository.full_name), + inArray(pullRequests.prNumber, prNumbers), + ), + ); } diff --git a/src/server/webhook/github/installation.ts b/src/server/webhook/github/installation.ts index 6af2fac..100e8e4 100644 --- a/src/server/webhook/github/installation.ts +++ b/src/server/webhook/github/installation.ts @@ -1,63 +1,66 @@ -import type { EmitterWebhookEvent } from "@octokit/webhooks"; import { eq } from "drizzle-orm"; -import type { AppDb } from "../../db/client"; import { installations } from "../../db/schema"; +import type { AppDb } from "../../db/client"; +import type { EmitterWebhookEvent } from "@octokit/webhooks"; + export async function handleInstallation( - event: EmitterWebhookEvent<"installation">, - db: AppDb, + event: EmitterWebhookEvent<"installation">, + db: AppDb, ): Promise { - const { action, installation } = event.payload; - - if (!installation.account) { - console.error("Installation event received without account information"); - return; - } - - switch (action) { - case "created": { - const accountLogin = - "login" in installation.account ? installation.account.login : installation.account.slug; - const accountType = - "type" in installation.account ? installation.account.type : "Organization"; - const now = Date.now(); - - await db - .insert(installations) - .values({ - installationId: installation.id, - accountLogin, - accountType, - createdAt: now, - }) - .onConflictDoUpdate({ - target: installations.installationId, - set: { - accountLogin, - accountType, - updatedAt: now, - deletedAt: null, - }, - }); - - console.log(`App installed: ${accountLogin} (${installation.id})`); - break; + const { action, installation } = event.payload; + + if (!installation.account) { + console.error("Installation event received without account information"); + return; } - case "deleted": { - await db - .update(installations) - .set({ deletedAt: Date.now() }) - .where(eq(installations.installationId, installation.id)); - - const accountLogin = - "login" in installation.account - ? installation.account.login - : "slug" in installation.account - ? installation.account.slug - : "unknown"; - console.log(`App uninstalled: ${accountLogin} (${installation.id})`); - break; + switch (action) { + case "created": { + const accountLogin = + "login" in installation.account + ? installation.account.login + : installation.account.slug; + const accountType = + "type" in installation.account ? installation.account.type : "Organization"; + const now = Date.now(); + + await db + .insert(installations) + .values({ + installationId: installation.id, + accountLogin, + accountType, + createdAt: now, + }) + .onConflictDoUpdate({ + target: installations.installationId, + set: { + accountLogin, + accountType, + updatedAt: now, + deletedAt: null, + }, + }); + + console.log(`App installed: ${accountLogin} (${installation.id})`); + break; + } + + case "deleted": { + await db + .update(installations) + .set({ deletedAt: Date.now() }) + .where(eq(installations.installationId, installation.id)); + + const accountLogin = + "login" in installation.account + ? installation.account.login + : "slug" in installation.account + ? installation.account.slug + : "unknown"; + console.log(`App uninstalled: ${accountLogin} (${installation.id})`); + break; + } } - } } diff --git a/src/server/webhook/github/ping.ts b/src/server/webhook/github/ping.ts index d237b2a..5b541f8 100644 --- a/src/server/webhook/github/ping.ts +++ b/src/server/webhook/github/ping.ts @@ -1,5 +1,5 @@ import type { EmitterWebhookEvent } from "@octokit/webhooks"; export async function handlePing(event: EmitterWebhookEvent<"ping">): Promise { - console.log("Received ping event:", event.payload.zen); + console.log("Received ping event:", event.payload.zen); } diff --git a/src/server/webhook/github/pull-request-check-runs.ts b/src/server/webhook/github/pull-request-check-runs.ts index 21c4a2e..6c8bd9a 100644 --- a/src/server/webhook/github/pull-request-check-runs.ts +++ b/src/server/webhook/github/pull-request-check-runs.ts @@ -1,133 +1,140 @@ import { and, eq, inArray } from "drizzle-orm"; -import type { AppDb } from "../../db/client"; import { pullRequestCheckRuns, pullRequests } from "../../db/schema"; +import type { AppDb } from "../../db/client"; + interface ResetPullRequestCheckRunsParams { - db: AppDb; - repository: string; - prNumbers: number[]; + db: AppDb; + repository: string; + prNumbers: number[]; } interface UpsertPullRequestCheckRunParams extends ResetPullRequestCheckRunsParams { - checkRunId: number; - conclusion: string | null; - status: string; + checkRunId: number; + conclusion: string | null; + status: string; } export async function resetPullRequestCheckRuns({ - db, - repository, - prNumbers, + db, + repository, + prNumbers, }: ResetPullRequestCheckRunsParams): Promise { - if (prNumbers.length === 0) { - return; - } + if (prNumbers.length === 0) { + return; + } - await db - .delete(pullRequestCheckRuns) - .where( - and( - eq(pullRequestCheckRuns.repository, repository), - inArray(pullRequestCheckRuns.prNumber, prNumbers), - ), - ); + await db + .delete(pullRequestCheckRuns) + .where( + and( + eq(pullRequestCheckRuns.repository, repository), + inArray(pullRequestCheckRuns.prNumber, prNumbers), + ), + ); } export async function upsertPullRequestCheckRunCounts({ - db, - repository, - prNumbers, - checkRunId, - conclusion, - status, + db, + repository, + prNumbers, + checkRunId, + conclusion, + status, }: UpsertPullRequestCheckRunParams): Promise { - if (prNumbers.length === 0) { - return; - } + if (prNumbers.length === 0) { + return; + } - const now = Date.now(); - const checkRunIdText = String(checkRunId); + const now = Date.now(); + const checkRunIdText = String(checkRunId); - await db - .insert(pullRequestCheckRuns) - .values( - prNumbers.map((prNumber) => ({ - id: `${repository}:${prNumber}:${checkRunIdText}`, - repository, - prNumber, - checkRunId: checkRunIdText, - status, - conclusion, - updatedAt: now, - })), - ) - .onConflictDoUpdate({ - target: [ - pullRequestCheckRuns.repository, - pullRequestCheckRuns.prNumber, - pullRequestCheckRuns.checkRunId, - ], - set: { - status, - conclusion, - updatedAt: now, - }, - }); + await db + .insert(pullRequestCheckRuns) + .values( + prNumbers.map((prNumber) => ({ + id: `${repository}:${prNumber}:${checkRunIdText}`, + repository, + prNumber, + checkRunId: checkRunIdText, + status, + conclusion, + updatedAt: now, + })), + ) + .onConflictDoUpdate({ + target: [ + pullRequestCheckRuns.repository, + pullRequestCheckRuns.prNumber, + pullRequestCheckRuns.checkRunId, + ], + set: { + status, + conclusion, + updatedAt: now, + }, + }); - const [checkRunRows, pullRequestRows] = await Promise.all([ - db - .select({ - prNumber: pullRequestCheckRuns.prNumber, - status: pullRequestCheckRuns.status, - }) - .from(pullRequestCheckRuns) - .where( - and( - eq(pullRequestCheckRuns.repository, repository), - inArray(pullRequestCheckRuns.prNumber, prNumbers), - ), - ), - db - .select({ - prNumber: pullRequests.prNumber, - checksCount: pullRequests.checksCount, - }) - .from(pullRequests) - .where( - and(eq(pullRequests.repository, repository), inArray(pullRequests.prNumber, prNumbers)), - ), - ]); + const [checkRunRows, pullRequestRows] = await Promise.all([ + db + .select({ + prNumber: pullRequestCheckRuns.prNumber, + status: pullRequestCheckRuns.status, + }) + .from(pullRequestCheckRuns) + .where( + and( + eq(pullRequestCheckRuns.repository, repository), + inArray(pullRequestCheckRuns.prNumber, prNumbers), + ), + ), + db + .select({ + prNumber: pullRequests.prNumber, + checksCount: pullRequests.checksCount, + }) + .from(pullRequests) + .where( + and( + eq(pullRequests.repository, repository), + inArray(pullRequests.prNumber, prNumbers), + ), + ), + ]); - const countsByPrNumber = new Map(); - for (const row of checkRunRows) { - const current = countsByPrNumber.get(row.prNumber) ?? { completed: 0, total: 0 }; - current.total += 1; - if (row.status === "completed") { - current.completed += 1; + const countsByPrNumber = new Map(); + for (const row of checkRunRows) { + const current = countsByPrNumber.get(row.prNumber) ?? { completed: 0, total: 0 }; + current.total += 1; + if (row.status === "completed") { + current.completed += 1; + } + countsByPrNumber.set(row.prNumber, current); } - countsByPrNumber.set(row.prNumber, current); - } - await Promise.all( - pullRequestRows.map((pullRequestRow) => { - const counts = countsByPrNumber.get(pullRequestRow.prNumber) ?? { completed: 0, total: 0 }; - const totalChecks = Math.max(pullRequestRow.checksCount ?? 0, counts.total); + await Promise.all( + pullRequestRows.map((pullRequestRow) => { + const counts = countsByPrNumber.get(pullRequestRow.prNumber) ?? { + completed: 0, + total: 0, + }; + const totalChecks = Math.max(pullRequestRow.checksCount ?? 0, counts.total); - return db - .update(pullRequests) - .set({ - checksCompletedCount: counts.completed, - checksCount: totalChecks > 0 ? totalChecks : null, - checksConclusion: status === "completed" ? undefined : conclusion, - checksState: status === "completed" ? undefined : status, - checksUpdatedAt: now, - }) - .where( - and( - eq(pullRequests.repository, repository), - eq(pullRequests.prNumber, pullRequestRow.prNumber), - ), - ); - }), - ); + return db + .update(pullRequests) + .set({ + checksCompletedCount: counts.completed, + checksCount: totalChecks > 0 ? totalChecks : null, + checksConclusion: status === "completed" ? undefined : conclusion, + checksState: status === "completed" ? undefined : status, + checksUpdatedAt: now, + }) + .where( + and( + eq(pullRequests.repository, repository), + eq(pullRequests.prNumber, pullRequestRow.prNumber), + ), + ); + }), + ); } diff --git a/src/server/webhook/github/pull-request-review.ts b/src/server/webhook/github/pull-request-review.ts index f1aae22..f37236d 100644 --- a/src/server/webhook/github/pull-request-review.ts +++ b/src/server/webhook/github/pull-request-review.ts @@ -1,87 +1,88 @@ -import type { EmitterWebhookEvent } from "@octokit/webhooks"; import { and, eq, isNull, lte, or } from "drizzle-orm"; -import type { AppDb } from "../../db/client"; import { pullRequests } from "../../db/schema"; +import type { AppDb } from "../../db/client"; +import type { EmitterWebhookEvent } from "@octokit/webhooks"; + export async function handlePullRequestReview( - event: EmitterWebhookEvent<"pull_request_review">, - db: AppDb, + event: EmitterWebhookEvent<"pull_request_review">, + db: AppDb, ): Promise { - const { action, pull_request: pr, repository, review } = event.payload; + const { action, pull_request: pr, repository, review } = event.payload; - let reviewState: string; - switch (action) { - case "submitted": - case "edited": - reviewState = review.state.toLowerCase(); - break; - case "dismissed": - reviewState = review.state?.toLowerCase() ?? "dismissed"; - break; - default: - return; - } + let reviewState: string; + switch (action) { + case "submitted": + case "edited": + reviewState = review.state.toLowerCase(); + break; + case "dismissed": + reviewState = review.state?.toLowerCase() ?? "dismissed"; + break; + default: + return; + } - console.log(`PR #${pr.number} review ${action}: ${reviewState}`); + console.log(`PR #${pr.number} review ${action}: ${reviewState}`); - const reviewEventAt = - toMsTimestamp(("submitted_at" in review ? review.submitted_at : undefined) ?? undefined) ?? - toMsTimestamp(("updated_at" in review ? review.updated_at : undefined) ?? undefined) ?? - Date.now(); - const reviewWhere = and( - eq(pullRequests.repository, repository.full_name), - eq(pullRequests.prNumber, pr.number), - or(isNull(pullRequests.reviewUpdatedAt), lte(pullRequests.reviewUpdatedAt, reviewEventAt)), - ); + const reviewEventAt = + toMsTimestamp(("submitted_at" in review ? review.submitted_at : undefined) ?? undefined) ?? + toMsTimestamp(("updated_at" in review ? review.updated_at : undefined) ?? undefined) ?? + Date.now(); + const reviewWhere = and( + eq(pullRequests.repository, repository.full_name), + eq(pullRequests.prNumber, pr.number), + or(isNull(pullRequests.reviewUpdatedAt), lte(pullRequests.reviewUpdatedAt, reviewEventAt)), + ); - const updated = await db - .update(pullRequests) - .set({ - branch: pr.head.ref, - reviewState, - reviewUpdatedAt: reviewEventAt, - }) - .where(reviewWhere) - .returning({ id: pullRequests.id }); + const updated = await db + .update(pullRequests) + .set({ + branch: pr.head.ref, + reviewState, + reviewUpdatedAt: reviewEventAt, + }) + .where(reviewWhere) + .returning({ id: pullRequests.id }); - if (updated.length > 0) { - return; - } + if (updated.length > 0) { + return; + } - if (!("installation" in event.payload) || event.payload.installation == null) { - return; - } + if (!("installation" in event.payload) || event.payload.installation == null) { + return; + } - await db - .insert(pullRequests) - .values({ - id: crypto.randomUUID(), - installationId: event.payload.installation.id, - repository: repository.full_name, - branch: pr.head.ref, - prNumber: pr.number, - openedAt: reviewEventAt, - readyAt: pr.draft ? null : reviewEventAt, - }) - .onConflictDoNothing({ - target: [pullRequests.repository, pullRequests.prNumber], - }); + await db + .insert(pullRequests) + .values({ + id: crypto.randomUUID(), + installationId: event.payload.installation.id, + repository: repository.full_name, + branch: pr.head.ref, + prNumber: pr.number, + openedAt: reviewEventAt, + readyAt: pr.draft ? null : reviewEventAt, + }) + .onConflictDoNothing({ + target: [pullRequests.repository, pullRequests.prNumber], + }); - await db - .update(pullRequests) - .set({ - branch: pr.head.ref, - reviewState, - reviewUpdatedAt: reviewEventAt, - }) - .where(reviewWhere); + await db + .update(pullRequests) + .set({ + branch: pr.head.ref, + reviewState, + reviewUpdatedAt: reviewEventAt, + }) + .where(reviewWhere); } function toMsTimestamp(value: string | null | undefined): number | null { - if (!value) { - return null; - } + if (!value) { + return null; + } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; } diff --git a/src/server/webhook/github/pull-request.ts b/src/server/webhook/github/pull-request.ts index 1b662e5..ae93718 100644 --- a/src/server/webhook/github/pull-request.ts +++ b/src/server/webhook/github/pull-request.ts @@ -1,223 +1,224 @@ -import type { EmitterWebhookEvent } from "@octokit/webhooks"; import { and, eq, isNull, lte, or } from "drizzle-orm"; -import type { AppDb } from "../../db/client"; import { pullRequests } from "../../db/schema"; import { resetPullRequestCheckRuns } from "./pull-request-check-runs"; +import type { AppDb } from "../../db/client"; +import type { EmitterWebhookEvent } from "@octokit/webhooks"; + export async function handlePullRequest( - event: EmitterWebhookEvent<"pull_request">, - db: AppDb, + event: EmitterWebhookEvent<"pull_request">, + db: AppDb, ): Promise { - const { action, pull_request: pr, repository } = event.payload; - const branch = pr.head.ref; - const pullRequestEventAt = toMsTimestamp(pr.updated_at) ?? Date.now(); - const pullRequestWhere = and( - eq(pullRequests.prNumber, pr.number), - eq(pullRequests.repository, repository.full_name), - ); - - if (!("installation" in event.payload) || event.payload.installation == null) { - throw Error("No installation found"); - } - const installation = event.payload.installation; - - switch (action) { - case "closed": { - if (pr.merged) { - console.log(`PR #${pr.number} merged: ${pr.title}`); - - await db - .update(pullRequests) - .set({ - mergedAt: pr.merged_at ? new Date(pr.merged_at).getTime() : null, - mergedBy: pr.merged_by?.login, - branch, - state: "merged", - }) - .where(pullRequestWhere); - - return; - } - - console.log(`PR #${pr.number} closed without merge: ${pr.title}`); - await db.update(pullRequests).set({ branch, state: "closed" }).where(pullRequestWhere); - break; + const { action, pull_request: pr, repository } = event.payload; + const branch = pr.head.ref; + const pullRequestEventAt = toMsTimestamp(pr.updated_at) ?? Date.now(); + const pullRequestWhere = and( + eq(pullRequests.prNumber, pr.number), + eq(pullRequests.repository, repository.full_name), + ); + + if (!("installation" in event.payload) || event.payload.installation == null) { + throw Error("No installation found"); } - - case "opened": { - console.log(`PR #${pr.number} opened: ${pr.title}`); - const now = Date.now(); - await db - .insert(pullRequests) - .values({ - id: crypto.randomUUID(), - installationId: installation.id, - repository: repository.full_name, - branch, - prNumber: pr.number, - openedAt: now, - readyAt: pr.draft ? null : now, - state: pr.draft ? "draft" : "open", - reviewState: null, - reviewUpdatedAt: null, - checksState: null, - checksConclusion: null, - checksUpdatedAt: null, - }) - .onConflictDoUpdate({ - target: [pullRequests.repository, pullRequests.prNumber], - set: { - installationId: installation.id, - branch, - state: pr.draft ? "draft" : "open", - mergedAt: null, - mergedBy: null, - reviewState: null, - reviewUpdatedAt: null, - checksCount: null, - checksCompletedCount: null, - checksState: null, - checksConclusion: null, - checksUpdatedAt: null, - }, - }); - await resetPullRequestCheckRuns({ - db, - repository: repository.full_name, - prNumbers: [pr.number], - }); - break; - } - - case "ready_for_review": { - console.log(`PR #${pr.number} ready for review: ${pr.title}`); - await db - .update(pullRequests) - .set({ readyAt: Date.now(), branch, state: "open" }) - .where(pullRequestWhere); - break; + const installation = event.payload.installation; + + switch (action) { + case "closed": { + if (pr.merged) { + console.log(`PR #${pr.number} merged: ${pr.title}`); + + await db + .update(pullRequests) + .set({ + mergedAt: pr.merged_at ? new Date(pr.merged_at).getTime() : null, + mergedBy: pr.merged_by?.login, + branch, + state: "merged", + }) + .where(pullRequestWhere); + + return; + } + + console.log(`PR #${pr.number} closed without merge: ${pr.title}`); + await db.update(pullRequests).set({ branch, state: "closed" }).where(pullRequestWhere); + break; + } + + case "opened": { + console.log(`PR #${pr.number} opened: ${pr.title}`); + const now = Date.now(); + await db + .insert(pullRequests) + .values({ + id: crypto.randomUUID(), + installationId: installation.id, + repository: repository.full_name, + branch, + prNumber: pr.number, + openedAt: now, + readyAt: pr.draft ? null : now, + state: pr.draft ? "draft" : "open", + reviewState: null, + reviewUpdatedAt: null, + checksState: null, + checksConclusion: null, + checksUpdatedAt: null, + }) + .onConflictDoUpdate({ + target: [pullRequests.repository, pullRequests.prNumber], + set: { + installationId: installation.id, + branch, + state: pr.draft ? "draft" : "open", + mergedAt: null, + mergedBy: null, + reviewState: null, + reviewUpdatedAt: null, + checksCount: null, + checksCompletedCount: null, + checksState: null, + checksConclusion: null, + checksUpdatedAt: null, + }, + }); + await resetPullRequestCheckRuns({ + db, + repository: repository.full_name, + prNumbers: [pr.number], + }); + break; + } + + case "ready_for_review": { + console.log(`PR #${pr.number} ready for review: ${pr.title}`); + await db + .update(pullRequests) + .set({ readyAt: Date.now(), branch, state: "open" }) + .where(pullRequestWhere); + break; + } + + case "synchronize": { + console.log(`PR #${pr.number} synchronized: ${pr.title}`); + await db + .update(pullRequests) + .set({ + branch, + state: pr.draft ? "draft" : "open", + }) + .where(pullRequestWhere); + + await db + .update(pullRequests) + .set({ + reviewState: null, + reviewUpdatedAt: pullRequestEventAt, + }) + .where( + and( + pullRequestWhere, + or( + isNull(pullRequests.reviewUpdatedAt), + lte(pullRequests.reviewUpdatedAt, pullRequestEventAt), + ), + ), + ); + + await db + .update(pullRequests) + .set({ + checksCount: null, + checksCompletedCount: null, + checksState: null, + checksConclusion: null, + checksUpdatedAt: pullRequestEventAt, + }) + .where( + and( + pullRequestWhere, + or( + isNull(pullRequests.checksUpdatedAt), + lte(pullRequests.checksUpdatedAt, pullRequestEventAt), + ), + ), + ); + await resetPullRequestCheckRuns({ + db, + repository: repository.full_name, + prNumbers: [pr.number], + }); + break; + } + + case "converted_to_draft": { + console.log(`PR #${pr.number} converted to draft: ${pr.title}`); + await db + .update(pullRequests) + .set({ + branch, + readyAt: null, + state: "draft", + mergedAt: null, + mergedBy: null, + reviewState: null, + reviewUpdatedAt: Date.now(), + checksCount: null, + checksCompletedCount: null, + checksState: null, + checksConclusion: null, + checksUpdatedAt: Date.now(), + }) + .where(pullRequestWhere); + await resetPullRequestCheckRuns({ + db, + repository: repository.full_name, + prNumbers: [pr.number], + }); + break; + } + + case "reopened": { + console.log(`PR #${pr.number} reopened: ${pr.title}`); + await db + .update(pullRequests) + .set({ + mergedAt: null, + mergedBy: null, + readyAt: pr.draft ? null : Date.now(), + branch, + state: pr.draft ? "draft" : "open", + reviewState: null, + reviewUpdatedAt: Date.now(), + checksCount: null, + checksCompletedCount: null, + checksState: null, + checksConclusion: null, + checksUpdatedAt: Date.now(), + }) + .where(pullRequestWhere); + await resetPullRequestCheckRuns({ + db, + repository: repository.full_name, + prNumbers: [pr.number], + }); + break; + } + + case "review_requested": + case "review_request_removed": { + console.log(`PR #${pr.number} ${action}: ${pr.title}`); + await db.update(pullRequests).set({ branch }).where(pullRequestWhere); + break; + } } - - case "synchronize": { - console.log(`PR #${pr.number} synchronized: ${pr.title}`); - await db - .update(pullRequests) - .set({ - branch, - state: pr.draft ? "draft" : "open", - }) - .where(pullRequestWhere); - - await db - .update(pullRequests) - .set({ - reviewState: null, - reviewUpdatedAt: pullRequestEventAt, - }) - .where( - and( - pullRequestWhere, - or( - isNull(pullRequests.reviewUpdatedAt), - lte(pullRequests.reviewUpdatedAt, pullRequestEventAt), - ), - ), - ); - - await db - .update(pullRequests) - .set({ - checksCount: null, - checksCompletedCount: null, - checksState: null, - checksConclusion: null, - checksUpdatedAt: pullRequestEventAt, - }) - .where( - and( - pullRequestWhere, - or( - isNull(pullRequests.checksUpdatedAt), - lte(pullRequests.checksUpdatedAt, pullRequestEventAt), - ), - ), - ); - await resetPullRequestCheckRuns({ - db, - repository: repository.full_name, - prNumbers: [pr.number], - }); - break; - } - - case "converted_to_draft": { - console.log(`PR #${pr.number} converted to draft: ${pr.title}`); - await db - .update(pullRequests) - .set({ - branch, - readyAt: null, - state: "draft", - mergedAt: null, - mergedBy: null, - reviewState: null, - reviewUpdatedAt: Date.now(), - checksCount: null, - checksCompletedCount: null, - checksState: null, - checksConclusion: null, - checksUpdatedAt: Date.now(), - }) - .where(pullRequestWhere); - await resetPullRequestCheckRuns({ - db, - repository: repository.full_name, - prNumbers: [pr.number], - }); - break; - } - - case "reopened": { - console.log(`PR #${pr.number} reopened: ${pr.title}`); - await db - .update(pullRequests) - .set({ - mergedAt: null, - mergedBy: null, - readyAt: pr.draft ? null : Date.now(), - branch, - state: pr.draft ? "draft" : "open", - reviewState: null, - reviewUpdatedAt: Date.now(), - checksCount: null, - checksCompletedCount: null, - checksState: null, - checksConclusion: null, - checksUpdatedAt: Date.now(), - }) - .where(pullRequestWhere); - await resetPullRequestCheckRuns({ - db, - repository: repository.full_name, - prNumbers: [pr.number], - }); - break; - } - - case "review_requested": - case "review_request_removed": { - console.log(`PR #${pr.number} ${action}: ${pr.title}`); - await db.update(pullRequests).set({ branch }).where(pullRequestWhere); - break; - } - } } function toMsTimestamp(value: string | null | undefined): number | null { - if (!value) { - return null; - } + if (!value) { + return null; + } - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : null; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; } diff --git a/src/shared/task-stream-events.ts b/src/shared/task-stream-events.ts index e86ebfd..bf1d656 100644 --- a/src/shared/task-stream-events.ts +++ b/src/shared/task-stream-events.ts @@ -9,38 +9,38 @@ import type { Event as OpenCodeEvent } from "@opencode-ai/sdk"; * JSON-stringified `Event` object) */ export type TaskStreamEvent = TaskStreamEventBase & - (AssistantEventBody | OpenCodeEventBody | TaskLifecycleEventBody); + (AssistantEventBody | OpenCodeEventBody | TaskLifecycleEventBody); export type TaskStreamEventBase = { - id: string; - taskId: string; - runId: string; - createdAt: number; + id: string; + taskId: string; + runId: string; + createdAt: number; }; type AssistantEventBody = { - kind: "assistant"; - payload: string; + kind: "assistant"; + payload: string; }; type OpenCodeEventBody = { - kind: `opencode.${OpenCodeEvent["type"]}`; - payload: string; + kind: `opencode.${OpenCodeEvent["type"]}`; + payload: string; }; type TaskLifecyclePhase = "runner" | "clone" | "setup" | "assistant"; type TaskLifecycleStatus = "running" | "completed" | "skipped" | "error"; export type TaskLifecycleEventPayload = { - phase: TaskLifecyclePhase; - status: TaskLifecycleStatus; - message: string; - details?: string; + phase: TaskLifecyclePhase; + status: TaskLifecycleStatus; + message: string; + details?: string; }; type TaskLifecycleEventBody = { - kind: "task.lifecycle"; - payload: string; + kind: "task.lifecycle"; + payload: string; }; /** @@ -49,77 +49,80 @@ type TaskLifecycleEventBody = { * parsed or the event kind is not an opencode event. */ export function parseOpenCodeEventPayload(event: TaskStreamEvent): OpenCodeEvent | null { - if (event.kind === "assistant") { - return null; - } - - if (event.kind === "task.lifecycle") { - return null; - } - - try { - const parsed: unknown = JSON.parse(event.payload); - if ( - parsed && - typeof parsed === "object" && - "type" in parsed && - typeof parsed.type === "string" && - "properties" in parsed - ) { - return parsed as OpenCodeEvent; + if (event.kind === "assistant") { + return null; } - return null; - } catch { - return null; - } -} -export function parseTaskLifecycleEventPayload( - event: TaskStreamEvent, -): TaskLifecycleEventPayload | null { - if (event.kind !== "task.lifecycle") { - return null; - } - - try { - const parsed: unknown = JSON.parse(event.payload); - if ( - !parsed || - typeof parsed !== "object" || - !("phase" in parsed) || - !("status" in parsed) || - !("message" in parsed) - ) { - return null; + if (event.kind === "task.lifecycle") { + return null; } - const phase = (parsed as Record).phase; - const status = (parsed as Record).status; - const message = (parsed as Record).message; - const details = (parsed as Record).details; - - if ( - (phase !== "runner" && phase !== "clone" && phase !== "setup" && phase !== "assistant") || - (status !== "running" && - status !== "completed" && - status !== "skipped" && - status !== "error") || - typeof message !== "string" - ) { - return null; + try { + const parsed: unknown = JSON.parse(event.payload); + if ( + parsed && + typeof parsed === "object" && + "type" in parsed && + typeof parsed.type === "string" && + "properties" in parsed + ) { + return parsed as OpenCodeEvent; + } + return null; + } catch { + return null; } +} - if (details !== undefined && typeof details !== "string") { - return null; +export function parseTaskLifecycleEventPayload( + event: TaskStreamEvent, +): TaskLifecycleEventPayload | null { + if (event.kind !== "task.lifecycle") { + return null; } - return { - phase, - status, - message, - details, - }; - } catch { - return null; - } + try { + const parsed: unknown = JSON.parse(event.payload); + if ( + !parsed || + typeof parsed !== "object" || + !("phase" in parsed) || + !("status" in parsed) || + !("message" in parsed) + ) { + return null; + } + + const phase = (parsed as Record).phase; + const status = (parsed as Record).status; + const message = (parsed as Record).message; + const details = (parsed as Record).details; + + if ( + (phase !== "runner" && + phase !== "clone" && + phase !== "setup" && + phase !== "assistant") || + (status !== "running" && + status !== "completed" && + status !== "skipped" && + status !== "error") || + typeof message !== "string" + ) { + return null; + } + + if (details !== undefined && typeof details !== "string") { + return null; + } + + return { + phase, + status, + message, + details, + }; + } catch { + return null; + } } diff --git a/tsconfig.json b/tsconfig.json index 7ad2336..8aad0cd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,22 @@ { - "exclude": ["release"], - "include": ["**/*.ts", "**/*.tsx"], - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "jsx": "react-jsx", - "lib": ["ES2023", "DOM", "DOM.Iterable"], - "skipLibCheck": true, - "isolatedModules": true, - "resolveJsonModule": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "noEmit": true, - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] + "exclude": ["release"], + "include": ["**/*.ts", "**/*.tsx"], + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "isolatedModules": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } } - } } diff --git a/vercel.json b/vercel.json index 243a6d6..8988d66 100644 --- a/vercel.json +++ b/vercel.json @@ -1,3 +1,3 @@ { - "buildCommand": "bun run build" + "buildCommand": "bun run build" } diff --git a/vite.config.ts b/vite.config.ts index 912c5ae..0d0885b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,26 +1,26 @@ -import { defineConfig } from "vite"; -import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import tailwindcss from "@tailwindcss/vite"; +import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import react from "@vitejs/plugin-react"; -import tsConfigPaths from "vite-tsconfig-paths"; import { nitro } from "nitro/vite"; +import { defineConfig } from "vite"; +import tsConfigPaths from "vite-tsconfig-paths"; export default defineConfig({ - plugins: [ - tailwindcss(), - tsConfigPaths({ projects: ["./tsconfig.json"] }), - tanstackStart({}), - nitro({ - vercel: { - functions: { - maxDuration: 300, - }, - }, - }), - react({ - babel: { - plugins: [["babel-plugin-react-compiler"]], - }, - }), - ], + plugins: [ + tailwindcss(), + tsConfigPaths({ projects: ["./tsconfig.json"] }), + tanstackStart({}), + nitro({ + vercel: { + functions: { + maxDuration: 300, + }, + }, + }), + react({ + babel: { + plugins: [["babel-plugin-react-compiler"]], + }, + }), + ], });