mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-04 04:54:50 +00:00
Compare commits
52 Commits
openai-web
...
brendan-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a855229052 | ||
|
|
ba244a6e62 | ||
|
|
a638728201 | ||
|
|
7cb690d7e5 | ||
|
|
31ad6e85ba | ||
|
|
ea04b23745 | ||
|
|
05c3cfb2aa | ||
|
|
f54e4b60cc | ||
|
|
97c15a087d | ||
|
|
b90de755f9 | ||
|
|
8864fdce2f | ||
|
|
5179b87aef | ||
|
|
66a56551be | ||
|
|
7123aad5a8 | ||
|
|
d6fc5f414b | ||
|
|
77fc88c8ad | ||
|
|
cafc2b204b | ||
|
|
36709aae5f | ||
|
|
fac0dd8862 | ||
|
|
73e107250d | ||
|
|
b746aec493 | ||
|
|
ad40b65b0b | ||
|
|
971383661a | ||
|
|
b0017bf1b9 | ||
|
|
0c0c6f3bdb | ||
|
|
b480a38d31 | ||
|
|
4167e25c7e | ||
|
|
1041ae91d1 | ||
|
|
898456a25c | ||
|
|
53d0b58ebf | ||
|
|
2b0baf97bd | ||
|
|
0dbfefa080 | ||
|
|
d1c49ba210 | ||
|
|
3ea72aec21 | ||
|
|
9717383823 | ||
|
|
5d9e780029 | ||
|
|
aa11fa865d | ||
|
|
9a64bdb539 | ||
|
|
71693cc24b | ||
|
|
28cb8efa4b | ||
|
|
b0bd728275 | ||
|
|
700f57112a | ||
|
|
0a80ef4278 | ||
|
|
4f9667c4bb | ||
|
|
be142b00bd | ||
|
|
45c2573979 | ||
|
|
79e9d19019 | ||
|
|
958a80cc05 | ||
|
|
4647aa80ac | ||
|
|
a379eb3867 | ||
|
|
cbe1337f24 | ||
|
|
50f6aa3763 |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -25,3 +25,4 @@ r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
-OpenCodeEngineer bot that spams issues
|
||||
|
||||
24
.github/workflows/close-issues.yml
vendored
Normal file
24
.github/workflows/close-issues.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
name: close-issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *" # Daily at 2:00 AM
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
close:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Close stale issues
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: bun script/github/close-issues.ts
|
||||
74
.github/workflows/publish.yml
vendored
74
.github/workflows/publish.yml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
repo: ${{ steps.version.outputs.repo }}
|
||||
|
||||
build-cli:
|
||||
build-cli-linux-win:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
@@ -94,17 +94,74 @@ jobs:
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
OPENCODE_BUILD_OS: linux,win32
|
||||
OPENCODE_SKIP_RELEASE_UPLOAD: "1"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-cli
|
||||
name: opencode-cli-linux-win
|
||||
path: packages/opencode/dist
|
||||
|
||||
build-cli-darwin:
|
||||
needs: version
|
||||
runs-on: macos-latest
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-tags: true
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@v2
|
||||
with:
|
||||
keychain: build
|
||||
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Resolve signing identity
|
||||
run: |
|
||||
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
|
||||
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
|
||||
if [ -z "$CERT_ID" ]; then
|
||||
echo "Developer ID Application identity not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Build
|
||||
id: build
|
||||
run: |
|
||||
./packages/opencode/script/build.ts
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
|
||||
OPENCODE_BUILD_OS: darwin
|
||||
OPENCODE_SKIP_RELEASE_UPLOAD: "1"
|
||||
|
||||
- name: Verify darwin signatures
|
||||
run: |
|
||||
for file in packages/opencode/dist/opencode-darwin-*/bin/opencode; do
|
||||
codesign -vvv --verify "$file"
|
||||
done
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-cli-darwin
|
||||
path: packages/opencode/dist
|
||||
outputs:
|
||||
version: ${{ needs.version.outputs.version }}
|
||||
|
||||
build-tauri:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
continue-on-error: false
|
||||
strategy:
|
||||
@@ -248,7 +305,6 @@ jobs:
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
continue-on-error: false
|
||||
strategy:
|
||||
@@ -372,7 +428,8 @@ jobs:
|
||||
publish:
|
||||
needs:
|
||||
- version
|
||||
- build-cli
|
||||
- build-cli-darwin
|
||||
- build-cli-linux-win
|
||||
- build-tauri
|
||||
- build-electron
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -408,8 +465,9 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: opencode-cli
|
||||
pattern: opencode-cli-*
|
||||
path: packages/opencode/dist
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
if: needs.version.outputs.release
|
||||
|
||||
33
.github/workflows/stale-issues.yml
vendored
33
.github/workflows/stale-issues.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: stale-issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *" # Daily at 1:30 AM
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DAYS_BEFORE_STALE: 90
|
||||
DAYS_BEFORE_CLOSE: 7
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
|
||||
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
|
||||
stale-issue-label: "stale"
|
||||
close-issue-message: |
|
||||
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
|
||||
|
||||
Feel free to reopen if you still need this!
|
||||
stale-issue-message: |
|
||||
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
|
||||
|
||||
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
|
||||
remove-stale-when-updated: true
|
||||
exempt-issue-labels: "pinned,security,feature-request,on-hold"
|
||||
start-date: "2025-12-27"
|
||||
16
bun.lock
16
bun.lock
@@ -329,7 +329,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -358,7 +358,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.3.2",
|
||||
"gitlab-ai-provider": "5.3.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -383,7 +383,6 @@
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"ws": "8.18.0",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
"zod": "catalog:",
|
||||
@@ -411,7 +410,6 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -1327,7 +1325,7 @@
|
||||
|
||||
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="],
|
||||
|
||||
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
||||
|
||||
@@ -2891,7 +2889,7 @@
|
||||
|
||||
"express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
|
||||
|
||||
"expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="],
|
||||
|
||||
@@ -3039,7 +3037,7 @@
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
|
||||
"gitlab-ai-provider": ["gitlab-ai-provider@5.3.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-k0kRUoAhDvoRC28hQW4sPp+A3cfpT5c/oL9Ng10S0oBiF2Tci1AtsX1iclJM5Os8C1nIIAXBW8LMr0GY7rwcGA=="],
|
||||
|
||||
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
|
||||
|
||||
@@ -5131,6 +5129,8 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.1", "", {}, "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
@@ -6313,6 +6313,8 @@
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
@@ -496,7 +496,6 @@ async function subscribeSessionEvents() {
|
||||
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
todoread: ["Todo", "\x1b[33m\x1b[1m"],
|
||||
bash: ["Bash", "\x1b[31m\x1b[1m"],
|
||||
edit: ["Edit", "\x1b[32m\x1b[1m"],
|
||||
glob: ["Glob", "\x1b[34m\x1b[1m"],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-MmN2+NfHeLPDClpLPOlCAZTmwI94M6XgNAqXrW5Ls4I=",
|
||||
"aarch64-linux": "sha256-whVIlmDvoMmEMUY2Yxx2vAmFDuKQic6ChY1V+9gLd84=",
|
||||
"aarch64-darwin": "sha256-TulGiC24w3usk26hKr3PyccatvIfmAlHgEJaOTUf3pQ=",
|
||||
"x86_64-darwin": "sha256-T8NWm0bBybJKThRdp/jQdxilv1Ec9SF1iVT3udSoZOg="
|
||||
"x86_64-linux": "sha256-l3k/1fRIAQkj7zdVj2Ad3QZWeTOf1CuIM6vgMHRaK1s=",
|
||||
"aarch64-linux": "sha256-iN3YtrKAUTK1GIwVMoVYkMXhtDZOiP7sSJ+Z8v4B5xw=",
|
||||
"aarch64-darwin": "sha256-FFedoiPyfHGdzQnITz1SRV7xv2XoT9vzxIDp4EcVdkU=",
|
||||
"x86_64-darwin": "sha256-0HiHkhJiN73UixUq5CC6YP6DkZzLar8lKnEL1aoiiHg="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,25 @@ describe("command keybind helpers", () => {
|
||||
expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("matchKeybind supports bracket keys", () => {
|
||||
const keybinds = parseKeybind("mod+alt+[, mod+alt+]")
|
||||
const prev = keybinds[0]
|
||||
const next = keybinds[1]
|
||||
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(
|
||||
matchKeybind(
|
||||
keybinds,
|
||||
new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("formatKeybind returns human readable output", () => {
|
||||
const display = formatKeybind("ctrl+alt+arrowup")
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { retry } from "@opencode-ai/util/retry"
|
||||
import { batch } from "solid-js"
|
||||
import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { cmp, normalizeProviderList } from "./utils"
|
||||
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
|
||||
type GlobalStore = {
|
||||
@@ -174,7 +174,7 @@ export async function bootstrapDirectory(input: {
|
||||
seededProject
|
||||
? Promise.resolve()
|
||||
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
@@ -190,7 +190,7 @@ export async function bootstrapDirectory(input: {
|
||||
input.sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? input.store.vcs
|
||||
input.setStore("vcs", next)
|
||||
if (next?.branch) input.vcsCache.setStore("value", next)
|
||||
if (next) input.vcsCache.setStore("value", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
|
||||
|
||||
@@ -494,8 +494,10 @@ describe("applyDirectoryEvent", () => {
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
value: { branch: "main", default_branch: "main" } as State["vcs"],
|
||||
})
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
|
||||
@@ -511,8 +513,8 @@ describe("applyDirectoryEvent", () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.vcs).toEqual({ branch: "feature/test" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test" })
|
||||
expect(store.vcs).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
expect(cacheStore.value).toEqual({ branch: "feature/test", default_branch: "main" })
|
||||
})
|
||||
|
||||
test("routes disposal and lsp events to side-effect handlers", () => {
|
||||
|
||||
@@ -271,9 +271,9 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "vcs.branch.updated": {
|
||||
const props = event.properties as { branch: string }
|
||||
const props = event.properties as { branch?: string }
|
||||
if (input.store.vcs?.branch === props.branch) break
|
||||
const next = { branch: props.branch }
|
||||
const next = { ...input.store.vcs, branch: props.branch }
|
||||
input.setStore("vcs", next)
|
||||
if (input.vcsCache) input.vcsCache.setStore("value", next)
|
||||
break
|
||||
|
||||
35
packages/app/src/context/global-sync/utils.test.ts
Normal file
35
packages/app/src/context/global-sync/utils.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Agent } from "@opencode-ai/sdk/v2/client"
|
||||
import { normalizeAgentList } from "./utils"
|
||||
|
||||
const agent = (name = "build") =>
|
||||
({
|
||||
name,
|
||||
mode: "primary",
|
||||
permission: {},
|
||||
options: {},
|
||||
}) as Agent
|
||||
|
||||
describe("normalizeAgentList", () => {
|
||||
test("keeps array payloads", () => {
|
||||
expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("wraps a single agent payload", () => {
|
||||
expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")])
|
||||
})
|
||||
|
||||
test("extracts agents from keyed objects", () => {
|
||||
expect(
|
||||
normalizeAgentList({
|
||||
build: agent("build"),
|
||||
docs: agent("docs"),
|
||||
}),
|
||||
).toEqual([agent("build"), agent("docs")])
|
||||
})
|
||||
|
||||
test("drops invalid payloads", () => {
|
||||
expect(normalizeAgentList({ name: "AbortError" })).toEqual([])
|
||||
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,21 @@
|
||||
import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
function isAgent(input: unknown): input is Agent {
|
||||
if (!input || typeof input !== "object") return false
|
||||
const item = input as { name?: unknown; mode?: unknown }
|
||||
if (typeof item.name !== "string") return false
|
||||
return item.mode === "subagent" || item.mode === "primary" || item.mode === "all"
|
||||
}
|
||||
|
||||
export function normalizeAgentList(input: unknown): Agent[] {
|
||||
if (Array.isArray(input)) return input.filter(isAgent)
|
||||
if (isAgent(input)) return [input]
|
||||
if (!input || typeof input !== "object") return []
|
||||
return Object.values(input).filter(isAgent)
|
||||
}
|
||||
|
||||
export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
|
||||
return {
|
||||
...input,
|
||||
|
||||
@@ -97,15 +97,10 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
|
||||
throw new Error(getRootNotFoundError())
|
||||
}
|
||||
|
||||
const localUrl = () =>
|
||||
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
|
||||
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
|
||||
|
||||
const getCurrentUrl = () => {
|
||||
if (location.hostname.includes("opencode.ai")) return localUrl()
|
||||
if (import.meta.env.DEV) return localUrl()
|
||||
if (isLocalHost()) return localUrl()
|
||||
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
}
|
||||
|
||||
|
||||
@@ -722,8 +722,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
|
||||
"settings.permissions.tool.todoread.title": "قراءة المهام",
|
||||
"settings.permissions.tool.todoread.description": "قراءة قائمة المهام",
|
||||
"settings.permissions.tool.todowrite.title": "كتابة المهام",
|
||||
"settings.permissions.tool.todowrite.description": "تحديث قائمة المهام",
|
||||
"settings.permissions.tool.webfetch.title": "جلب الويب",
|
||||
|
||||
@@ -732,8 +732,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Carregar uma habilidade por nome",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Executar consultas de servidor de linguagem",
|
||||
"settings.permissions.tool.todoread.title": "Ler Tarefas",
|
||||
"settings.permissions.tool.todoread.description": "Ler a lista de tarefas",
|
||||
"settings.permissions.tool.todowrite.title": "Escrever Tarefas",
|
||||
"settings.permissions.tool.todowrite.description": "Atualizar a lista de tarefas",
|
||||
"settings.permissions.tool.webfetch.title": "Buscar Web",
|
||||
|
||||
@@ -806,8 +806,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Učitaj vještinu po nazivu",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Pokreni upite jezičnog servera",
|
||||
"settings.permissions.tool.todoread.title": "Čitanje liste zadataka",
|
||||
"settings.permissions.tool.todoread.description": "Čitanje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.title": "Ažuriranje liste zadataka",
|
||||
"settings.permissions.tool.todowrite.description": "Ažuriraj listu zadataka",
|
||||
"settings.permissions.tool.webfetch.title": "Web preuzimanje",
|
||||
|
||||
@@ -800,8 +800,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Indlæs en færdighed efter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kør sprogserverforespørgsler",
|
||||
"settings.permissions.tool.todoread.title": "Læs To-do",
|
||||
"settings.permissions.tool.todoread.description": "Læs to-do listen",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv To-do",
|
||||
"settings.permissions.tool.todowrite.description": "Opdater to-do listen",
|
||||
"settings.permissions.tool.webfetch.title": "Webhentning",
|
||||
|
||||
@@ -743,8 +743,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Eine Fähigkeit nach Namen laden",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Language-Server-Abfragen ausführen",
|
||||
"settings.permissions.tool.todoread.title": "Todo lesen",
|
||||
"settings.permissions.tool.todoread.description": "Die Todo-Liste lesen",
|
||||
"settings.permissions.tool.todowrite.title": "Todo schreiben",
|
||||
"settings.permissions.tool.todowrite.description": "Die Todo-Liste aktualisieren",
|
||||
"settings.permissions.tool.webfetch.title": "Web-Abruf",
|
||||
|
||||
@@ -535,6 +535,8 @@ export const dict = {
|
||||
"session.review.noVcs.createGit.action": "Create Git repository",
|
||||
"session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable",
|
||||
"session.review.noChanges": "No changes",
|
||||
"session.review.noUncommittedChanges": "No uncommitted changes yet",
|
||||
"session.review.noBranchChanges": "No branch changes yet",
|
||||
|
||||
"session.files.selectToOpen": "Select a file to open",
|
||||
"session.files.all": "All files",
|
||||
@@ -900,8 +902,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Load a skill by name",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Run language server queries",
|
||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
||||
"settings.permissions.tool.todoread.description": "Read the todo list",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Update the todo list",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -813,8 +813,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Cargar una habilidad por nombre",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Ejecutar consultas de servidor de lenguaje",
|
||||
"settings.permissions.tool.todoread.title": "Leer Todo",
|
||||
"settings.permissions.tool.todoread.description": "Leer la lista de tareas",
|
||||
"settings.permissions.tool.todowrite.title": "Escribir Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Actualizar la lista de tareas",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -741,8 +741,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Charger une compétence par son nom",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Exécuter des requêtes de serveur de langage",
|
||||
"settings.permissions.tool.todoread.title": "Lire Todo",
|
||||
"settings.permissions.tool.todoread.description": "Lire la liste de tâches",
|
||||
"settings.permissions.tool.todowrite.title": "Écrire Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Mettre à jour la liste de tâches",
|
||||
"settings.permissions.tool.webfetch.title": "Récupération Web",
|
||||
|
||||
@@ -727,8 +727,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "名前によるスキルの読み込み",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "言語サーバークエリの実行",
|
||||
"settings.permissions.tool.todoread.title": "Todo読み込み",
|
||||
"settings.permissions.tool.todoread.description": "Todoリストの読み込み",
|
||||
"settings.permissions.tool.todowrite.title": "Todo書き込み",
|
||||
"settings.permissions.tool.todowrite.description": "Todoリストの更新",
|
||||
"settings.permissions.tool.webfetch.title": "Web取得",
|
||||
|
||||
@@ -726,8 +726,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "이름으로 기술 로드",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "언어 서버 쿼리 실행",
|
||||
"settings.permissions.tool.todoread.title": "할 일 읽기",
|
||||
"settings.permissions.tool.todoread.description": "할 일 목록 읽기",
|
||||
"settings.permissions.tool.todowrite.title": "할 일 쓰기",
|
||||
"settings.permissions.tool.todowrite.description": "할 일 목록 업데이트",
|
||||
"settings.permissions.tool.webfetch.title": "웹 가져오기",
|
||||
|
||||
@@ -807,8 +807,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Last en ferdighet etter navn",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Kjør språkserverforespørsler",
|
||||
"settings.permissions.tool.todoread.title": "Les gjøremål",
|
||||
"settings.permissions.tool.todoread.description": "Les gjøremålslisten",
|
||||
"settings.permissions.tool.todowrite.title": "Skriv gjøremål",
|
||||
"settings.permissions.tool.todowrite.description": "Oppdater gjøremålslisten",
|
||||
"settings.permissions.tool.webfetch.title": "Webhenting",
|
||||
|
||||
@@ -729,8 +729,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Ładowanie umiejętności według nazwy",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Uruchamianie zapytań serwera językowego",
|
||||
"settings.permissions.tool.todoread.title": "Odczyt Todo",
|
||||
"settings.permissions.tool.todoread.description": "Odczyt listy zadań",
|
||||
"settings.permissions.tool.todowrite.title": "Zapis Todo",
|
||||
"settings.permissions.tool.todowrite.description": "Aktualizacja listy zadań",
|
||||
"settings.permissions.tool.webfetch.title": "Pobieranie z sieci",
|
||||
|
||||
@@ -808,8 +808,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Загрузка навыка по имени",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Запросы к языковому серверу",
|
||||
"settings.permissions.tool.todoread.title": "Todo Read",
|
||||
"settings.permissions.tool.todoread.description": "Чтение списка задач",
|
||||
"settings.permissions.tool.todowrite.title": "Todo Write",
|
||||
"settings.permissions.tool.todowrite.description": "Обновление списка задач",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -796,8 +796,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
|
||||
"settings.permissions.tool.todoread.title": "อ่านรายการงาน",
|
||||
"settings.permissions.tool.todoread.description": "อ่านรายการงาน",
|
||||
"settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
|
||||
"settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
|
||||
"settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
|
||||
|
||||
@@ -816,8 +816,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "Ada göre bir beceri yükle",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "Dil sunucusu sorguları çalıştır",
|
||||
"settings.permissions.tool.todoread.title": "Görev Oku",
|
||||
"settings.permissions.tool.todoread.description": "Görev listesini oku",
|
||||
"settings.permissions.tool.todowrite.title": "Görev Yaz",
|
||||
"settings.permissions.tool.todowrite.description": "Görev listesini güncelle",
|
||||
"settings.permissions.tool.webfetch.title": "Web Getir",
|
||||
|
||||
@@ -795,8 +795,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "按名称加载技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "运行语言服务器查询",
|
||||
"settings.permissions.tool.todoread.title": "读取待办",
|
||||
"settings.permissions.tool.todoread.description": "读取待办列表",
|
||||
"settings.permissions.tool.todowrite.title": "更新待办",
|
||||
"settings.permissions.tool.todowrite.description": "更新待办列表",
|
||||
"settings.permissions.tool.webfetch.title": "网页获取",
|
||||
|
||||
@@ -790,8 +790,6 @@ export const dict = {
|
||||
"settings.permissions.tool.skill.description": "按名稱載入技能",
|
||||
"settings.permissions.tool.lsp.title": "LSP",
|
||||
"settings.permissions.tool.lsp.description": "執行語言伺服器查詢",
|
||||
"settings.permissions.tool.todoread.title": "讀取待辦",
|
||||
"settings.permissions.tool.todoread.description": "讀取待辦清單",
|
||||
"settings.permissions.tool.todowrite.title": "更新待辦",
|
||||
"settings.permissions.tool.todowrite.description": "更新待辦清單",
|
||||
"settings.permissions.tool.webfetch.title": "Web Fetch",
|
||||
|
||||
@@ -965,6 +965,8 @@ export default function Layout(props: ParentProps) {
|
||||
: projects[(index + offset + projects.length) % projects.length]
|
||||
if (!target) return
|
||||
|
||||
// warm up child store to prevent flicker
|
||||
globalSync.child(target.worktree)
|
||||
openProject(target.worktree)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import {
|
||||
@@ -64,6 +64,9 @@ import { formatServerError } from "@/utils/server-errors"
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyFollowups: (FollowupDraft & { id: string })[] = []
|
||||
|
||||
type ChangeMode = "git" | "branch" | "session" | "turn"
|
||||
type VcsMode = "git" | "branch"
|
||||
|
||||
type SessionHistoryWindowInput = {
|
||||
sessionID: () => string | undefined
|
||||
messagesReady: () => boolean
|
||||
@@ -424,15 +427,16 @@ export default function Page() {
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasSessionReview = createMemo(() => sessionCount() > 0)
|
||||
const canReview = createMemo(() => !!params.id)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const tabState = createSessionTabs({
|
||||
tabs,
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -455,6 +459,12 @@ export default function Page() {
|
||||
if (!id) return false
|
||||
return sync.session.history.loading(id)
|
||||
})
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasSessionReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
@@ -508,11 +518,22 @@ export default function Page() {
|
||||
const [store, setStore] = createStore({
|
||||
messageId: undefined as string | undefined,
|
||||
mobileTab: "session" as "session" | "changes",
|
||||
changes: "session" as "session" | "turn",
|
||||
changes: "git" as ChangeMode,
|
||||
newSessionWorktree: "main",
|
||||
deferRender: false,
|
||||
})
|
||||
|
||||
const [vcs, setVcs] = createStore({
|
||||
diff: {
|
||||
git: [] as FileDiff[],
|
||||
branch: [] as FileDiff[],
|
||||
},
|
||||
ready: {
|
||||
git: false,
|
||||
branch: false,
|
||||
},
|
||||
})
|
||||
|
||||
const [followup, setFollowup] = createStore({
|
||||
items: {} as Record<string, (FollowupDraft & { id: string })[] | undefined>,
|
||||
failed: {} as Record<string, string | undefined>,
|
||||
@@ -539,6 +560,68 @@ export default function Page() {
|
||||
let refreshTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
const vcsTask = new Map<VcsMode, Promise<void>>()
|
||||
const vcsRun = new Map<VcsMode, number>()
|
||||
|
||||
const bumpVcs = (mode: VcsMode) => {
|
||||
const next = (vcsRun.get(mode) ?? 0) + 1
|
||||
vcsRun.set(mode, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const resetVcs = (mode?: VcsMode) => {
|
||||
const list = mode ? [mode] : (["git", "branch"] as const)
|
||||
list.forEach((item) => {
|
||||
bumpVcs(item)
|
||||
vcsTask.delete(item)
|
||||
setVcs("diff", item, [])
|
||||
setVcs("ready", item, false)
|
||||
})
|
||||
}
|
||||
|
||||
const loadVcs = (mode: VcsMode, force = false) => {
|
||||
if (sync.project?.vcs !== "git") return Promise.resolve()
|
||||
if (!force && vcs.ready[mode]) return Promise.resolve()
|
||||
|
||||
if (force) {
|
||||
if (vcsTask.has(mode)) bumpVcs(mode)
|
||||
vcsTask.delete(mode)
|
||||
setVcs("ready", mode, false)
|
||||
}
|
||||
|
||||
const current = vcsTask.get(mode)
|
||||
if (current) return current
|
||||
|
||||
const run = bumpVcs(mode)
|
||||
|
||||
const task = sdk.client.vcs
|
||||
.diff({ mode })
|
||||
.then((result) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
setVcs("diff", mode, result.data ?? [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (vcsRun.get(mode) !== run) return
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
setVcs("diff", mode, [])
|
||||
setVcs("ready", mode, true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (vcsTask.get(mode) === task) vcsTask.delete(mode)
|
||||
})
|
||||
|
||||
vcsTask.set(mode, task)
|
||||
return task
|
||||
}
|
||||
|
||||
const refreshVcs = () => {
|
||||
resetVcs()
|
||||
const mode = untrack(vcsMode)
|
||||
if (!mode) return
|
||||
if (!untrack(wantsReview)) return
|
||||
void loadVcs(mode, true)
|
||||
}
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -554,7 +637,42 @@ export default function Page() {
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
|
||||
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
if (sync.project?.vcs === "git") list.push("git")
|
||||
if (
|
||||
sync.project?.vcs === "git" &&
|
||||
sync.data.vcs?.branch &&
|
||||
sync.data.vcs?.default_branch &&
|
||||
sync.data.vcs.branch !== sync.data.vcs.default_branch
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("session", "turn")
|
||||
return list
|
||||
})
|
||||
const vcsMode = createMemo<VcsMode | undefined>(() => {
|
||||
if (store.changes === "git" || store.changes === "branch") return store.changes
|
||||
})
|
||||
const reviewDiffs = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git
|
||||
if (store.changes === "branch") return vcs.diff.branch
|
||||
if (store.changes === "session") return diffs()
|
||||
return turnDiffs()
|
||||
})
|
||||
const reviewCount = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.diff.git.length
|
||||
if (store.changes === "branch") return vcs.diff.branch.length
|
||||
if (store.changes === "session") return sessionCount()
|
||||
return turnDiffs().length
|
||||
})
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const reviewReady = createMemo(() => {
|
||||
if (store.changes === "git") return vcs.ready.git
|
||||
if (store.changes === "branch") return vcs.ready.branch
|
||||
if (store.changes === "session") return !hasSessionReview() || diffsReady()
|
||||
return true
|
||||
})
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
@@ -620,13 +738,7 @@ export default function Page() {
|
||||
scrollToMessage(msgs[targetIndex], "auto")
|
||||
}
|
||||
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
const sessionEmptyKey = createMemo(() => {
|
||||
const project = sync.project
|
||||
if (project && !project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
@@ -748,13 +860,46 @@ export default function Page() {
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setStore("changes", "git")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sdk.directory,
|
||||
() => {
|
||||
resetVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const,
|
||||
(next, prev) => {
|
||||
if (prev === undefined || same(next, prev)) return
|
||||
refreshVcs()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const stopVcs = sdk.event.listen((evt) => {
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
@@ -877,6 +1022,40 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
|
||||
const wantsReview = createMemo(() =>
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const list = changesOptions()
|
||||
if (list.includes(store.changes)) return
|
||||
const next = list[0]
|
||||
if (!next) return
|
||||
setStore("changes", next)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
void loadVcs(mode)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.session_status[params.id ?? ""]?.type,
|
||||
(next, prev) => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
if (!wantsReview()) return
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
void loadVcs(mode, true)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
|
||||
@@ -923,21 +1102,23 @@ export default function Page() {
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
const changesOptions = ["session", "turn"] as const
|
||||
const changesOptionsList = [...changesOptions]
|
||||
|
||||
const changesTitle = () => {
|
||||
if (!hasReview()) {
|
||||
if (!canReview()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = (option: ChangeMode) => {
|
||||
if (option === "git") return language.t("ui.sessionReview.title.git")
|
||||
if (option === "branch") return language.t("ui.sessionReview.title.branch")
|
||||
if (option === "session") return language.t("ui.sessionReview.title")
|
||||
return language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={changesOptionsList}
|
||||
options={changesOptions()}
|
||||
current={store.changes}
|
||||
label={(option) =>
|
||||
option === "session" ? language.t("ui.sessionReview.title") : language.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
label={label}
|
||||
onSelect={(option) => option && setStore("changes", option)}
|
||||
variant="ghost"
|
||||
size="small"
|
||||
@@ -946,20 +1127,34 @@ export default function Page() {
|
||||
)
|
||||
}
|
||||
|
||||
const emptyTurn = () => (
|
||||
const empty = (text: string) => (
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.noChanges")}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{text}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "turn") return emptyTurn()
|
||||
const reviewEmptyText = createMemo(() => {
|
||||
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
|
||||
if (store.changes === "turn") return language.t("session.review.noChanges")
|
||||
return language.t(sessionEmptyKey())
|
||||
})
|
||||
|
||||
if (hasReview() && !diffsReady()) {
|
||||
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
|
||||
if (store.changes === "git" || store.changes === "branch") {
|
||||
if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (store.changes === "turn") {
|
||||
return empty(reviewEmptyText())
|
||||
}
|
||||
|
||||
if (hasSessionReview() && !diffsReady()) {
|
||||
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
|
||||
}
|
||||
|
||||
if (reviewEmptyKey() === "session.review.noVcs") {
|
||||
if (sessionEmptyKey() === "session.review.noVcs") {
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -979,7 +1174,7 @@ export default function Page() {
|
||||
|
||||
return (
|
||||
<div class={input.emptyClass}>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{language.t(reviewEmptyKey())}</div>
|
||||
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1083,7 +1278,7 @@ export default function Page() {
|
||||
const pending = tree.pendingDiff
|
||||
if (!pending) return
|
||||
if (!tree.reviewScroll) return
|
||||
if (!diffsReady()) return
|
||||
if (!reviewReady()) return
|
||||
|
||||
const attempt = (count: number) => {
|
||||
if (tree.pendingDiff !== pending) return
|
||||
@@ -1124,10 +1319,7 @@ export default function Page() {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
|
||||
const wants = isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes"
|
||||
if (!wants) return
|
||||
if (!wantsReview()) return
|
||||
if (sync.data.session_diff[id] !== undefined) return
|
||||
if (sync.status === "loading") return
|
||||
|
||||
@@ -1136,13 +1328,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
[
|
||||
sessionKey(),
|
||||
isDesktop()
|
||||
? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review")
|
||||
: store.mobileTab === "changes",
|
||||
] as const,
|
||||
() => [sessionKey(), wantsReview()] as const,
|
||||
([key, wants]) => {
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
@@ -1828,6 +2014,12 @@ export default function Page() {
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
|
||||
@@ -896,7 +896,8 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
<div
|
||||
role="log"
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
data-slot="session-turn-list"
|
||||
class="flex flex-col items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import type { FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -19,7 +20,6 @@ import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
|
||||
import { FileTabContent } from "@/pages/session/file-tabs"
|
||||
import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
|
||||
@@ -27,6 +27,12 @@ import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => FileDiff[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
@@ -34,12 +40,11 @@ export function SessionSidePanel(props: {
|
||||
size: Sizing
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const sync = useSync()
|
||||
const file = useFile()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const dialog = useDialog()
|
||||
const { params, sessionKey, tabs, view } = useSessionLayout()
|
||||
const { sessionKey, tabs, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
|
||||
@@ -54,24 +59,7 @@ export function SessionSidePanel(props: {
|
||||
})
|
||||
const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
|
||||
const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
|
||||
const hasReview = createMemo(() => reviewCount() > 0)
|
||||
const diffsReady = createMemo(() => {
|
||||
const id = params.id
|
||||
if (!id) return true
|
||||
if (!hasReview()) return true
|
||||
return sync.data.session_diff[id] !== undefined
|
||||
})
|
||||
|
||||
const reviewEmptyKey = createMemo(() => {
|
||||
if (sync.project && !sync.project.vcs) return "session.review.noVcs"
|
||||
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
|
||||
return "session.review.noChanges"
|
||||
})
|
||||
|
||||
const diffFiles = createMemo(() => diffs().map((d) => d.file))
|
||||
const diffFiles = createMemo(() => props.diffs().map((d) => d.file))
|
||||
const kinds = createMemo(() => {
|
||||
const merge = (a: "add" | "del" | "mix" | undefined, b: "add" | "del" | "mix") => {
|
||||
if (!a) return b
|
||||
@@ -82,7 +70,7 @@ export function SessionSidePanel(props: {
|
||||
const normalize = (p: string) => p.replaceAll("\\\\", "/").replace(/\/+$/, "")
|
||||
|
||||
const out = new Map<string, "add" | "del" | "mix">()
|
||||
for (const diff of diffs()) {
|
||||
for (const diff of props.diffs()) {
|
||||
const file = normalize(diff.file)
|
||||
const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
|
||||
|
||||
@@ -136,7 +124,7 @@ export function SessionSidePanel(props: {
|
||||
pathFromTab: file.pathFromTab,
|
||||
normalizeTab,
|
||||
review: reviewTab,
|
||||
hasReview,
|
||||
hasReview: props.canReview,
|
||||
})
|
||||
const contextOpen = tabState.contextOpen
|
||||
const openedTabs = tabState.openedTabs
|
||||
@@ -241,12 +229,12 @@ export function SessionSidePanel(props: {
|
||||
onCleanup(stop)
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={hasReview()}>
|
||||
<div>{reviewCount()}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
<div>{props.reviewCount()}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.Trigger>
|
||||
@@ -303,7 +291,7 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab()}>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
@@ -377,8 +365,10 @@ export function SessionSidePanel(props: {
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{reviewCount()}{" "}
|
||||
{language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")}
|
||||
{props.reviewCount()}{" "}
|
||||
{language.t(
|
||||
props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other",
|
||||
)}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
|
||||
{language.t("session.files.all")}
|
||||
@@ -386,9 +376,9 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="changes" class="bg-background-stronger px-3 py-0">
|
||||
<Switch>
|
||||
<Match when={hasReview()}>
|
||||
<Match when={props.hasReview() || !props.diffsReady()}>
|
||||
<Show
|
||||
when={diffsReady()}
|
||||
when={props.diffsReady()}
|
||||
fallback={
|
||||
<div class="px-2 py-2 text-12-regular text-text-weak">
|
||||
{language.t("common.loading")}
|
||||
@@ -407,11 +397,7 @@ export function SessionSidePanel(props: {
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
{empty(
|
||||
language.t(sync.project && !sync.project.vcs ? "session.review.noChanges" : reviewEmptyKey()),
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>{empty(props.empty())}</Match>
|
||||
</Switch>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="all" class="bg-background-stronger px-3 py-0">
|
||||
|
||||
@@ -56,11 +56,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
if (!id) return
|
||||
return sync.session.get(id)
|
||||
}
|
||||
const hasReview = () => {
|
||||
const id = params.id
|
||||
if (!id) return false
|
||||
return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
|
||||
}
|
||||
const hasReview = () => !!params.id
|
||||
const normalizeTab = (tab: string) => {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
return file.tab(tab)
|
||||
@@ -333,7 +329,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "message.previous",
|
||||
title: language.t("command.message.previous"),
|
||||
description: language.t("command.message.previous.description"),
|
||||
keybind: "mod+arrowup",
|
||||
keybind: "mod+alt+[",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(-1),
|
||||
}),
|
||||
@@ -341,7 +337,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
id: "message.next",
|
||||
title: language.t("command.message.next"),
|
||||
description: language.t("command.message.next.description"),
|
||||
keybind: "mod+arrowdown",
|
||||
keybind: "mod+alt+]",
|
||||
disabled: !params.id,
|
||||
onSelect: () => navigateMessageByOffset(1),
|
||||
}),
|
||||
|
||||
@@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
"open-directory-picker",
|
||||
async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])],
|
||||
properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"],
|
||||
title: opts?.title ?? "Choose a folder",
|
||||
defaultPath: opts?.defaultPath,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ const cache = new Map<string, Store>()
|
||||
export function getStore(name = SETTINGS_STORE) {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
const next = new Store({ name })
|
||||
const next = new Store({ name, fileExtension: "" })
|
||||
cache.set(name, next)
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -31,12 +31,14 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
- Use `Schema.Defect` instead of `unknown` for defect-like causes.
|
||||
- In `Effect.gen` / `Effect.fn`, prefer `yield* new MyError(...)` over `yield* Effect.fail(new MyError(...))` for direct early-failure branches.
|
||||
|
||||
## Runtime vs Instances
|
||||
## Runtime vs InstanceState
|
||||
|
||||
- Use the shared runtime for process-wide services with one lifecycle for the whole app.
|
||||
- Use `src/effect/instances.ts` for per-directory or per-project services that need `InstanceContext`, per-instance state, or per-instance cleanup.
|
||||
- If two open directories should not share one copy of the service, it belongs in `Instances`.
|
||||
- Instance-scoped services should read context from `InstanceContext`, not `Instance.*` globals.
|
||||
- Use `makeRuntime` (from `src/effect/run-service.ts`) for all services. It returns `{ runPromise, runFork, runCallback }` backed by a shared `memoMap` that deduplicates layers.
|
||||
- Use `InstanceState` (from `src/effect/instance-state.ts`) for per-directory or per-project state that needs per-instance cleanup. It uses `ScopedCache` keyed by directory — each open project gets its own state, automatically cleaned up on disposal.
|
||||
- If two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
- Do the work directly in the `InstanceState.make` closure — `ScopedCache` handles run-once semantics. Don't add fibers, `ensure()` callbacks, or `started` flags on top.
|
||||
- Use `Effect.addFinalizer` or `Effect.acquireRelease` inside the `InstanceState.make` closure for cleanup (subscriptions, process teardown, etc.).
|
||||
- Use `Effect.forkScoped` inside the closure for background stream consumers — the fiber is interrupted when the instance is disposed.
|
||||
|
||||
## Preferred Effect services
|
||||
|
||||
@@ -51,7 +53,7 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish`, `Instance.state()`, or anything that reads `Instance.directory`.
|
||||
Use it for native addon callbacks (`@parcel/watcher`, `node-pty`, native `fs.watch`, etc.) that need to call `Bus.publish` or anything that reads `Instance.directory`.
|
||||
|
||||
You do not need it for `setTimeout`, `Promise.then`, `EventEmitter.on`, or Effect fibers.
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE `event_sequence` (
|
||||
`aggregate_id` text PRIMARY KEY,
|
||||
`seq` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `event` (
|
||||
`id` text PRIMARY KEY,
|
||||
`aggregate_id` text NOT NULL,
|
||||
`seq` integer NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`data` text NOT NULL,
|
||||
CONSTRAINT `fk_event_aggregate_id_event_sequence_aggregate_id_fk` FOREIGN KEY (`aggregate_id`) REFERENCES `event_sequence`(`aggregate_id`) ON DELETE CASCADE
|
||||
);
|
||||
1271
packages/opencode/migration/20260323234822_events/snapshot.json
Normal file
1271
packages/opencode/migration/20260323234822_events/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,6 @@
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/turndown": "5.0.5",
|
||||
"@types/which": "3.0.4",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "17.0.33",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
@@ -93,7 +92,7 @@
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@hono/standard-validator": "0.1.5",
|
||||
"@hono/zod-validator": "catalog:",
|
||||
"@modelcontextprotocol/sdk": "1.25.2",
|
||||
"@modelcontextprotocol/sdk": "1.27.1",
|
||||
"@octokit/graphql": "9.0.2",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@openauthjs/openauth": "catalog:",
|
||||
@@ -122,7 +121,7 @@
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "3.1.0",
|
||||
"gitlab-ai-provider": "5.3.2",
|
||||
"gitlab-ai-provider": "5.3.3",
|
||||
"glob": "13.0.5",
|
||||
"google-auth-library": "10.5.0",
|
||||
"gray-matter": "4.0.3",
|
||||
@@ -146,7 +145,6 @@
|
||||
"ulid": "catalog:",
|
||||
"vscode-jsonrpc": "8.2.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"ws": "8.18.0",
|
||||
"which": "6.0.1",
|
||||
"xdg-basedir": "5.1.0",
|
||||
"yargs": "18.0.0",
|
||||
|
||||
@@ -21,10 +21,14 @@ const modelsData = process.env.MODELS_DEV_API_JSON
|
||||
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
|
||||
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
|
||||
path.join(dir, "src/provider/models-snapshot.js"),
|
||||
`// @ts-nocheck\n// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData}\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.ts")
|
||||
await Bun.write(
|
||||
path.join(dir, "src/provider/models-snapshot.d.ts"),
|
||||
`// Auto-generated by build.ts - do not edit\nexport declare const snapshot: Record<string, unknown>\n`,
|
||||
)
|
||||
console.log("Generated models-snapshot.js")
|
||||
|
||||
// Load migrations from migration directories
|
||||
const migrationDirs = (
|
||||
@@ -59,6 +63,10 @@ console.log(`Loaded ${migrations.length} migrations`)
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const skipUpload = process.argv.includes("--skip-release-upload") || process.env.OPENCODE_SKIP_RELEASE_UPLOAD === "1"
|
||||
const sign = process.env.APPLE_SIGNING_IDENTITY
|
||||
const entitlements = process.env.OPENCODE_CODESIGN_ENTITLEMENTS || path.join(dir, "script/entitlements.plist")
|
||||
const raw = process.env.OPENCODE_BUILD_OS?.trim()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -144,6 +152,31 @@ const targets = singleFlag
|
||||
})
|
||||
: allTargets
|
||||
|
||||
const os = raw
|
||||
? Array.from(
|
||||
new Set(
|
||||
raw
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
|
||||
if (os) {
|
||||
const set = new Set(allTargets.map((item) => item.os))
|
||||
const bad = os.filter((item) => !set.has(item))
|
||||
if (bad.length > 0) {
|
||||
throw new Error(`Invalid OPENCODE_BUILD_OS value: ${bad.join(", ")}`)
|
||||
}
|
||||
}
|
||||
|
||||
const list = os ? targets.filter((item) => os.includes(item.os)) : targets
|
||||
|
||||
if (list.length === 0) {
|
||||
throw new Error("No build targets selected")
|
||||
}
|
||||
|
||||
await $`rm -rf dist`
|
||||
|
||||
const binaries: Record<string, string> = {}
|
||||
@@ -151,7 +184,7 @@ if (!skipInstall) {
|
||||
await $`bun install --os="*" --cpu="*" @opentui/core@${pkg.dependencies["@opentui/core"]}`
|
||||
await $`bun install --os="*" --cpu="*" @parcel/watcher@${pkg.dependencies["@parcel/watcher"]}`
|
||||
}
|
||||
for (const item of targets) {
|
||||
for (const item of list) {
|
||||
const name = [
|
||||
pkg.name,
|
||||
// changing to win32 flags npm for some reason
|
||||
@@ -199,6 +232,24 @@ for (const item of targets) {
|
||||
},
|
||||
})
|
||||
|
||||
if (item.os === "darwin") {
|
||||
if (Script.release && process.platform !== "darwin") {
|
||||
throw new Error("darwin release binaries must be built on macOS runners")
|
||||
}
|
||||
if (Script.release && !sign) {
|
||||
throw new Error("APPLE_SIGNING_IDENTITY is required for darwin release binaries")
|
||||
}
|
||||
if (process.platform === "darwin" && sign) {
|
||||
if (!fs.existsSync(entitlements)) {
|
||||
throw new Error(`Codesign entitlements file not found: ${entitlements}`)
|
||||
}
|
||||
const file = `dist/${name}/bin/opencode`
|
||||
console.log(`codesigning ${name}`)
|
||||
await $`codesign --entitlements ${entitlements} -vvvv --deep --sign ${sign} ${file} --force`
|
||||
await $`codesign -vvv --verify ${file}`
|
||||
}
|
||||
}
|
||||
|
||||
// Smoke test: only run if binary is for current platform
|
||||
if (item.os === process.platform && item.arch === process.arch && !item.abi) {
|
||||
const binaryPath = `dist/${name}/bin/opencode`
|
||||
@@ -236,7 +287,9 @@ if (Script.release) {
|
||||
await $`zip -r ../../${key}.zip *`.cwd(`dist/${key}/bin`)
|
||||
}
|
||||
}
|
||||
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
|
||||
if (!skipUpload) {
|
||||
await $`gh release upload v${Script.version} ./dist/*.zip ./dist/*.tar.gz --clobber --repo ${process.env.GH_REPO}`
|
||||
}
|
||||
}
|
||||
|
||||
export { binaries }
|
||||
|
||||
16
packages/opencode/script/entitlements.plist
Normal file
16
packages/opencode/script/entitlements.plist
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-executable-page-protection</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -6,7 +6,7 @@ Practical reference for new and migrated Effect code in `packages/opencode`.
|
||||
|
||||
Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need per-directory state, per-instance cleanup, or project-bound background work. InstanceState uses a `ScopedCache` keyed by directory, so each open project gets its own copy of the state that is automatically cleaned up on disposal.
|
||||
|
||||
Use `makeRunPromise` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`.
|
||||
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||
|
||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||
@@ -46,7 +46,7 @@ export namespace Foo {
|
||||
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
|
||||
|
||||
// Per-service runtime (inside the namespace)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// Async facade functions
|
||||
export async function get(id: FooID) {
|
||||
@@ -79,22 +79,24 @@ See `Auth.ZodInfo` for the canonical example.
|
||||
|
||||
The `InstanceState.make` init callback receives a `Scope`, so you can use `Effect.acquireRelease`, `Effect.addFinalizer`, and `Effect.forkScoped` inside it. Resources acquired this way are automatically cleaned up when the instance is disposed or invalidated by `ScopedCache`. This makes it the right place for:
|
||||
|
||||
- **Subscriptions**: Use `Effect.acquireRelease` to subscribe and auto-unsubscribe:
|
||||
- **Subscriptions**: Yield `Bus.Service` at the layer level, then use `Stream` + `forkScoped` inside the init closure. The fiber is automatically interrupted when the instance scope closes:
|
||||
|
||||
```ts
|
||||
const bus = yield * Bus.Service
|
||||
|
||||
const cache =
|
||||
yield *
|
||||
InstanceState.make<State>(
|
||||
Effect.fn("Foo.state")(function* (ctx) {
|
||||
// ... load state ...
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribeAll((event) => {
|
||||
yield* bus.subscribeAll().pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.sync(() => {
|
||||
/* handle */
|
||||
}),
|
||||
),
|
||||
(unsub) => Effect.sync(unsub),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -104,6 +106,16 @@ const cache =
|
||||
)
|
||||
```
|
||||
|
||||
- **Resource cleanup**: Use `Effect.acquireRelease` or `Effect.addFinalizer` for resources that need teardown (native watchers, process handles, etc.):
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => nativeAddon.watch(dir)),
|
||||
(watcher) => Effect.sync(() => watcher.close()),
|
||||
)
|
||||
```
|
||||
|
||||
- **Background fibers**: Use `Effect.forkScoped` — the fiber is interrupted on disposal.
|
||||
- **Side effects at init**: Config notification, event wiring, etc. all belong in the init closure. Callers just do `InstanceState.get(cache)` to trigger everything, and `ScopedCache` deduplicates automatically.
|
||||
|
||||
@@ -164,8 +176,8 @@ Still open and likely worth migrating:
|
||||
- [x] `Plugin`
|
||||
- [x] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [ ] `Worktree`
|
||||
- [ ] `Bus`
|
||||
- [x] `Worktree`
|
||||
- [x] `Bus`
|
||||
- [x] `Command`
|
||||
- [ ] `Config`
|
||||
- [ ] `Session`
|
||||
@@ -175,4 +187,4 @@ Still open and likely worth migrating:
|
||||
- [ ] `Provider`
|
||||
- [x] `Project`
|
||||
- [ ] `LSP`
|
||||
- [ ] `MCP`
|
||||
- [x] `MCP`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { AccountRepo, type AccountRow } from "./repo"
|
||||
import {
|
||||
@@ -379,7 +379,7 @@ export namespace Account {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
export const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function active(): Promise<Info | undefined> {
|
||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } f
|
||||
export type AccountRow = (typeof AccountTable)["$inferSelect"]
|
||||
|
||||
type DbClient = Parameters<typeof Database.use>[0] extends (db: infer T) => unknown ? T : never
|
||||
type DbTransactionCallback<A> = Parameters<typeof Database.transaction<A>>[0]
|
||||
|
||||
const ACCOUNT_STATE_ID = 1
|
||||
|
||||
@@ -42,13 +43,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
|
||||
Effect.gen(function* () {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
const query = <A>(f: (db: DbClient) => A) =>
|
||||
const query = <A>(f: DbTransactionCallback<A>) =>
|
||||
Effect.try({
|
||||
try: () => Database.use(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
})
|
||||
|
||||
const tx = <A>(f: (db: DbClient) => A) =>
|
||||
const tx = <A>(f: DbTransactionCallback<A>) =>
|
||||
Effect.try({
|
||||
try: () => Database.transaction(f),
|
||||
catch: (cause) => new AccountRepoError({ message: "Database operation failed", cause }),
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
import { Effect, ServiceMap, Layer } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -148,7 +148,6 @@ export namespace Agent {
|
||||
permission: Permission.merge(
|
||||
defaults,
|
||||
Permission.fromConfig({
|
||||
todoread: "deny",
|
||||
todowrite: "deny",
|
||||
}),
|
||||
user,
|
||||
@@ -394,7 +393,7 @@ export namespace Agent {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(agent: string) {
|
||||
return runPromise((svc) => svc.get(agent))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "path"
|
||||
import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { zod } from "@/util/effect-zod"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -95,7 +95,7 @@ export namespace Auth {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function get(providerID: string) {
|
||||
return runPromise((service) => service.get(providerID))
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import z from "zod"
|
||||
import type { ZodType } from "zod"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
export namespace BusEvent {
|
||||
const log = Log.create({ service: "event" })
|
||||
|
||||
export type Definition = ReturnType<typeof define>
|
||||
|
||||
const registry = new Map<string, Definition>()
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import z from "zod"
|
||||
import { Effect, Exit, Layer, PubSub, Scope, ServiceMap, Stream } from "effect"
|
||||
import { Log } from "../util/log"
|
||||
import { Instance } from "../project/instance"
|
||||
import { BusEvent } from "./bus-event"
|
||||
import { GlobalBus } from "./global"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Bus {
|
||||
const log = Log.create({ service: "bus" })
|
||||
type Subscription = (event: any) => void
|
||||
|
||||
export const InstanceDisposed = BusEvent.define(
|
||||
"server.instance.disposed",
|
||||
@@ -15,91 +17,168 @@ export namespace Bus {
|
||||
}),
|
||||
)
|
||||
|
||||
const state = Instance.state(
|
||||
() => {
|
||||
const subscriptions = new Map<any, Subscription[]>()
|
||||
type Payload<D extends BusEvent.Definition = BusEvent.Definition> = {
|
||||
type: D["type"]
|
||||
properties: z.infer<D["properties"]>
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
type State = {
|
||||
wildcard: PubSub.PubSub<Payload>
|
||||
typed: Map<string, PubSub.PubSub<Payload>>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
properties: z.output<D["properties"]>,
|
||||
) => Effect.Effect<void>
|
||||
readonly subscribe: <D extends BusEvent.Definition>(def: D) => Stream.Stream<Payload<D>>
|
||||
readonly subscribeAll: () => Stream.Stream<Payload>
|
||||
readonly subscribeCallback: <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
callback: (event: Payload<D>) => unknown,
|
||||
) => Effect.Effect<() => void>
|
||||
readonly subscribeAllCallback: (callback: (event: any) => unknown) => Effect.Effect<() => void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
Effect.fn("Bus.state")(function* (ctx) {
|
||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.gen(function* () {
|
||||
// Publish InstanceDisposed before shutting down so subscribers see it
|
||||
yield* PubSub.publish(wildcard, {
|
||||
type: InstanceDisposed.type,
|
||||
properties: { directory: ctx.directory },
|
||||
})
|
||||
yield* PubSub.shutdown(wildcard)
|
||||
for (const ps of typed.values()) {
|
||||
yield* PubSub.shutdown(ps)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return { wildcard, typed }
|
||||
}),
|
||||
)
|
||||
|
||||
function getOrCreate<D extends BusEvent.Definition>(state: State, def: D) {
|
||||
return Effect.gen(function* () {
|
||||
let ps = state.typed.get(def.type)
|
||||
if (!ps) {
|
||||
ps = yield* PubSub.unbounded<Payload>()
|
||||
state.typed.set(def.type, ps)
|
||||
}
|
||||
return ps as unknown as PubSub.PubSub<Payload<D>>
|
||||
})
|
||||
}
|
||||
},
|
||||
async (entry) => {
|
||||
const wildcard = entry.subscriptions.get("*")
|
||||
if (!wildcard) return
|
||||
const event = {
|
||||
type: InstanceDisposed.type,
|
||||
properties: {
|
||||
directory: Instance.directory,
|
||||
},
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = state.typed.get(def.type)
|
||||
if (ps) yield* PubSub.publish(ps, payload)
|
||||
yield* PubSub.publish(state.wildcard, payload)
|
||||
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
}
|
||||
for (const sub of [...wildcard]) {
|
||||
sub(event)
|
||||
|
||||
function subscribe<D extends BusEvent.Definition>(def: D): Stream.Stream<Payload<D>> {
|
||||
log.info("subscribing", { type: def.type })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const ps = yield* getOrCreate(state, def)
|
||||
return Stream.fromPubSub(ps)
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||
}
|
||||
},
|
||||
|
||||
function subscribeAll(): Stream.Stream<Payload> {
|
||||
log.info("subscribing", { type: "*" })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return Stream.fromPubSub(state.wildcard)
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
||||
}
|
||||
|
||||
function on<T>(pubsub: PubSub.PubSub<T>, type: string, callback: (event: T) => unknown) {
|
||||
return Effect.gen(function* () {
|
||||
log.info("subscribing", { type })
|
||||
const scope = yield* Scope.make()
|
||||
const subscription = yield* Scope.provide(scope)(PubSub.subscribe(pubsub))
|
||||
|
||||
yield* Scope.provide(scope)(
|
||||
Stream.fromSubscription(subscription).pipe(
|
||||
Stream.runForEach((msg) =>
|
||||
Effect.tryPromise({
|
||||
try: () => Promise.resolve().then(() => callback(msg)),
|
||||
catch: (cause) => {
|
||||
log.error("subscriber failed", { type, cause })
|
||||
},
|
||||
}).pipe(Effect.ignore),
|
||||
),
|
||||
Effect.forkScoped,
|
||||
),
|
||||
)
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
Effect.runFork(Scope.close(scope, Exit.void))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const subscribeCallback = Effect.fn("Bus.subscribeCallback")(function* <D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
callback: (event: Payload<D>) => unknown,
|
||||
) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const ps = yield* getOrCreate(state, def)
|
||||
return yield* on(ps, def.type, callback)
|
||||
})
|
||||
|
||||
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return yield* on(state.wildcard, "*", callback)
|
||||
})
|
||||
|
||||
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||
}),
|
||||
)
|
||||
|
||||
export async function publish<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
properties: z.output<Definition["properties"]>,
|
||||
const { runPromise, runSync } = makeRuntime(Service, layer)
|
||||
|
||||
// runSync is safe here because the subscribe chain (InstanceState.get, PubSub.subscribe,
|
||||
// Scope.make, Effect.forkScoped) is entirely synchronous. If any step becomes async, this will throw.
|
||||
export async function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return runPromise((svc) => svc.publish(def, properties))
|
||||
}
|
||||
|
||||
export function subscribe<D extends BusEvent.Definition>(
|
||||
def: D,
|
||||
callback: (event: { type: D["type"]; properties: z.infer<D["properties"]> }) => unknown,
|
||||
) {
|
||||
const payload = {
|
||||
type: def.type,
|
||||
properties,
|
||||
}
|
||||
log.info("publishing", {
|
||||
type: def.type,
|
||||
})
|
||||
const pending = []
|
||||
for (const key of [def.type, "*"]) {
|
||||
const match = [...(state().subscriptions.get(key) ?? [])]
|
||||
for (const sub of match) {
|
||||
pending.push(sub(payload))
|
||||
}
|
||||
}
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
payload,
|
||||
})
|
||||
return Promise.all(pending)
|
||||
return runSync((svc) => svc.subscribeCallback(def, callback))
|
||||
}
|
||||
|
||||
export function subscribe<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
callback: (event: { type: Definition["type"]; properties: z.infer<Definition["properties"]> }) => void,
|
||||
) {
|
||||
return raw(def.type, callback)
|
||||
}
|
||||
|
||||
export function once<Definition extends BusEvent.Definition>(
|
||||
def: Definition,
|
||||
callback: (event: {
|
||||
type: Definition["type"]
|
||||
properties: z.infer<Definition["properties"]>
|
||||
}) => "done" | undefined,
|
||||
) {
|
||||
const unsub = subscribe(def, (event) => {
|
||||
if (callback(event)) unsub()
|
||||
})
|
||||
}
|
||||
|
||||
export function subscribeAll(callback: (event: any) => void) {
|
||||
return raw("*", callback)
|
||||
}
|
||||
|
||||
function raw(type: string, callback: (event: any) => void) {
|
||||
log.info("subscribing", { type })
|
||||
const subscriptions = state().subscriptions
|
||||
let match = subscriptions.get(type) ?? []
|
||||
match.push(callback)
|
||||
subscriptions.set(type, match)
|
||||
|
||||
return () => {
|
||||
log.info("unsubscribing", { type })
|
||||
const match = subscriptions.get(type)
|
||||
if (!match) return
|
||||
const index = match.indexOf(callback)
|
||||
if (index === -1) return
|
||||
match.splice(index, 1)
|
||||
}
|
||||
export function subscribeAll(callback: (event: any) => unknown) {
|
||||
return runSync((svc) => svc.subscribeAllCallback(callback))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,19 +14,7 @@ import type { Argv } from "yargs"
|
||||
|
||||
type AgentMode = "all" | "primary" | "subagent"
|
||||
|
||||
const AVAILABLE_TOOLS = [
|
||||
"bash",
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"list",
|
||||
"glob",
|
||||
"grep",
|
||||
"webfetch",
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
]
|
||||
const AVAILABLE_TOOLS = ["bash", "read", "write", "edit", "list", "glob", "grep", "webfetch", "task", "todowrite"]
|
||||
|
||||
const AgentCreateCommand = cmd({
|
||||
command: "create",
|
||||
|
||||
@@ -28,9 +28,9 @@ import { Provider } from "../../provider/provider"
|
||||
import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { Git } from "@/git"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -257,7 +257,7 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
// Get repo info
|
||||
const info = (await git(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const info = (await Git.run(["remote", "get-url", "origin"], { cwd: Instance.worktree })).text().trim()
|
||||
const parsed = parseGitHubRemote(info)
|
||||
if (!parsed) {
|
||||
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
|
||||
@@ -496,20 +496,20 @@ export const GithubRunCommand = cmd({
|
||||
: "issue"
|
||||
: undefined
|
||||
const gitText = async (args: string[]) => {
|
||||
const result = await git(args, { cwd: Instance.worktree })
|
||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result.text().trim()
|
||||
}
|
||||
const gitRun = async (args: string[]) => {
|
||||
const result = await git(args, { cwd: Instance.worktree })
|
||||
const result = await Git.run(args, { cwd: Instance.worktree })
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Process.RunFailedError(["git", ...args], result.exitCode, result.stdout, result.stderr)
|
||||
}
|
||||
return result
|
||||
}
|
||||
const gitStatus = (args: string[]) => git(args, { cwd: Instance.worktree })
|
||||
const gitStatus = (args: string[]) => Git.run(args, { cwd: Instance.worktree })
|
||||
const commitChanges = async (summary: string, actor?: string) => {
|
||||
const args = ["commit", "-m", summary]
|
||||
if (actor) args.push("-m", `Co-authored-by: ${actor} <${actor}@users.noreply.github.com>`)
|
||||
@@ -869,7 +869,6 @@ export const GithubRunCommand = cmd({
|
||||
function subscribeSessionEvents() {
|
||||
const TOOL: Record<string, [string, string]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
@@ -890,7 +889,7 @@ export const GithubRunCommand = cmd({
|
||||
}
|
||||
|
||||
let text = ""
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, async (evt) => {
|
||||
Bus.subscribe(MessageV2.Event.PartUpdated, (evt) => {
|
||||
if (evt.properties.part.sessionID !== session.id) return
|
||||
//if (evt.properties.part.messageID === messageID) return
|
||||
const part = evt.properties.part
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Process } from "@/util/process"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export const PrCommand = cmd({
|
||||
command: "pr <number>",
|
||||
@@ -67,9 +67,9 @@ export const PrCommand = cmd({
|
||||
const remoteName = forkOwner
|
||||
|
||||
// Check if remote already exists
|
||||
const remotes = (await git(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
const remotes = (await Git.run(["remote"], { cwd: Instance.worktree })).text().trim()
|
||||
if (!remotes.split("\n").includes(remoteName)) {
|
||||
await git(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
await Git.run(["remote", "add", remoteName, `https://github.com/${forkOwner}/${forkName}.git`], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
UI.println(`Added fork remote: ${remoteName}`)
|
||||
@@ -77,7 +77,7 @@ export const PrCommand = cmd({
|
||||
|
||||
// Set upstream to the fork so pushes go there
|
||||
const headRefName = prInfo.headRefName
|
||||
await git(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
await Git.run(["branch", `--set-upstream-to=${remoteName}/${headRefName}`, localBranchName], {
|
||||
cwd: Instance.worktree,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -710,7 +710,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
})
|
||||
})
|
||||
|
||||
sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
|
||||
sdk.event.on("session.deleted", (evt) => {
|
||||
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
|
||||
route.navigate({ type: "home" })
|
||||
toast.show({
|
||||
@@ -720,7 +720,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
}
|
||||
})
|
||||
|
||||
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
|
||||
sdk.event.on("session.error", (evt) => {
|
||||
const error = evt.properties.error
|
||||
if (error && typeof error === "object" && error.name === "MessageAbortedError") return
|
||||
const message = (() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
@@ -173,7 +173,7 @@ export namespace Command {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((svc) => svc.get(name))
|
||||
|
||||
@@ -673,7 +673,6 @@ export namespace Config {
|
||||
task: PermissionRule.optional(),
|
||||
external_directory: PermissionRule.optional(),
|
||||
todowrite: PermissionAction.optional(),
|
||||
todoread: PermissionAction.optional(),
|
||||
question: PermissionAction.optional(),
|
||||
webfetch: PermissionAction.optional(),
|
||||
websearch: PermissionAction.optional(),
|
||||
|
||||
@@ -22,12 +22,11 @@ export const WorktreeAdaptor: Adaptor = {
|
||||
},
|
||||
async create(info) {
|
||||
const config = Config.parse(info)
|
||||
const bootstrap = await Worktree.createFromInfo({
|
||||
await Worktree.createFromInfo({
|
||||
name: config.name,
|
||||
directory: config.directory,
|
||||
branch: config.branch,
|
||||
})
|
||||
return bootstrap()
|
||||
},
|
||||
async remove(info) {
|
||||
const config = Config.parse(info)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ServiceMap } from "effect"
|
||||
import type { Project } from "@/project/project"
|
||||
|
||||
export declare namespace InstanceContext {
|
||||
export interface Shape {
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
readonly project: Project.Info
|
||||
}
|
||||
}
|
||||
|
||||
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
|
||||
"opencode/InstanceContext",
|
||||
) {}
|
||||
@@ -3,11 +3,15 @@ import * as ServiceMap from "effect/ServiceMap"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
export function makeRunPromise<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
||||
|
||||
return <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => {
|
||||
rt ??= ManagedRuntime.make(layer, { memoMap })
|
||||
return rt.runPromise(service.use(fn), options)
|
||||
return {
|
||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||
getRuntime().runPromise(service.use(fn), options),
|
||||
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Git } from "@/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fs from "fs"
|
||||
import fuzzysort from "fuzzysort"
|
||||
@@ -323,7 +323,6 @@ export namespace File {
|
||||
|
||||
interface State {
|
||||
cache: Entry
|
||||
fiber: Fiber.Fiber<void> | undefined
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -348,7 +347,6 @@ export namespace File {
|
||||
Effect.fn("File.state")(() =>
|
||||
Effect.succeed({
|
||||
cache: { files: [], dirs: [] } as Entry,
|
||||
fiber: undefined as Fiber.Fiber<void> | undefined,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -406,21 +404,11 @@ export namespace File {
|
||||
s.cache = next
|
||||
})
|
||||
|
||||
const scope = yield* Scope.Scope
|
||||
let cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||
|
||||
const ensure = Effect.fn("File.ensure")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
if (!s.fiber)
|
||||
s.fiber = yield* scan().pipe(
|
||||
Effect.catchCause(() => Effect.void),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
s.fiber = undefined
|
||||
}),
|
||||
),
|
||||
Effect.forkIn(scope),
|
||||
)
|
||||
yield* Fiber.join(s.fiber)
|
||||
yield* cachedScan
|
||||
cachedScan = yield* Effect.cached(scan().pipe(Effect.catchCause(() => Effect.void)))
|
||||
})
|
||||
|
||||
const init = Effect.fn("File.init")(function* () {
|
||||
@@ -432,7 +420,7 @@ export namespace File {
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const diffOutput = (
|
||||
await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
await Git.run(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
@@ -452,7 +440,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
const untrackedOutput = (
|
||||
await git(
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
@@ -485,7 +473,7 @@ export namespace File {
|
||||
}
|
||||
|
||||
const deletedOutput = (
|
||||
await git(
|
||||
await Git.run(
|
||||
[
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
@@ -576,17 +564,17 @@ export namespace File {
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
let diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: Instance.directory })
|
||||
).text()
|
||||
if (!diff.trim()) {
|
||||
diff = (
|
||||
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
await Git.run(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
|
||||
cwd: Instance.directory,
|
||||
})
|
||||
).text()
|
||||
}
|
||||
if (diff.trim()) {
|
||||
const original = (await git(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const original = (await Git.run(["show", `HEAD:${file}`], { cwd: Instance.directory })).text()
|
||||
const patch = structuredPatch(file, file, original, content, "old", "new", {
|
||||
context: Infinity,
|
||||
ignoreWhitespace: true,
|
||||
@@ -688,7 +676,7 @@ export namespace File {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import type { SessionID } from "@/session/schema"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
@@ -108,7 +108,7 @@ export namespace FileTime {
|
||||
}),
|
||||
).pipe(Layer.orDie)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export function read(sessionID: SessionID, file: string) {
|
||||
return runPromise((s) => s.read(sessionID, file))
|
||||
|
||||
@@ -8,10 +8,10 @@ import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Git } from "@/git"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { git } from "@/util/git"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Config } from "../config/config"
|
||||
import { FileIgnore } from "./ignore"
|
||||
@@ -130,7 +130,7 @@ export namespace FileWatcher {
|
||||
|
||||
if (Instance.project.vcs === "git") {
|
||||
const result = yield* Effect.promise(() =>
|
||||
git(["rev-parse", "--git-dir"], {
|
||||
Git.run(["rev-parse", "--git-dir"], {
|
||||
cwd: Instance.project.worktree,
|
||||
}),
|
||||
)
|
||||
@@ -159,7 +159,7 @@ export namespace FileWatcher {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import path from "path"
|
||||
import { mergeDeep } from "remeda"
|
||||
import z from "zod"
|
||||
import { Bus } from "../bus"
|
||||
import { Config } from "../config/config"
|
||||
import { File } from "../file"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Process } from "../util/process"
|
||||
import { Log } from "../util/log"
|
||||
@@ -29,6 +27,7 @@ export namespace Format {
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly file: (filepath: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
|
||||
@@ -97,53 +96,46 @@ export namespace Format {
|
||||
return checks.filter((x) => x.enabled).map((x) => x.item)
|
||||
}
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
File.Event.Edited,
|
||||
Instance.bind(async (payload) => {
|
||||
const file = payload.properties.file
|
||||
log.info("formatting", { file })
|
||||
const ext = path.extname(file)
|
||||
async function formatFile(filepath: string) {
|
||||
log.info("formatting", { file: filepath })
|
||||
const ext = path.extname(filepath)
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", filepath)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file: filepath,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of await getFormatter(ext)) {
|
||||
log.info("running", { command: item.command })
|
||||
try {
|
||||
const proc = Process.spawn(
|
||||
item.command.map((x) => x.replace("$FILE", file)),
|
||||
{
|
||||
cwd: Instance.directory,
|
||||
env: { ...process.env, ...item.environment },
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
},
|
||||
)
|
||||
const exit = await proc.exited
|
||||
if (exit !== 0) {
|
||||
log.error("failed", {
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("failed to format file", {
|
||||
error,
|
||||
command: item.command,
|
||||
...item.environment,
|
||||
file,
|
||||
})
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
)
|
||||
log.info("init")
|
||||
|
||||
return {
|
||||
formatters,
|
||||
isEnabled,
|
||||
formatFile,
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -166,11 +158,16 @@ export namespace Format {
|
||||
return result
|
||||
})
|
||||
|
||||
return Service.of({ init, status })
|
||||
const file = Effect.fn("Format.file")(function* (filepath: string) {
|
||||
const { formatFile } = yield* InstanceState.get(state)
|
||||
yield* Effect.promise(() => formatFile(filepath))
|
||||
})
|
||||
|
||||
return Service.of({ init, status, file })
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((s) => s.init())
|
||||
@@ -179,4 +176,8 @@ export namespace Format {
|
||||
export async function status() {
|
||||
return runPromise((s) => s.status())
|
||||
}
|
||||
|
||||
export async function file(filepath: string) {
|
||||
return runPromise((s) => s.file(filepath))
|
||||
}
|
||||
}
|
||||
|
||||
308
packages/opencode/src/git/index.ts
Normal file
308
packages/opencode/src/git/index.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Git {
|
||||
const cfg = [
|
||||
"--no-optional-locks",
|
||||
"-c",
|
||||
"core.autocrlf=false",
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.longpaths=true",
|
||||
"-c",
|
||||
"core.symlinks=true",
|
||||
"-c",
|
||||
"core.quotepath=false",
|
||||
] as const
|
||||
|
||||
const out = (result: { text(): string }) => result.text().trim()
|
||||
const nuls = (text: string) => text.split("\0").filter(Boolean)
|
||||
const fail = (err: unknown) =>
|
||||
({
|
||||
exitCode: 1,
|
||||
text: () => "",
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.from(err instanceof Error ? err.message : String(err)),
|
||||
}) satisfies Result
|
||||
|
||||
export type Kind = "added" | "deleted" | "modified"
|
||||
|
||||
export type Base = {
|
||||
readonly name: string
|
||||
readonly ref: string
|
||||
}
|
||||
|
||||
export type Item = {
|
||||
readonly file: string
|
||||
readonly code: string
|
||||
readonly status: Kind
|
||||
}
|
||||
|
||||
export type Stat = {
|
||||
readonly file: string
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
readonly exitCode: number
|
||||
readonly text: () => string
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
readonly cwd: string
|
||||
readonly env?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (args: string[], opts: Options) => Effect.Effect<Result>
|
||||
readonly branch: (cwd: string) => Effect.Effect<string | undefined>
|
||||
readonly prefix: (cwd: string) => Effect.Effect<string>
|
||||
readonly defaultBranch: (cwd: string) => Effect.Effect<Base | undefined>
|
||||
readonly hasHead: (cwd: string) => Effect.Effect<boolean>
|
||||
readonly mergeBase: (cwd: string, base: string, head?: string) => Effect.Effect<string | undefined>
|
||||
readonly show: (cwd: string, ref: string, file: string, prefix?: string) => Effect.Effect<string>
|
||||
readonly status: (cwd: string) => Effect.Effect<Item[]>
|
||||
readonly diff: (cwd: string, ref: string) => Effect.Effect<Item[]>
|
||||
readonly stats: (cwd: string, ref: string) => Effect.Effect<Stat[]>
|
||||
}
|
||||
|
||||
const kind = (code: string): Kind => {
|
||||
if (code === "??") return "added"
|
||||
if (code.includes("U")) return "modified"
|
||||
if (code.includes("A") && !code.includes("D")) return "added"
|
||||
if (code.includes("D") && !code.includes("A")) return "deleted"
|
||||
return "modified"
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Git") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
const run = Effect.fn("Git.run")(
|
||||
function* (args: string[], opts: Options) {
|
||||
const proc = ChildProcess.make("git", [...cfg, ...args], {
|
||||
cwd: opts.cwd,
|
||||
env: opts.env,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [stdout, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
return {
|
||||
exitCode: yield* handle.exitCode,
|
||||
text: () => stdout,
|
||||
stdout: Buffer.from(stdout),
|
||||
stderr: Buffer.from(stderr),
|
||||
} satisfies Result
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) => Effect.succeed(fail(err))),
|
||||
)
|
||||
|
||||
const text = Effect.fn("Git.text")(function* (args: string[], opts: Options) {
|
||||
return (yield* run(args, opts)).text()
|
||||
})
|
||||
|
||||
const lines = Effect.fn("Git.lines")(function* (args: string[], opts: Options) {
|
||||
return (yield* text(args, opts))
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const refs = Effect.fnUntraced(function* (cwd: string) {
|
||||
return yield* lines(["for-each-ref", "--format=%(refname:short)", "refs/heads"], { cwd })
|
||||
})
|
||||
|
||||
const configured = Effect.fnUntraced(function* (cwd: string, list: string[]) {
|
||||
const result = yield* run(["config", "init.defaultBranch"], { cwd })
|
||||
const name = out(result)
|
||||
if (!name || !list.includes(name)) return
|
||||
return { name, ref: name } satisfies Base
|
||||
})
|
||||
|
||||
const primary = Effect.fnUntraced(function* (cwd: string) {
|
||||
const list = yield* lines(["remote"], { cwd })
|
||||
if (list.includes("origin")) return "origin"
|
||||
if (list.length === 1) return list[0]
|
||||
if (list.includes("upstream")) return "upstream"
|
||||
return list[0]
|
||||
})
|
||||
|
||||
const branch = Effect.fn("Git.branch")(function* (cwd: string) {
|
||||
const result = yield* run(["symbolic-ref", "--quiet", "--short", "HEAD"], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
const text = out(result)
|
||||
return text || undefined
|
||||
})
|
||||
|
||||
const prefix = Effect.fn("Git.prefix")(function* (cwd: string) {
|
||||
const result = yield* run(["rev-parse", "--show-prefix"], { cwd })
|
||||
if (result.exitCode !== 0) return ""
|
||||
return out(result)
|
||||
})
|
||||
|
||||
const defaultBranch = Effect.fn("Git.defaultBranch")(function* (cwd: string) {
|
||||
const remote = yield* primary(cwd)
|
||||
if (remote) {
|
||||
const head = yield* run(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd })
|
||||
if (head.exitCode === 0) {
|
||||
const ref = out(head).replace(/^refs\/remotes\//, "")
|
||||
const name = ref.startsWith(`${remote}/`) ? ref.slice(`${remote}/`.length) : ""
|
||||
if (name) return { name, ref } satisfies Base
|
||||
}
|
||||
}
|
||||
|
||||
const list = yield* refs(cwd)
|
||||
const next = yield* configured(cwd, list)
|
||||
if (next) return next
|
||||
if (list.includes("main")) return { name: "main", ref: "main" } satisfies Base
|
||||
if (list.includes("master")) return { name: "master", ref: "master" } satisfies Base
|
||||
})
|
||||
|
||||
const hasHead = Effect.fn("Git.hasHead")(function* (cwd: string) {
|
||||
const result = yield* run(["rev-parse", "--verify", "HEAD"], { cwd })
|
||||
return result.exitCode === 0
|
||||
})
|
||||
|
||||
const mergeBase = Effect.fn("Git.mergeBase")(function* (cwd: string, base: string, head = "HEAD") {
|
||||
const result = yield* run(["merge-base", base, head], { cwd })
|
||||
if (result.exitCode !== 0) return
|
||||
const text = out(result)
|
||||
return text || undefined
|
||||
})
|
||||
|
||||
const show = Effect.fn("Git.show")(function* (cwd: string, ref: string, file: string, prefix = "") {
|
||||
const target = prefix ? `${prefix}${file}` : file
|
||||
const result = yield* run(["show", `${ref}:${target}`], { cwd })
|
||||
if (result.exitCode !== 0) return ""
|
||||
if (result.stdout.includes(0)) return ""
|
||||
return result.text()
|
||||
})
|
||||
|
||||
const status = Effect.fn("Git.status")(function* (cwd: string) {
|
||||
return nuls(
|
||||
yield* text(["status", "--porcelain=v1", "--untracked-files=all", "--no-renames", "-z", "--", "."], {
|
||||
cwd,
|
||||
}),
|
||||
).flatMap((item) => {
|
||||
const file = item.slice(3)
|
||||
if (!file) return []
|
||||
const code = item.slice(0, 2)
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Git.diff")(function* (cwd: string, ref: string) {
|
||||
const list = nuls(
|
||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--name-status", "-z", ref, "--", "."], { cwd }),
|
||||
)
|
||||
return list.flatMap((code, idx) => {
|
||||
if (idx % 2 !== 0) return []
|
||||
const file = list[idx + 1]
|
||||
if (!code || !file) return []
|
||||
return [{ file, code, status: kind(code) } satisfies Item]
|
||||
})
|
||||
})
|
||||
|
||||
const stats = Effect.fn("Git.stats")(function* (cwd: string, ref: string) {
|
||||
return nuls(
|
||||
yield* text(["diff", "--no-ext-diff", "--no-renames", "--numstat", "-z", ref, "--", "."], { cwd }),
|
||||
).flatMap((item) => {
|
||||
const a = item.indexOf("\t")
|
||||
const b = item.indexOf("\t", a + 1)
|
||||
if (a === -1 || b === -1) return []
|
||||
const file = item.slice(b + 1)
|
||||
if (!file) return []
|
||||
const adds = item.slice(0, a)
|
||||
const dels = item.slice(a + 1, b)
|
||||
const additions = adds === "-" ? 0 : Number.parseInt(adds || "0", 10)
|
||||
const deletions = dels === "-" ? 0 : Number.parseInt(dels || "0", 10)
|
||||
return [
|
||||
{
|
||||
file,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
} satisfies Stat,
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
run,
|
||||
branch,
|
||||
prefix,
|
||||
defaultBranch,
|
||||
hasHead,
|
||||
mergeBase,
|
||||
show,
|
||||
status,
|
||||
diff,
|
||||
stats,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function run(args: string[], opts: Options) {
|
||||
return runPromise((git) => git.run(args, opts))
|
||||
}
|
||||
|
||||
export function branch(cwd: string) {
|
||||
return runPromise((git) => git.branch(cwd))
|
||||
}
|
||||
|
||||
export function prefix(cwd: string) {
|
||||
return runPromise((git) => git.prefix(cwd))
|
||||
}
|
||||
|
||||
export function defaultBranch(cwd: string) {
|
||||
return runPromise((git) => git.defaultBranch(cwd))
|
||||
}
|
||||
|
||||
export function hasHead(cwd: string) {
|
||||
return runPromise((git) => git.hasHead(cwd))
|
||||
}
|
||||
|
||||
export function mergeBase(cwd: string, base: string, head?: string) {
|
||||
return runPromise((git) => git.mergeBase(cwd, base, head))
|
||||
}
|
||||
|
||||
export function show(cwd: string, ref: string, file: string, prefix?: string) {
|
||||
return runPromise((git) => git.show(cwd, ref, file, prefix))
|
||||
}
|
||||
|
||||
export function status(cwd: string) {
|
||||
return runPromise((git) => git.status(cwd))
|
||||
}
|
||||
|
||||
export function diff(cwd: string, ref: string) {
|
||||
return runPromise((git) => git.diff(cwd, ref))
|
||||
}
|
||||
|
||||
export function stats(cwd: string, ref: string) {
|
||||
return runPromise((git) => git.stats(cwd, ref))
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { randomBytes } from "crypto"
|
||||
|
||||
export namespace Identifier {
|
||||
const prefixes = {
|
||||
event: "evt",
|
||||
session: "ses",
|
||||
message: "msg",
|
||||
permission: "per",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
@@ -346,7 +346,7 @@ export namespace Installation {
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function info(): Promise<Info> {
|
||||
return runPromise((svc) => svc.info())
|
||||
|
||||
@@ -177,6 +177,12 @@ export namespace LSP {
|
||||
|
||||
async function getClients(file: string) {
|
||||
const s = await state()
|
||||
|
||||
// Only spawn LSP clients for files within the instance directory
|
||||
if (!Instance.containsPath(file)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "../global"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace McpAuth {
|
||||
export const Tokens = z.object({
|
||||
@@ -25,106 +27,155 @@ export namespace McpAuth {
|
||||
clientInfo: ClientInfo.optional(),
|
||||
codeVerifier: z.string().optional(),
|
||||
oauthState: z.string().optional(),
|
||||
serverUrl: z.string().optional(), // Track the URL these credentials are for
|
||||
serverUrl: z.string().optional(),
|
||||
})
|
||||
export type Entry = z.infer<typeof Entry>
|
||||
|
||||
const filepath = path.join(Global.Path.data, "mcp-auth.json")
|
||||
|
||||
export async function get(mcpName: string): Promise<Entry | undefined> {
|
||||
const data = await all()
|
||||
return data[mcpName]
|
||||
export interface Interface {
|
||||
readonly all: () => Effect.Effect<Record<string, Entry>>
|
||||
readonly get: (mcpName: string) => Effect.Effect<Entry | undefined>
|
||||
readonly getForUrl: (mcpName: string, serverUrl: string) => Effect.Effect<Entry | undefined>
|
||||
readonly set: (mcpName: string, entry: Entry, serverUrl?: string) => Effect.Effect<void>
|
||||
readonly remove: (mcpName: string) => Effect.Effect<void>
|
||||
readonly updateTokens: (mcpName: string, tokens: Tokens, serverUrl?: string) => Effect.Effect<void>
|
||||
readonly updateClientInfo: (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) => Effect.Effect<void>
|
||||
readonly updateCodeVerifier: (mcpName: string, codeVerifier: string) => Effect.Effect<void>
|
||||
readonly clearCodeVerifier: (mcpName: string) => Effect.Effect<void>
|
||||
readonly updateOAuthState: (mcpName: string, oauthState: string) => Effect.Effect<void>
|
||||
readonly getOAuthState: (mcpName: string) => Effect.Effect<string | undefined>
|
||||
readonly clearOAuthState: (mcpName: string) => Effect.Effect<void>
|
||||
readonly isTokenExpired: (mcpName: string) => Effect.Effect<boolean | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auth entry and validate it's for the correct URL.
|
||||
* Returns undefined if URL has changed (credentials are invalid).
|
||||
*/
|
||||
export async function getForUrl(mcpName: string, serverUrl: string): Promise<Entry | undefined> {
|
||||
const entry = await get(mcpName)
|
||||
if (!entry) return undefined
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/McpAuth") {}
|
||||
|
||||
// If no serverUrl is stored, this is from an old version - consider it invalid
|
||||
if (!entry.serverUrl) return undefined
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
|
||||
// If URL has changed, credentials are invalid
|
||||
if (entry.serverUrl !== serverUrl) return undefined
|
||||
const all = Effect.fn("McpAuth.all")(function* () {
|
||||
return yield* fs.readJson(filepath).pipe(
|
||||
Effect.map((data) => data as Record<string, Entry>),
|
||||
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
|
||||
)
|
||||
})
|
||||
|
||||
return entry
|
||||
}
|
||||
const get = Effect.fn("McpAuth.get")(function* (mcpName: string) {
|
||||
const data = yield* all()
|
||||
return data[mcpName]
|
||||
})
|
||||
|
||||
export async function all(): Promise<Record<string, Entry>> {
|
||||
return Filesystem.readJson<Record<string, Entry>>(filepath).catch(() => ({}))
|
||||
}
|
||||
const getForUrl = Effect.fn("McpAuth.getForUrl")(function* (mcpName: string, serverUrl: string) {
|
||||
const entry = yield* get(mcpName)
|
||||
if (!entry) return undefined
|
||||
if (!entry.serverUrl) return undefined
|
||||
if (entry.serverUrl !== serverUrl) return undefined
|
||||
return entry
|
||||
})
|
||||
|
||||
export async function set(mcpName: string, entry: Entry, serverUrl?: string): Promise<void> {
|
||||
const data = await all()
|
||||
// Always update serverUrl if provided
|
||||
if (serverUrl) {
|
||||
entry.serverUrl = serverUrl
|
||||
}
|
||||
await Filesystem.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600)
|
||||
}
|
||||
const set = Effect.fn("McpAuth.set")(function* (mcpName: string, entry: Entry, serverUrl?: string) {
|
||||
const data = yield* all()
|
||||
if (serverUrl) entry.serverUrl = serverUrl
|
||||
yield* fs.writeJson(filepath, { ...data, [mcpName]: entry }, 0o600).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export async function remove(mcpName: string): Promise<void> {
|
||||
const data = await all()
|
||||
delete data[mcpName]
|
||||
await Filesystem.writeJson(filepath, data, 0o600)
|
||||
}
|
||||
const remove = Effect.fn("McpAuth.remove")(function* (mcpName: string) {
|
||||
const data = yield* all()
|
||||
delete data[mcpName]
|
||||
yield* fs.writeJson(filepath, data, 0o600).pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {
|
||||
const entry = (await get(mcpName)) ?? {}
|
||||
entry.tokens = tokens
|
||||
await set(mcpName, entry, serverUrl)
|
||||
}
|
||||
const updateField = <K extends keyof Entry>(field: K, spanName: string) =>
|
||||
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string, value: NonNullable<Entry[K]>, serverUrl?: string) {
|
||||
const entry = (yield* get(mcpName)) ?? {}
|
||||
entry[field] = value
|
||||
yield* set(mcpName, entry, serverUrl)
|
||||
})
|
||||
|
||||
export async function updateClientInfo(mcpName: string, clientInfo: ClientInfo, serverUrl?: string): Promise<void> {
|
||||
const entry = (await get(mcpName)) ?? {}
|
||||
entry.clientInfo = clientInfo
|
||||
await set(mcpName, entry, serverUrl)
|
||||
}
|
||||
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
|
||||
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
|
||||
const entry = yield* get(mcpName)
|
||||
if (entry) {
|
||||
delete entry[field]
|
||||
yield* set(mcpName, entry)
|
||||
}
|
||||
})
|
||||
|
||||
export async function updateCodeVerifier(mcpName: string, codeVerifier: string): Promise<void> {
|
||||
const entry = (await get(mcpName)) ?? {}
|
||||
entry.codeVerifier = codeVerifier
|
||||
await set(mcpName, entry)
|
||||
}
|
||||
const updateTokens = updateField("tokens", "updateTokens")
|
||||
const updateClientInfo = updateField("clientInfo", "updateClientInfo")
|
||||
const updateCodeVerifier = updateField("codeVerifier", "updateCodeVerifier")
|
||||
const updateOAuthState = updateField("oauthState", "updateOAuthState")
|
||||
const clearCodeVerifier = clearField("codeVerifier", "clearCodeVerifier")
|
||||
const clearOAuthState = clearField("oauthState", "clearOAuthState")
|
||||
|
||||
export async function clearCodeVerifier(mcpName: string): Promise<void> {
|
||||
const entry = await get(mcpName)
|
||||
if (entry) {
|
||||
delete entry.codeVerifier
|
||||
await set(mcpName, entry)
|
||||
}
|
||||
}
|
||||
const getOAuthState = Effect.fn("McpAuth.getOAuthState")(function* (mcpName: string) {
|
||||
const entry = yield* get(mcpName)
|
||||
return entry?.oauthState
|
||||
})
|
||||
|
||||
export async function updateOAuthState(mcpName: string, oauthState: string): Promise<void> {
|
||||
const entry = (await get(mcpName)) ?? {}
|
||||
entry.oauthState = oauthState
|
||||
await set(mcpName, entry)
|
||||
}
|
||||
const isTokenExpired = Effect.fn("McpAuth.isTokenExpired")(function* (mcpName: string) {
|
||||
const entry = yield* get(mcpName)
|
||||
if (!entry?.tokens) return null
|
||||
if (!entry.tokens.expiresAt) return false
|
||||
return entry.tokens.expiresAt < Date.now() / 1000
|
||||
})
|
||||
|
||||
export async function getOAuthState(mcpName: string): Promise<string | undefined> {
|
||||
const entry = await get(mcpName)
|
||||
return entry?.oauthState
|
||||
}
|
||||
return Service.of({
|
||||
all,
|
||||
get,
|
||||
getForUrl,
|
||||
set,
|
||||
remove,
|
||||
updateTokens,
|
||||
updateClientInfo,
|
||||
updateCodeVerifier,
|
||||
clearCodeVerifier,
|
||||
updateOAuthState,
|
||||
getOAuthState,
|
||||
clearOAuthState,
|
||||
isTokenExpired,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export async function clearOAuthState(mcpName: string): Promise<void> {
|
||||
const entry = await get(mcpName)
|
||||
if (entry) {
|
||||
delete entry.oauthState
|
||||
await set(mcpName, entry)
|
||||
}
|
||||
}
|
||||
const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||
|
||||
/**
|
||||
* Check if stored tokens are expired.
|
||||
* Returns null if no tokens exist, false if no expiry or not expired, true if expired.
|
||||
*/
|
||||
export async function isTokenExpired(mcpName: string): Promise<boolean | null> {
|
||||
const entry = await get(mcpName)
|
||||
if (!entry?.tokens) return null
|
||||
if (!entry.tokens.expiresAt) return false
|
||||
return entry.tokens.expiresAt < Date.now() / 1000
|
||||
}
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// Async facades for backward compat (used by McpOAuthProvider, CLI)
|
||||
|
||||
export const get = async (mcpName: string) => runPromise((svc) => svc.get(mcpName))
|
||||
|
||||
export const getForUrl = async (mcpName: string, serverUrl: string) =>
|
||||
runPromise((svc) => svc.getForUrl(mcpName, serverUrl))
|
||||
|
||||
export const all = async () => runPromise((svc) => svc.all())
|
||||
|
||||
export const set = async (mcpName: string, entry: Entry, serverUrl?: string) =>
|
||||
runPromise((svc) => svc.set(mcpName, entry, serverUrl))
|
||||
|
||||
export const remove = async (mcpName: string) => runPromise((svc) => svc.remove(mcpName))
|
||||
|
||||
export const updateTokens = async (mcpName: string, tokens: Tokens, serverUrl?: string) =>
|
||||
runPromise((svc) => svc.updateTokens(mcpName, tokens, serverUrl))
|
||||
|
||||
export const updateClientInfo = async (mcpName: string, clientInfo: ClientInfo, serverUrl?: string) =>
|
||||
runPromise((svc) => svc.updateClientInfo(mcpName, clientInfo, serverUrl))
|
||||
|
||||
export const updateCodeVerifier = async (mcpName: string, codeVerifier: string) =>
|
||||
runPromise((svc) => svc.updateCodeVerifier(mcpName, codeVerifier))
|
||||
|
||||
export const clearCodeVerifier = async (mcpName: string) => runPromise((svc) => svc.clearCodeVerifier(mcpName))
|
||||
|
||||
export const updateOAuthState = async (mcpName: string, oauthState: string) =>
|
||||
runPromise((svc) => svc.updateOAuthState(mcpName, oauthState))
|
||||
|
||||
export const getOAuthState = async (mcpName: string) => runPromise((svc) => svc.getOAuthState(mcpName))
|
||||
|
||||
export const clearOAuthState = async (mcpName: string) => runPromise((svc) => svc.clearOAuthState(mcpName))
|
||||
|
||||
export const isTokenExpired = async (mcpName: string) => runPromise((svc) => svc.isTokenExpired(mcpName))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,9 @@ interface PendingAuth {
|
||||
export namespace McpOAuthCallback {
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
const pendingAuths = new Map<string, PendingAuth>()
|
||||
// Reverse index: mcpName → oauthState, so cancelPending(mcpName) can
|
||||
// find the right entry in pendingAuths (which is keyed by oauthState).
|
||||
const mcpNameToState = new Map<string, string>()
|
||||
|
||||
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
@@ -98,6 +101,12 @@ export namespace McpOAuthCallback {
|
||||
const pending = pendingAuths.get(state)!
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
for (const [name, s] of mcpNameToState) {
|
||||
if (s === state) {
|
||||
mcpNameToState.delete(name)
|
||||
break
|
||||
}
|
||||
}
|
||||
pending.reject(new Error(errorMsg))
|
||||
}
|
||||
return new Response(HTML_ERROR(errorMsg), {
|
||||
@@ -126,6 +135,13 @@ export namespace McpOAuthCallback {
|
||||
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(state)
|
||||
// Clean up reverse index
|
||||
for (const [name, s] of mcpNameToState) {
|
||||
if (s === state) {
|
||||
mcpNameToState.delete(name)
|
||||
break
|
||||
}
|
||||
}
|
||||
pending.resolve(code)
|
||||
|
||||
return new Response(HTML_SUCCESS, {
|
||||
@@ -137,11 +153,13 @@ export namespace McpOAuthCallback {
|
||||
log.info("oauth callback server started", { port: OAUTH_CALLBACK_PORT })
|
||||
}
|
||||
|
||||
export function waitForCallback(oauthState: string): Promise<string> {
|
||||
export function waitForCallback(oauthState: string, mcpName?: string): Promise<string> {
|
||||
if (mcpName) mcpNameToState.set(mcpName, oauthState)
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (pendingAuths.has(oauthState)) {
|
||||
pendingAuths.delete(oauthState)
|
||||
if (mcpName) mcpNameToState.delete(mcpName)
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
}, CALLBACK_TIMEOUT_MS)
|
||||
@@ -151,10 +169,14 @@ export namespace McpOAuthCallback {
|
||||
}
|
||||
|
||||
export function cancelPending(mcpName: string): void {
|
||||
const pending = pendingAuths.get(mcpName)
|
||||
// Look up the oauthState for this mcpName via the reverse index
|
||||
const oauthState = mcpNameToState.get(mcpName)
|
||||
const key = oauthState ?? mcpName
|
||||
const pending = pendingAuths.get(key)
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout)
|
||||
pendingAuths.delete(mcpName)
|
||||
pendingAuths.delete(key)
|
||||
mcpNameToState.delete(mcpName)
|
||||
pending.reject(new Error("Authorization cancelled"))
|
||||
}
|
||||
}
|
||||
@@ -184,6 +206,7 @@ export namespace McpOAuthCallback {
|
||||
pending.reject(new Error("OAuth callback server stopped"))
|
||||
}
|
||||
pendingAuths.clear()
|
||||
mcpNameToState.clear()
|
||||
}
|
||||
|
||||
export function isRunning(): boolean {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
@@ -306,7 +306,7 @@ export namespace Permission {
|
||||
return result
|
||||
}
|
||||
|
||||
export const runPromise = makeRunPromise(Service, layer)
|
||||
export const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function ask(input: z.infer<typeof AskInput>) {
|
||||
return runPromise((s) => s.ask(input))
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { Log } from "../../util/log"
|
||||
import { Installation } from "../../installation"
|
||||
import { OAUTH_DUMMY_KEY } from "../../auth"
|
||||
import { Log } from "../util/log"
|
||||
import { Installation } from "../installation"
|
||||
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { createWebSocketFetch } from "./websocket"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -352,18 +351,12 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
||||
}
|
||||
|
||||
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
const ws = createWebSocketFetch()
|
||||
return {
|
||||
auth: {
|
||||
provider: "openai",
|
||||
async loader(getAuth, provider) {
|
||||
const auth = await getAuth()
|
||||
if (auth.type !== "oauth")
|
||||
return {
|
||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||
return ws(requestInput, init)
|
||||
},
|
||||
}
|
||||
if (auth.type !== "oauth") return {}
|
||||
|
||||
// Filter models to only allowed Codex models for OAuth
|
||||
const allowedModels = new Set([
|
||||
@@ -498,7 +491,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
? new URL(CODEX_API_ENDPOINT)
|
||||
: parsed
|
||||
|
||||
return ws(url, {
|
||||
return fetch(url, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
@@ -629,14 +622,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
if (input.model.providerID !== "openai") return
|
||||
output.headers.originator = "opencode"
|
||||
output.headers["User-Agent"] = `opencode/${Installation.VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`
|
||||
|
||||
// TODO: this is kinda hacky, we need to move the transport creation to a later point so it can accept more arguments rather than just relying on whatever is passed to fetch...
|
||||
// distinguish between title gen and actual chat session
|
||||
if (!input.small) {
|
||||
output.headers.session_id = input.sessionID
|
||||
} else {
|
||||
output.headers.session_id = input.sessionID + "_title"
|
||||
}
|
||||
output.headers.session_id = input.sessionID
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,17 @@ import { Config } from "../config/config"
|
||||
import { Bus } from "../bus"
|
||||
import { Log } from "../util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Server } from "../server/server"
|
||||
import { BunProc } from "../bun"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CodexAuthPlugin } from "./openai/codex"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "../session"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { CopilotAuthPlugin } from "./copilot"
|
||||
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace Plugin {
|
||||
const log = Log.create({ service: "plugin" })
|
||||
@@ -53,11 +52,15 @@ export namespace Plugin {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
Effect.fn("Plugin.state")(function* (ctx) {
|
||||
const hooks: Hooks[] = []
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
const { Server } = await import("../server/server")
|
||||
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
directory: ctx.directory,
|
||||
@@ -145,16 +148,16 @@ export namespace Plugin {
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to bus events, clean up when scope is closed
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribeAll(async (input) => {
|
||||
// Subscribe to bus events, fiber interrupted when scope closes
|
||||
yield* bus.subscribeAll().pipe(
|
||||
Stream.runForEach((input) =>
|
||||
Effect.sync(() => {
|
||||
for (const hook of hooks) {
|
||||
hook["event"]?.({ event: input })
|
||||
hook["event"]?.({ event: input as any })
|
||||
}
|
||||
}),
|
||||
),
|
||||
(unsub) => Effect.sync(unsub),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return { hooks }
|
||||
@@ -191,7 +194,8 @@ export namespace Plugin {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function trigger<
|
||||
Name extends TriggerName,
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import WebSocket from "ws"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
const log = Log.create({ service: "plugin.openai.websocket" })
|
||||
|
||||
export interface CreateWebSocketFetchOptions {
|
||||
/**
|
||||
* WebSocket endpoint URL.
|
||||
* @default 'wss://api.openai.com/v1/responses'
|
||||
*/
|
||||
url?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `fetch` function that routes OpenAI Responses API streaming
|
||||
* requests through a persistent WebSocket connection instead of HTTP.
|
||||
*
|
||||
* Non-streaming requests and requests to other endpoints are passed
|
||||
* through to the standard `fetch`.
|
||||
*
|
||||
* The connection is created lazily on the first streaming request and
|
||||
* reused for subsequent ones, which is the main source of latency
|
||||
* savings in multi-step tool-calling workflows.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createOpenAI } from '@ai-sdk/openai';
|
||||
* import { createWebSocketFetch } from 'ai-sdk-openai-websocket-fetch';
|
||||
*
|
||||
* const wsFetch = createWebSocketFetch();
|
||||
* const openai = createOpenAI({ fetch: wsFetch });
|
||||
*
|
||||
* const result = streamText({
|
||||
* model: openai('gpt-4.1-mini'),
|
||||
* prompt: 'Hello!',
|
||||
* onFinish: () => wsFetch.close(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createWebSocketFetch(options?: CreateWebSocketFetchOptions) {
|
||||
let ws: WebSocket | null = null
|
||||
let connecting: Promise<WebSocket> | null = null
|
||||
let busy = false
|
||||
|
||||
function getConnection(url: string, headers: Record<string, string>): Promise<WebSocket> {
|
||||
if (ws?.readyState === WebSocket.OPEN && !busy) {
|
||||
log.debug("reusing websocket", { url })
|
||||
return Promise.resolve(ws)
|
||||
}
|
||||
if (connecting && !busy) return connecting
|
||||
|
||||
connecting = new Promise<WebSocket>((resolve, reject) => {
|
||||
log.debug("connecting websocket", {
|
||||
url,
|
||||
headers: Object.keys(headers).sort().join(","),
|
||||
})
|
||||
const socket = new WebSocket(url, { headers })
|
||||
|
||||
socket.on("open", () => {
|
||||
ws = socket
|
||||
connecting = null
|
||||
log.debug("websocket connected", { url })
|
||||
resolve(socket)
|
||||
})
|
||||
|
||||
socket.on("error", (err) => {
|
||||
log.debug("websocket connect error", {
|
||||
url,
|
||||
error: err.message,
|
||||
})
|
||||
if (connecting) {
|
||||
connecting = null
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("close", () => {
|
||||
log.debug("websocket closed", { url })
|
||||
if (ws === socket) ws = null
|
||||
})
|
||||
})
|
||||
|
||||
return connecting
|
||||
}
|
||||
|
||||
async function websocketFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = input instanceof URL ? input.toString() : typeof input === "string" ? input : input.url
|
||||
|
||||
if (init?.method !== "POST" || !url.endsWith("/responses")) {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = JSON.parse(typeof init.body === "string" ? init.body : "")
|
||||
} catch {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
if (!body.stream) {
|
||||
return globalThis.fetch(input, init)
|
||||
}
|
||||
|
||||
const wsUrl = getWebSocketURL(url, options?.url)
|
||||
const headers = getWebSocketHeaders(init.headers)
|
||||
|
||||
log.debug("intercepting responses request", {
|
||||
url,
|
||||
wsUrl,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
const connection = await getConnection(wsUrl, headers)
|
||||
busy = true
|
||||
|
||||
const { stream: _, ...requestBody } = body
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const responseStream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
function cleanup() {
|
||||
connection.off("message", onMessage)
|
||||
connection.off("error", onError)
|
||||
connection.off("close", onClose)
|
||||
busy = false
|
||||
}
|
||||
|
||||
function onMessage(data: WebSocket.RawData) {
|
||||
const text = data.toString()
|
||||
log.debug("websocket event", { event: pretty(text), url: wsUrl })
|
||||
controller.enqueue(encoder.encode(`data: ${text}\n\n`))
|
||||
|
||||
try {
|
||||
const event = JSON.parse(text)
|
||||
if (event.type === "response.completed" || event.type === "error") {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"))
|
||||
cleanup()
|
||||
controller.close()
|
||||
}
|
||||
} catch {
|
||||
// non-JSON frame, continue
|
||||
}
|
||||
}
|
||||
|
||||
function onError(err: Error) {
|
||||
log.debug("websocket stream error", { url: wsUrl, error: err.message })
|
||||
cleanup()
|
||||
controller.error(err)
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
log.debug("websocket stream close", { url: wsUrl })
|
||||
cleanup()
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
}
|
||||
|
||||
connection.on("message", onMessage)
|
||||
connection.on("error", onError)
|
||||
connection.on("close", onClose)
|
||||
|
||||
if (init?.signal) {
|
||||
if (init.signal.aborted) {
|
||||
cleanup()
|
||||
controller.error(init.signal.reason ?? new DOMException("Aborted", "AbortError"))
|
||||
return
|
||||
}
|
||||
init.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
cleanup()
|
||||
try {
|
||||
controller.error(init!.signal!.reason ?? new DOMException("Aborted", "AbortError"))
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
}
|
||||
|
||||
connection.send(JSON.stringify({ type: "response.create", ...requestBody }))
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(responseStream, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
return Object.assign(websocketFetch, {
|
||||
/** Close the underlying WebSocket connection. */
|
||||
close() {
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (!headers) return result
|
||||
|
||||
if (headers instanceof Headers) {
|
||||
headers.forEach((v, k) => {
|
||||
result[k.toLowerCase()] = v
|
||||
})
|
||||
} else if (Array.isArray(headers)) {
|
||||
for (const [k, v] of headers) {
|
||||
result[k.toLowerCase()] = v
|
||||
}
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (v != null) result[k.toLowerCase()] = v
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function getWebSocketHeaders(headers: HeadersInit | undefined) {
|
||||
const result = normalizeHeaders(headers)
|
||||
delete result["content-length"]
|
||||
result["openai-beta"] ??= "responses_websockets=2026-02-06"
|
||||
return result
|
||||
}
|
||||
|
||||
function getWebSocketURL(url: string, fallback?: string) {
|
||||
if (fallback) return fallback
|
||||
|
||||
const parsed = new URL(url)
|
||||
parsed.protocol = parsed.protocol === "https:" ? "wss:" : "ws:"
|
||||
return parsed.toString()
|
||||
}
|
||||
|
||||
function pretty(text: string) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch {
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { ProjectID } from "./schema"
|
||||
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -462,7 +462,7 @@ export namespace Project {
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Promise-based API (delegates to Effect service via runPromise)
|
||||
|
||||
@@ -1,17 +1,111 @@
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Git } from "@/git"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { Log } from "@/util/log"
|
||||
import { git } from "@/util/git"
|
||||
import { Instance } from "./instance"
|
||||
import z from "zod"
|
||||
|
||||
export namespace Vcs {
|
||||
const log = Log.create({ service: "vcs" })
|
||||
|
||||
const count = (text: string) => {
|
||||
if (!text) return 0
|
||||
if (!text.endsWith("\n")) return text.split("\n").length
|
||||
return text.slice(0, -1).split("\n").length
|
||||
}
|
||||
|
||||
const work = Effect.fnUntraced(function* (fs: AppFileSystem.Interface, cwd: string, file: string) {
|
||||
const full = path.join(cwd, file)
|
||||
if (!(yield* fs.exists(full).pipe(Effect.orDie))) return ""
|
||||
const buf = yield* fs.readFile(full).pipe(Effect.catch(() => Effect.succeed(new Uint8Array())))
|
||||
if (Buffer.from(buf).includes(0)) return ""
|
||||
return Buffer.from(buf).toString("utf8")
|
||||
})
|
||||
|
||||
const nums = (list: Git.Stat[]) =>
|
||||
new Map(list.map((item) => [item.file, { additions: item.additions, deletions: item.deletions }] as const))
|
||||
|
||||
const merge = (...lists: Git.Item[][]) => {
|
||||
const out = new Map<string, Git.Item>()
|
||||
lists.flat().forEach((item) => {
|
||||
if (!out.has(item.file)) out.set(item.file, item)
|
||||
})
|
||||
return [...out.values()]
|
||||
}
|
||||
|
||||
const files = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
list: Git.Item[],
|
||||
map: Map<string, { additions: number; deletions: number }>,
|
||||
) {
|
||||
const base = ref ? yield* git.prefix(cwd) : ""
|
||||
const next = yield* Effect.forEach(
|
||||
list,
|
||||
(item) =>
|
||||
Effect.gen(function* () {
|
||||
const before = item.status === "added" || !ref ? "" : yield* git.show(cwd, ref, item.file, base)
|
||||
const after = item.status === "deleted" ? "" : yield* work(fs, cwd, item.file)
|
||||
const stat = map.get(item.file)
|
||||
return {
|
||||
file: item.file,
|
||||
before,
|
||||
after,
|
||||
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
|
||||
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
|
||||
status: item.status,
|
||||
} satisfies Snapshot.FileDiff
|
||||
}),
|
||||
{ concurrency: 8 },
|
||||
)
|
||||
return next.toSorted((a, b) => a.file.localeCompare(b.file))
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string | undefined,
|
||||
) {
|
||||
if (!ref) return yield* files(fs, git, cwd, ref, yield* git.status(cwd), new Map())
|
||||
const [list, stats] = yield* Effect.all([git.status(cwd), git.stats(cwd, ref)], { concurrency: 2 })
|
||||
return yield* files(fs, git, cwd, ref, list, nums(stats))
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (
|
||||
fs: AppFileSystem.Interface,
|
||||
git: Git.Interface,
|
||||
cwd: string,
|
||||
ref: string,
|
||||
) {
|
||||
const [list, stats, extra] = yield* Effect.all([git.diff(cwd, ref), git.stats(cwd, ref), git.status(cwd)], {
|
||||
concurrency: 3,
|
||||
})
|
||||
return yield* files(
|
||||
fs,
|
||||
git,
|
||||
cwd,
|
||||
ref,
|
||||
merge(
|
||||
list,
|
||||
extra.filter((item) => item.code === "??"),
|
||||
),
|
||||
nums(stats),
|
||||
)
|
||||
})
|
||||
|
||||
export const Mode = z.enum(["git", "branch"])
|
||||
export type Mode = z.infer<typeof Mode>
|
||||
|
||||
export const Event = {
|
||||
BranchUpdated: BusEvent.define(
|
||||
"vcs.branch.updated",
|
||||
@@ -23,7 +117,8 @@ export namespace Vcs {
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
branch: z.string(),
|
||||
branch: z.string().optional(),
|
||||
default_branch: z.string().optional(),
|
||||
})
|
||||
.meta({
|
||||
ref: "VcsInfo",
|
||||
@@ -33,54 +128,50 @@ export namespace Vcs {
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly branch: () => Effect.Effect<string | undefined>
|
||||
readonly defaultBranch: () => Effect.Effect<string | undefined>
|
||||
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
|
||||
}
|
||||
|
||||
interface State {
|
||||
current: string | undefined
|
||||
root: Git.Base | undefined
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Git.Service | Bus.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const git = yield* Git.Service
|
||||
const bus = yield* Bus.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Vcs.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
if (ctx.project.vcs !== "git") {
|
||||
return { current: undefined }
|
||||
return { current: undefined, root: undefined }
|
||||
}
|
||||
|
||||
const getCurrentBranch = async () => {
|
||||
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
if (result.exitCode !== 0) return undefined
|
||||
const text = result.text().trim()
|
||||
return text || undefined
|
||||
}
|
||||
const get = () => Effect.runPromise(git.branch(ctx.directory))
|
||||
const [current, root] = yield* Effect.all([git.branch(ctx.directory), git.defaultBranch(ctx.directory)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
const value = { current, root }
|
||||
log.info("initialized", { branch: value.current, default_branch: value.root?.name })
|
||||
|
||||
const value = {
|
||||
current: yield* Effect.promise(() => getCurrentBranch()),
|
||||
}
|
||||
log.info("initialized", { branch: value.current })
|
||||
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bus.subscribe(
|
||||
FileWatcher.Event.Updated,
|
||||
Instance.bind(async (evt) => {
|
||||
if (!evt.properties.file.endsWith("HEAD")) return
|
||||
const next = await getCurrentBranch()
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
Bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
yield* bus.subscribe(FileWatcher.Event.Updated).pipe(
|
||||
Stream.filter((evt) => evt.properties.file.endsWith("HEAD")),
|
||||
Stream.runForEach((_evt) =>
|
||||
Effect.gen(function* () {
|
||||
const next = yield* Effect.promise(() => get())
|
||||
if (next !== value.current) {
|
||||
log.info("branch changed", { from: value.current, to: next })
|
||||
value.current = next
|
||||
yield* bus.publish(Event.BranchUpdated, { branch: next })
|
||||
}
|
||||
}),
|
||||
),
|
||||
(unsubscribe) => Effect.sync(unsubscribe),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return value
|
||||
@@ -95,11 +186,38 @@ export namespace Vcs {
|
||||
branch: Effect.fn("Vcs.branch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.current)
|
||||
}),
|
||||
defaultBranch: Effect.fn("Vcs.defaultBranch")(function* () {
|
||||
return yield* InstanceState.use(state, (x) => x.root?.name)
|
||||
}),
|
||||
diff: Effect.fn("Vcs.diff")(function* (mode: Mode) {
|
||||
const value = yield* InstanceState.get(state)
|
||||
if (Instance.project.vcs !== "git") return []
|
||||
if (mode === "git") {
|
||||
return yield* track(
|
||||
fs,
|
||||
git,
|
||||
Instance.directory,
|
||||
(yield* git.hasHead(Instance.directory)) ? "HEAD" : undefined,
|
||||
)
|
||||
}
|
||||
|
||||
if (!value.root) return []
|
||||
if (value.current && value.current === value.root.name) return []
|
||||
const ref = yield* git.mergeBase(Instance.directory, value.root.ref)
|
||||
if (!ref) return []
|
||||
return yield* compare(fs, git, Instance.directory, ref)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
@@ -108,4 +226,12 @@ export namespace Vcs {
|
||||
export function branch() {
|
||||
return runPromise((svc) => svc.branch())
|
||||
}
|
||||
|
||||
export function defaultBranch() {
|
||||
return runPromise((svc) => svc.defaultBranch())
|
||||
}
|
||||
|
||||
export function diff(mode: Mode) {
|
||||
return runPromise((svc) => svc.diff(mode))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AuthOuathResult, Hooks } from "@opencode-ai/plugin"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Auth } from "@/auth"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ProviderID } from "./schema"
|
||||
import { Array as Arr, Effect, Layer, Record, Result, ServiceMap } from "effect"
|
||||
@@ -231,7 +231,7 @@ export namespace ProviderAuth {
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function methods() {
|
||||
return runPromise((svc) => svc.methods())
|
||||
|
||||
@@ -89,7 +89,7 @@ export namespace ModelsDev {
|
||||
const result = await Filesystem.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).catch(() => {})
|
||||
if (result) return result
|
||||
// @ts-ignore
|
||||
const snapshot = await import("./models-snapshot")
|
||||
const snapshot = await import("./models-snapshot.js")
|
||||
.then((m) => m.snapshot as Record<string, unknown>)
|
||||
.catch(() => undefined)
|
||||
if (snapshot) return snapshot
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { type IPty } from "bun-pty"
|
||||
import z from "zod"
|
||||
@@ -361,7 +361,7 @@ export namespace Pty {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Log } from "@/util/log"
|
||||
import z from "zod"
|
||||
@@ -197,7 +197,7 @@ export namespace Question {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
|
||||
export async function ask(input: {
|
||||
sessionID: SessionID
|
||||
|
||||
28
packages/opencode/src/server/projectors.ts
Normal file
28
packages/opencode/src/server/projectors.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import z from "zod"
|
||||
import sessionProjectors from "../session/projectors"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Session } from "@/session"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { Database, eq } from "@/storage/db"
|
||||
|
||||
export function initProjectors() {
|
||||
SyncEvent.init({
|
||||
projectors: sessionProjectors,
|
||||
convertEvent: (type, data) => {
|
||||
if (type === "session.updated") {
|
||||
const id = (data as z.infer<typeof Session.Event.Updated.schema>).sessionID
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
|
||||
if (!row) return data
|
||||
|
||||
return {
|
||||
sessionID: id,
|
||||
info: Session.fromRow(row),
|
||||
}
|
||||
}
|
||||
return data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
initProjectors()
|
||||
@@ -6,7 +6,6 @@ import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { AsyncQueue } from "../../util/queue"
|
||||
import { Instance } from "@/project/instance"
|
||||
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -53,13 +52,6 @@ export const EventRoutes = lazy(() =>
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
q.push(JSON.stringify(event))
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
@@ -69,6 +61,13 @@ export const EventRoutes = lazy(() =>
|
||||
log.info("event disconnected")
|
||||
}
|
||||
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
q.push(JSON.stringify(event))
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
|
||||
@@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.create.schema),
|
||||
validator("json", Worktree.CreateInput.optional()),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const worktree = await Worktree.create(body)
|
||||
@@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.remove.schema),
|
||||
validator("json", Worktree.RemoveInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.remove(body)
|
||||
@@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator("json", Worktree.reset.schema),
|
||||
validator("json", Worktree.ResetInput),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
await Worktree.reset(body)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Hono } from "hono"
|
||||
import { describeRoute, validator, resolver } from "hono-openapi"
|
||||
import { Hono, type Context } from "hono"
|
||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
import z from "zod"
|
||||
import { Bus } from "../../bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { AsyncQueue } from "@/util/queue"
|
||||
import { Instance } from "../../project/instance"
|
||||
@@ -17,6 +17,56 @@ const log = Log.create({ service: "server" })
|
||||
|
||||
export const GlobalDisposedEvent = BusEvent.define("global.disposed", z.object({}))
|
||||
|
||||
async function streamEvents(c: Context, subscribe: (q: AsyncQueue<string | null>) => () => void) {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
unsub()
|
||||
q.push(null)
|
||||
log.info("global event disconnected")
|
||||
}
|
||||
|
||||
const unsub = subscribe(q)
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const GlobalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.get(
|
||||
@@ -70,55 +120,58 @@ export const GlobalRoutes = lazy(() =>
|
||||
log.info("global event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
const q = new AsyncQueue<string | null>()
|
||||
let done = false
|
||||
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Send heartbeat every 10s to prevent stalled proxy streams.
|
||||
const heartbeat = setInterval(() => {
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
type: "server.heartbeat",
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}, 10_000)
|
||||
|
||||
return streamEvents(c, (q) => {
|
||||
async function handler(event: any) {
|
||||
q.push(JSON.stringify(event))
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
|
||||
const stop = () => {
|
||||
if (done) return
|
||||
done = true
|
||||
clearInterval(heartbeat)
|
||||
GlobalBus.off("event", handler)
|
||||
q.push(null)
|
||||
log.info("event disconnected")
|
||||
}
|
||||
|
||||
stream.onAbort(stop)
|
||||
|
||||
try {
|
||||
for await (const data of q) {
|
||||
if (data === null) return
|
||||
await stream.writeSSE({ data })
|
||||
}
|
||||
} finally {
|
||||
stop()
|
||||
}
|
||||
return () => GlobalBus.off("event", handler)
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/sync-event",
|
||||
describeRoute({
|
||||
summary: "Subscribe to global sync events",
|
||||
description: "Get global sync events",
|
||||
operationId: "global.sync-event.subscribe",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Event stream",
|
||||
content: {
|
||||
"text/event-stream": {
|
||||
schema: resolver(
|
||||
z
|
||||
.object({
|
||||
payload: SyncEvent.payloads(),
|
||||
})
|
||||
.meta({
|
||||
ref: "SyncEvent",
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global sync event connected")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamEvents(c, (q) => {
|
||||
return SyncEvent.subscribeAll(({ def, event }) => {
|
||||
// TODO: don't pass def, just pass the type (and it should
|
||||
// be versioned)
|
||||
q.push(
|
||||
JSON.stringify({
|
||||
payload: {
|
||||
...event,
|
||||
type: SyncEvent.versionedType(def.type, def.version),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -281,14 +281,14 @@ export const SessionRoutes = lazy(() =>
|
||||
const sessionID = c.req.valid("param").sessionID
|
||||
const updates = c.req.valid("json")
|
||||
|
||||
let session = await Session.get(sessionID)
|
||||
if (updates.title !== undefined) {
|
||||
session = await Session.setTitle({ sessionID, title: updates.title })
|
||||
await Session.setTitle({ sessionID, title: updates.title })
|
||||
}
|
||||
if (updates.time?.archived !== undefined) {
|
||||
session = await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||
}
|
||||
|
||||
const session = await Session.get(sessionID)
|
||||
return c.json(session)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { Log } from "../util/log"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
@@ -38,15 +39,22 @@ import { websocket } from "hono/bun"
|
||||
import { HTTPException } from "hono/http-exception"
|
||||
import { errors } from "./error"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { QuestionRoutes } from "./routes/question"
|
||||
import { PermissionRoutes } from "./routes/permission"
|
||||
import { GlobalRoutes } from "./routes/global"
|
||||
import { MDNS } from "./mdns"
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { initProjectors } from "./projectors"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
||||
const csp = (hash = "") =>
|
||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||
|
||||
initProjectors()
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
|
||||
@@ -330,12 +338,40 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const branch = await Vcs.branch()
|
||||
const [branch, default_branch] = await Promise.all([Vcs.branch(), Vcs.defaultBranch()])
|
||||
return c.json({
|
||||
branch,
|
||||
default_branch,
|
||||
})
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/vcs/diff",
|
||||
describeRoute({
|
||||
summary: "Get VCS diff",
|
||||
description: "Retrieve the current git diff for the working tree or against the default branch.",
|
||||
operationId: "vcs.diff",
|
||||
responses: {
|
||||
200: {
|
||||
description: "VCS diff",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(Snapshot.FileDiff.array()),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"query",
|
||||
z.object({
|
||||
mode: Vcs.Mode,
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
return c.json(await Vcs.diff(c.req.valid("query").mode))
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/command",
|
||||
describeRoute({
|
||||
@@ -506,10 +542,13 @@ export namespace Server {
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
response.headers.set(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:",
|
||||
)
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { fn } from "@/util/fn"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Config } from "@/config/config"
|
||||
import { NotFoundError } from "@/storage/db"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
@@ -60,7 +61,11 @@ export namespace SessionCompaction {
|
||||
const config = await Config.get()
|
||||
if (config.compaction?.prune === false) return
|
||||
log.info("pruning")
|
||||
const msgs = await Session.messages({ sessionID: input.sessionID })
|
||||
const msgs = await Session.messages({ sessionID: input.sessionID }).catch((err) => {
|
||||
if (NotFoundError.isInstance(err)) return undefined
|
||||
throw err
|
||||
})
|
||||
if (!msgs) return
|
||||
let total = 0
|
||||
let pruned = 0
|
||||
const toPrune = []
|
||||
|
||||
@@ -9,12 +9,14 @@ import { Config } from "../config/config"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Installation } from "../installation"
|
||||
|
||||
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import { Database, NotFoundError, eq, and, gte, isNull, desc, like, inArray, lt } from "../storage/db"
|
||||
import { SyncEvent } from "../sync"
|
||||
import type { SQL } from "../storage/db"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { SessionTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Log } from "../util/log"
|
||||
import { updateSchema } from "../util/update-schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Instance } from "../project/instance"
|
||||
import { SessionPrompt } from "./prompt"
|
||||
@@ -182,24 +184,40 @@ export namespace Session {
|
||||
export type GlobalInfo = z.output<typeof GlobalInfo>
|
||||
|
||||
export const Event = {
|
||||
Created: BusEvent.define(
|
||||
"session.created",
|
||||
z.object({
|
||||
Created: SyncEvent.define({
|
||||
type: "session.created",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Updated: BusEvent.define(
|
||||
"session.updated",
|
||||
z.object({
|
||||
}),
|
||||
Updated: SyncEvent.define({
|
||||
type: "session.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: updateSchema(Info).extend({
|
||||
share: updateSchema(Info.shape.share.unwrap()).optional(),
|
||||
time: updateSchema(Info.shape.time).optional(),
|
||||
}),
|
||||
}),
|
||||
busSchema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Deleted: BusEvent.define(
|
||||
"session.deleted",
|
||||
z.object({
|
||||
}),
|
||||
Deleted: SyncEvent.define({
|
||||
type: "session.deleted",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Diff: BusEvent.define(
|
||||
"session.diff",
|
||||
z.object({
|
||||
@@ -280,18 +298,8 @@ export namespace Session {
|
||||
)
|
||||
|
||||
export const touch = fn(SessionID.zod, async (sessionID) => {
|
||||
const now = Date.now()
|
||||
Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: now })
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
})
|
||||
const time = Date.now()
|
||||
SyncEvent.run(Event.Updated, { sessionID, info: { time: { updated: time } } })
|
||||
})
|
||||
|
||||
export async function createNext(input: {
|
||||
@@ -318,22 +326,25 @@ export namespace Session {
|
||||
},
|
||||
}
|
||||
log.info("created", result)
|
||||
Database.use((db) => {
|
||||
db.insert(SessionTable).values(toRow(result)).run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(Event.Created, {
|
||||
info: result,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
SyncEvent.run(Event.Created, { sessionID: result.id, info: result })
|
||||
|
||||
const cfg = await Config.get()
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto"))
|
||||
if (!result.parentID && (Flag.OPENCODE_AUTO_SHARE || cfg.share === "auto")) {
|
||||
share(result.id).catch(() => {
|
||||
// Silently ignore sharing errors during session creation
|
||||
})
|
||||
Bus.publish(Event.Updated, {
|
||||
info: result,
|
||||
})
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
|
||||
// This only exist for backwards compatibility. We should not be
|
||||
// manually publishing this event; it is a sync event now
|
||||
Bus.publish(Event.Updated, {
|
||||
sessionID: result.id,
|
||||
info: result,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -357,12 +368,9 @@ export namespace Session {
|
||||
}
|
||||
const { ShareNext } = await import("@/share/share-next")
|
||||
const share = await ShareNext.create(id)
|
||||
Database.use((db) => {
|
||||
const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
})
|
||||
|
||||
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: share.url } } })
|
||||
|
||||
return share
|
||||
})
|
||||
|
||||
@@ -370,12 +378,8 @@ export namespace Session {
|
||||
// Use ShareNext to remove the share (same as share function uses ShareNext to create)
|
||||
const { ShareNext } = await import("@/share/share-next")
|
||||
await ShareNext.remove(id)
|
||||
Database.use((db) => {
|
||||
const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
})
|
||||
|
||||
SyncEvent.run(Event.Updated, { sessionID: id, info: { share: { url: null } } })
|
||||
})
|
||||
|
||||
export const setTitle = fn(
|
||||
@@ -384,18 +388,7 @@ export namespace Session {
|
||||
title: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ title: input.title })
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
})
|
||||
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { title: input.title } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -405,18 +398,7 @@ export namespace Session {
|
||||
time: z.number().optional(),
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ time_archived: input.time })
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
})
|
||||
SyncEvent.run(Event.Updated, { sessionID: input.sessionID, info: { time: { archived: input.time } } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -426,17 +408,9 @@ export namespace Session {
|
||||
permission: Permission.Ruleset,
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({ permission: input.permission, time_updated: Date.now() })
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
SyncEvent.run(Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: { permission: input.permission, time: { updated: Date.now() } },
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -448,42 +422,24 @@ export namespace Session {
|
||||
summary: Info.shape.summary,
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: input.revert ?? null,
|
||||
summary_additions: input.summary?.additions,
|
||||
summary_deletions: input.summary?.deletions,
|
||||
summary_files: input.summary?.files,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
SyncEvent.run(Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
summary: input.summary,
|
||||
time: { updated: Date.now() },
|
||||
revert: input.revert,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const clearRevert = fn(SessionID.zod, async (sessionID) => {
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
revert: null,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
SyncEvent.run(Event.Updated, {
|
||||
sessionID,
|
||||
info: {
|
||||
time: { updated: Date.now() },
|
||||
revert: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -493,22 +449,12 @@ export namespace Session {
|
||||
summary: Info.shape.summary,
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use((db) => {
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
summary_additions: input.summary?.additions,
|
||||
summary_deletions: input.summary?.deletions,
|
||||
summary_files: input.summary?.files,
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.where(eq(SessionTable.id, input.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
|
||||
const info = fromRow(row)
|
||||
Database.effect(() => Bus.publish(Event.Updated, { info }))
|
||||
return info
|
||||
SyncEvent.run(Event.Updated, {
|
||||
sessionID: input.sessionID,
|
||||
info: {
|
||||
time: { updated: Date.now() },
|
||||
summary: input.summary,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -662,46 +608,28 @@ export namespace Session {
|
||||
})
|
||||
|
||||
export const remove = fn(SessionID.zod, async (sessionID) => {
|
||||
const project = Instance.project
|
||||
try {
|
||||
const session = await get(sessionID)
|
||||
for (const child of await children(sessionID)) {
|
||||
await remove(child.id)
|
||||
}
|
||||
await unshare(sessionID).catch(() => {})
|
||||
// CASCADE delete handles messages and parts automatically
|
||||
Database.use((db) => {
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(Event.Deleted, {
|
||||
info: session,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
SyncEvent.run(Event.Deleted, { sessionID, info: session })
|
||||
|
||||
// Eagerly remove event sourcing data to free up space
|
||||
SyncEvent.remove(sessionID)
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
export const updateMessage = fn(MessageV2.Info, async (msg) => {
|
||||
const time_created = msg.time.created
|
||||
const { id, sessionID, ...data } = msg
|
||||
Database.use((db) => {
|
||||
db.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: sessionID,
|
||||
time_created,
|
||||
data,
|
||||
})
|
||||
.onConflictDoUpdate({ target: MessageTable.id, set: { data } })
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.Updated, {
|
||||
info: msg,
|
||||
}),
|
||||
)
|
||||
SyncEvent.run(MessageV2.Event.Updated, {
|
||||
sessionID: msg.sessionID,
|
||||
info: msg,
|
||||
})
|
||||
|
||||
return msg
|
||||
})
|
||||
|
||||
@@ -711,17 +639,9 @@ export namespace Session {
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
async (input) => {
|
||||
// CASCADE delete handles parts automatically
|
||||
Database.use((db) => {
|
||||
db.delete(MessageTable)
|
||||
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.Removed, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
}),
|
||||
)
|
||||
SyncEvent.run(MessageV2.Event.Removed, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
})
|
||||
return input.messageID
|
||||
},
|
||||
@@ -734,17 +654,10 @@ export namespace Session {
|
||||
partID: PartID.zod,
|
||||
}),
|
||||
async (input) => {
|
||||
Database.use((db) => {
|
||||
db.delete(PartTable)
|
||||
.where(and(eq(PartTable.id, input.partID), eq(PartTable.session_id, input.sessionID)))
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.PartRemoved, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
partID: input.partID,
|
||||
}),
|
||||
)
|
||||
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.messageID,
|
||||
partID: input.partID,
|
||||
})
|
||||
return input.partID
|
||||
},
|
||||
@@ -753,24 +666,10 @@ export namespace Session {
|
||||
const UpdatePartInput = MessageV2.Part
|
||||
|
||||
export const updatePart = fn(UpdatePartInput, async (part) => {
|
||||
const { id, messageID, sessionID, ...data } = part
|
||||
const time = Date.now()
|
||||
Database.use((db) => {
|
||||
db.insert(PartTable)
|
||||
.values({
|
||||
id,
|
||||
message_id: messageID,
|
||||
session_id: sessionID,
|
||||
time_created: time,
|
||||
data,
|
||||
})
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data } })
|
||||
.run()
|
||||
Database.effect(() =>
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part: structuredClone(part),
|
||||
}),
|
||||
)
|
||||
SyncEvent.run(MessageV2.Event.PartUpdated, {
|
||||
sessionID: part.sessionID,
|
||||
part: structuredClone(part),
|
||||
time: Date.now(),
|
||||
})
|
||||
return part
|
||||
})
|
||||
|
||||
@@ -155,7 +155,6 @@ export namespace LLM {
|
||||
model: input.model,
|
||||
provider,
|
||||
message: input.user,
|
||||
small: input.small === true,
|
||||
},
|
||||
{
|
||||
headers: {},
|
||||
|
||||
@@ -6,17 +6,22 @@ import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessag
|
||||
import { LSP } from "../lsp"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { fn } from "@/util/fn"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
|
||||
import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { STATUS_CODES } from "http"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { ProviderError } from "@/provider/error"
|
||||
import { iife } from "@/util/iife"
|
||||
import type { SystemError } from "bun"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
|
||||
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
|
||||
interface FetchDecompressionError extends Error {
|
||||
code: "ZlibError"
|
||||
errno: number
|
||||
path: string
|
||||
}
|
||||
|
||||
export namespace MessageV2 {
|
||||
export function isMedia(mime: string) {
|
||||
return mime.startsWith("image/") || mime === "application/pdf"
|
||||
@@ -449,25 +454,34 @@ export namespace MessageV2 {
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const Event = {
|
||||
Updated: BusEvent.define(
|
||||
"message.updated",
|
||||
z.object({
|
||||
Updated: SyncEvent.define({
|
||||
type: "message.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
info: Info,
|
||||
}),
|
||||
),
|
||||
Removed: BusEvent.define(
|
||||
"message.removed",
|
||||
z.object({
|
||||
}),
|
||||
Removed: SyncEvent.define({
|
||||
type: "message.removed",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
}),
|
||||
),
|
||||
PartUpdated: BusEvent.define(
|
||||
"message.part.updated",
|
||||
z.object({
|
||||
}),
|
||||
PartUpdated: SyncEvent.define({
|
||||
type: "message.part.updated",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
part: Part,
|
||||
time: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
PartDelta: BusEvent.define(
|
||||
"message.part.delta",
|
||||
z.object({
|
||||
@@ -478,14 +492,16 @@ export namespace MessageV2 {
|
||||
delta: z.string(),
|
||||
}),
|
||||
),
|
||||
PartRemoved: BusEvent.define(
|
||||
"message.part.removed",
|
||||
z.object({
|
||||
PartRemoved: SyncEvent.define({
|
||||
type: "message.part.removed",
|
||||
version: 1,
|
||||
aggregate: "sessionID",
|
||||
schema: z.object({
|
||||
sessionID: SessionID.zod,
|
||||
messageID: MessageID.zod,
|
||||
partID: PartID.zod,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithParts = z.object({
|
||||
@@ -897,7 +913,10 @@ export namespace MessageV2 {
|
||||
return result
|
||||
}
|
||||
|
||||
export function fromError(e: unknown, ctx: { providerID: ProviderID }): NonNullable<Assistant["error"]> {
|
||||
export function fromError(
|
||||
e: unknown,
|
||||
ctx: { providerID: ProviderID; aborted?: boolean },
|
||||
): NonNullable<Assistant["error"]> {
|
||||
switch (true) {
|
||||
case e instanceof DOMException && e.name === "AbortError":
|
||||
return new MessageV2.AbortedError(
|
||||
@@ -929,6 +948,21 @@ export namespace MessageV2 {
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError":
|
||||
if (ctx.aborted) {
|
||||
return new MessageV2.AbortedError({ message: e.message }, { cause: e }).toObject()
|
||||
}
|
||||
return new MessageV2.APIError(
|
||||
{
|
||||
message: "Response decompression failed",
|
||||
isRetryable: true,
|
||||
metadata: {
|
||||
code: (e as FetchDecompressionError).code,
|
||||
message: e.message,
|
||||
},
|
||||
},
|
||||
{ cause: e },
|
||||
).toObject()
|
||||
case APICallError.isInstance(e):
|
||||
const parsed = ProviderError.parseAPICallError({
|
||||
providerID: ctx.providerID,
|
||||
|
||||
@@ -356,7 +356,7 @@ export namespace SessionProcessor {
|
||||
error: e,
|
||||
stack: JSON.stringify(e.stack),
|
||||
})
|
||||
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
|
||||
const error = MessageV2.fromError(e, { providerID: input.model.providerID, aborted: input.abort.aborted })
|
||||
if (MessageV2.ContextOverflowError.isInstance(error)) {
|
||||
needsCompaction = true
|
||||
Bus.publish(Session.Event.Error, {
|
||||
|
||||
135
packages/opencode/src/session/projectors.ts
Normal file
135
packages/opencode/src/session/projectors.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { NotFoundError, eq, and } from "../storage/db"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { Session } from "./index"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { SessionTable, MessageTable, PartTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
const log = Log.create({ service: "session.projector" })
|
||||
|
||||
function foreign(err: unknown) {
|
||||
if (typeof err !== "object" || err === null) return false
|
||||
if ("code" in err && err.code === "SQLITE_CONSTRAINT_FOREIGNKEY") return true
|
||||
return "message" in err && typeof err.message === "string" && err.message.includes("FOREIGN KEY constraint failed")
|
||||
}
|
||||
|
||||
export type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> | null } : T
|
||||
|
||||
function grab<T extends object, K1 extends keyof T, X>(
|
||||
obj: T,
|
||||
field1: K1,
|
||||
cb?: (val: NonNullable<T[K1]>) => X,
|
||||
): X | undefined {
|
||||
if (obj == undefined || !(field1 in obj)) return undefined
|
||||
|
||||
const val = obj[field1]
|
||||
if (val && typeof val === "object" && cb) {
|
||||
return cb(val)
|
||||
}
|
||||
if (val === undefined) {
|
||||
throw new Error(
|
||||
"Session update failure: pass `null` to clear a field instead of `undefined`: " + JSON.stringify(obj),
|
||||
)
|
||||
}
|
||||
return val as X | undefined
|
||||
}
|
||||
|
||||
export function toPartialRow(info: DeepPartial<Session.Info>) {
|
||||
const obj = {
|
||||
id: grab(info, "id"),
|
||||
project_id: grab(info, "projectID"),
|
||||
workspace_id: grab(info, "workspaceID"),
|
||||
parent_id: grab(info, "parentID"),
|
||||
slug: grab(info, "slug"),
|
||||
directory: grab(info, "directory"),
|
||||
title: grab(info, "title"),
|
||||
version: grab(info, "version"),
|
||||
share_url: grab(info, "share", (v) => grab(v, "url")),
|
||||
summary_additions: grab(info, "summary", (v) => grab(v, "additions")),
|
||||
summary_deletions: grab(info, "summary", (v) => grab(v, "deletions")),
|
||||
summary_files: grab(info, "summary", (v) => grab(v, "files")),
|
||||
summary_diffs: grab(info, "summary", (v) => grab(v, "diffs")),
|
||||
revert: grab(info, "revert"),
|
||||
permission: grab(info, "permission"),
|
||||
time_created: grab(info, "time", (v) => grab(v, "created")),
|
||||
time_updated: grab(info, "time", (v) => grab(v, "updated")),
|
||||
time_compacting: grab(info, "time", (v) => grab(v, "compacting")),
|
||||
time_archived: grab(info, "time", (v) => grab(v, "archived")),
|
||||
}
|
||||
|
||||
return Object.fromEntries(Object.entries(obj).filter(([_, val]) => val !== undefined))
|
||||
}
|
||||
|
||||
export default [
|
||||
SyncEvent.project(Session.Event.Created, (db, data) => {
|
||||
db.insert(SessionTable).values(Session.toRow(data.info)).run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(Session.Event.Updated, (db, data) => {
|
||||
const info = data.info
|
||||
const row = db
|
||||
.update(SessionTable)
|
||||
.set(toPartialRow(info))
|
||||
.where(eq(SessionTable.id, data.sessionID))
|
||||
.returning()
|
||||
.get()
|
||||
if (!row) throw new NotFoundError({ message: `Session not found: ${data.sessionID}` })
|
||||
}),
|
||||
|
||||
SyncEvent.project(Session.Event.Deleted, (db, data) => {
|
||||
db.delete(SessionTable).where(eq(SessionTable.id, data.sessionID)).run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(MessageV2.Event.Updated, (db, data) => {
|
||||
const time_created = data.info.time.created
|
||||
const { id, sessionID, ...rest } = data.info
|
||||
|
||||
try {
|
||||
db.insert(MessageTable)
|
||||
.values({
|
||||
id,
|
||||
session_id: sessionID,
|
||||
time_created,
|
||||
data: rest,
|
||||
})
|
||||
.onConflictDoUpdate({ target: MessageTable.id, set: { data: rest } })
|
||||
.run()
|
||||
} catch (err) {
|
||||
if (!foreign(err)) throw err
|
||||
log.warn("ignored late message update", { messageID: id, sessionID })
|
||||
}
|
||||
}),
|
||||
|
||||
SyncEvent.project(MessageV2.Event.Removed, (db, data) => {
|
||||
db.delete(MessageTable)
|
||||
.where(and(eq(MessageTable.id, data.messageID), eq(MessageTable.session_id, data.sessionID)))
|
||||
.run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(MessageV2.Event.PartRemoved, (db, data) => {
|
||||
db.delete(PartTable)
|
||||
.where(and(eq(PartTable.id, data.partID), eq(PartTable.session_id, data.sessionID)))
|
||||
.run()
|
||||
}),
|
||||
|
||||
SyncEvent.project(MessageV2.Event.PartUpdated, (db, data) => {
|
||||
const { id, messageID, sessionID, ...rest } = data.part
|
||||
|
||||
try {
|
||||
db.insert(PartTable)
|
||||
.values({
|
||||
id,
|
||||
message_id: messageID,
|
||||
session_id: sessionID,
|
||||
time_created: data.time,
|
||||
data: rest,
|
||||
})
|
||||
.onConflictDoUpdate({ target: PartTable.id, set: { data: rest } })
|
||||
.run()
|
||||
} catch (err) {
|
||||
if (!foreign(err)) throw err
|
||||
log.warn("ignored late part update", { partID: id, messageID, sessionID })
|
||||
}
|
||||
}),
|
||||
]
|
||||
@@ -4,8 +4,7 @@ import { Snapshot } from "../snapshot"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Session } from "."
|
||||
import { Log } from "../util/log"
|
||||
import { Database, eq } from "../storage/db"
|
||||
import { MessageTable, PartTable } from "./session.sql"
|
||||
import { SyncEvent } from "../sync"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { Bus } from "../bus"
|
||||
import { SessionPrompt } from "./prompt"
|
||||
@@ -113,8 +112,10 @@ export namespace SessionRevert {
|
||||
remove.push(msg)
|
||||
}
|
||||
for (const msg of remove) {
|
||||
Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
|
||||
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
|
||||
SyncEvent.run(MessageV2.Event.Removed, {
|
||||
sessionID: sessionID,
|
||||
messageID: msg.info.id,
|
||||
})
|
||||
}
|
||||
if (session.revert.partID && target) {
|
||||
const partID = session.revert.partID
|
||||
@@ -124,8 +125,7 @@ export namespace SessionRevert {
|
||||
const removeParts = target.parts.slice(removeStart)
|
||||
target.parts = preserveParts
|
||||
for (const part of removeParts) {
|
||||
Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
|
||||
await Bus.publish(MessageV2.Event.PartRemoved, {
|
||||
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||
sessionID: sessionID,
|
||||
messageID: target.info.id,
|
||||
partID: part.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { SessionID } from "./schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import z from "zod"
|
||||
@@ -55,6 +55,8 @@ export namespace SessionStatus {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
|
||||
)
|
||||
@@ -70,9 +72,9 @@ export namespace SessionStatus {
|
||||
|
||||
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
|
||||
const data = yield* InstanceState.get(state)
|
||||
yield* Effect.promise(() => Bus.publish(Event.Status, { sessionID, status }))
|
||||
yield* bus.publish(Event.Status, { sessionID, status })
|
||||
if (status.type === "idle") {
|
||||
yield* Effect.promise(() => Bus.publish(Event.Idle, { sessionID }))
|
||||
yield* bus.publish(Event.Idle, { sessionID })
|
||||
data.delete(sessionID)
|
||||
return
|
||||
}
|
||||
@@ -83,7 +85,8 @@ export namespace SessionStatus {
|
||||
}),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, layer)
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(sessionID: SessionID) {
|
||||
return runPromise((svc) => svc.get(sessionID))
|
||||
|
||||
@@ -66,29 +66,28 @@ export namespace ShareNext {
|
||||
export async function init() {
|
||||
if (disabled) return
|
||||
Bus.subscribe(Session.Event.Updated, async (evt) => {
|
||||
await sync(evt.properties.info.id, [
|
||||
const session = await Session.get(evt.properties.sessionID)
|
||||
|
||||
await sync(session.id, [
|
||||
{
|
||||
type: "session",
|
||||
data: evt.properties.info,
|
||||
data: session,
|
||||
},
|
||||
])
|
||||
})
|
||||
Bus.subscribe(MessageV2.Event.Updated, async (evt) => {
|
||||
await sync(evt.properties.info.sessionID, [
|
||||
const info = evt.properties.info
|
||||
await sync(info.sessionID, [
|
||||
{
|
||||
type: "message",
|
||||
data: evt.properties.info,
|
||||
},
|
||||
])
|
||||
if (evt.properties.info.role === "user") {
|
||||
await sync(evt.properties.info.sessionID, [
|
||||
if (info.role === "user") {
|
||||
await sync(info.sessionID, [
|
||||
{
|
||||
type: "model",
|
||||
data: [
|
||||
await Provider.getModel(evt.properties.info.model.providerID, evt.properties.info.model.modelID).then(
|
||||
(m) => m,
|
||||
),
|
||||
],
|
||||
data: [await Provider.getModel(info.model.providerID, info.model.modelID).then((m) => m)],
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import type { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Global } from "@/global"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -54,11 +54,6 @@ export namespace Skill {
|
||||
type State = {
|
||||
skills: Record<string, Info>
|
||||
dirs: Set<string>
|
||||
task?: Promise<void>
|
||||
}
|
||||
|
||||
type Cache = State & {
|
||||
ensure: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -116,66 +111,47 @@ export namespace Skill {
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Migrate to Effect
|
||||
const create = (discovery: Discovery.Interface, directory: string, worktree: string): Cache => {
|
||||
const state: State = {
|
||||
skills: {},
|
||||
dirs: new Set<string>(),
|
||||
async function loadSkills(state: State, discovery: Discovery.Interface, directory: string, worktree: string) {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||
}
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for await (const root of Filesystem.up({
|
||||
targets: EXTERNAL_DIRS,
|
||||
start: directory,
|
||||
stop: worktree,
|
||||
})) {
|
||||
await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||
}
|
||||
const cfg = await Config.get()
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
|
||||
for (const dir of await Config.directories()) {
|
||||
await scan(state, dir, OPENCODE_SKILL_PATTERN)
|
||||
}
|
||||
|
||||
const cfg = await Config.get()
|
||||
for (const item of cfg.skills?.paths ?? []) {
|
||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||
if (!(await Filesystem.isDir(dir))) {
|
||||
log.warn("skill path not found", { path: dir })
|
||||
continue
|
||||
}
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
|
||||
for (const url of cfg.skills?.urls ?? []) {
|
||||
for (const dir of await Effect.runPromise(discovery.pull(url))) {
|
||||
state.dirs.add(dir)
|
||||
await scan(state, dir, SKILL_PATTERN)
|
||||
}
|
||||
}
|
||||
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
|
||||
const ensure = () => {
|
||||
if (state.task) return state.task
|
||||
state.task = load().catch((err) => {
|
||||
state.task = undefined
|
||||
throw err
|
||||
})
|
||||
return state.task
|
||||
}
|
||||
|
||||
return { ...state, ensure }
|
||||
log.info("init", { count: Object.keys(state.skills).length })
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
@@ -185,33 +161,33 @@ export namespace Skill {
|
||||
Effect.gen(function* () {
|
||||
const discovery = yield* Discovery.Service
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Skill.state")((ctx) => Effect.sync(() => create(discovery, ctx.directory, ctx.worktree))),
|
||||
Effect.fn("Skill.state")((ctx) =>
|
||||
Effect.gen(function* () {
|
||||
const s: State = { skills: {}, dirs: new Set() }
|
||||
yield* Effect.promise(() => loadSkills(s, discovery, ctx.directory, ctx.worktree))
|
||||
return s
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const ensure = Effect.fn("Skill.ensure")(function* () {
|
||||
const cache = yield* InstanceState.get(state)
|
||||
yield* Effect.promise(() => cache.ensure())
|
||||
return cache
|
||||
})
|
||||
|
||||
const get = Effect.fn("Skill.get")(function* (name: string) {
|
||||
const cache = yield* ensure()
|
||||
return cache.skills[name]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.skills[name]
|
||||
})
|
||||
|
||||
const all = Effect.fn("Skill.all")(function* () {
|
||||
const cache = yield* ensure()
|
||||
return Object.values(cache.skills)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Object.values(s.skills)
|
||||
})
|
||||
|
||||
const dirs = Effect.fn("Skill.dirs")(function* () {
|
||||
const cache = yield* ensure()
|
||||
return Array.from(cache.dirs)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Array.from(s.dirs)
|
||||
})
|
||||
|
||||
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
||||
const cache = yield* ensure()
|
||||
const list = Object.values(cache.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
const s = yield* InstanceState.get(state)
|
||||
const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
if (!agent) return list
|
||||
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
||||
})
|
||||
@@ -242,7 +218,7 @@ export namespace Skill {
|
||||
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
|
||||
}
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((skill) => skill.get(name))
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
|
||||
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRunPromise } from "@/effect/run-service"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { Hash } from "@/util/hash"
|
||||
import { Config } from "../config/config"
|
||||
import { Global } from "../global"
|
||||
import { Log } from "../util/log"
|
||||
@@ -34,10 +35,10 @@ export namespace Snapshot {
|
||||
|
||||
const log = Log.create({ service: "snapshot" })
|
||||
const prune = "7.days"
|
||||
const limit = 2 * 1024 * 1024
|
||||
const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
|
||||
const cfg = ["-c", "core.autocrlf=false", ...core]
|
||||
const quote = [...cfg, "-c", "core.quotepath=false"]
|
||||
|
||||
interface GitResult {
|
||||
readonly code: ChildProcessSpawner.ExitCode
|
||||
readonly text: string
|
||||
@@ -65,12 +66,23 @@ export namespace Snapshot {
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
const lock = (key: string) => {
|
||||
const hit = locks.get(key)
|
||||
if (hit) return hit
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id),
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
||||
vcs: ctx.project.vcs,
|
||||
}
|
||||
|
||||
@@ -107,6 +119,7 @@ export namespace Snapshot {
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (state.vcs !== "git") return false
|
||||
@@ -123,192 +136,277 @@ export namespace Snapshot {
|
||||
return file
|
||||
})
|
||||
|
||||
const sync = Effect.fnUntraced(function* () {
|
||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(state.gitdir, "info", "exclude")
|
||||
const text = [
|
||||
file ? (yield* read(file)).trimEnd() : "",
|
||||
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||
if (!file) {
|
||||
yield* fs.writeFileString(target, "").pipe(Effect.orDie)
|
||||
return
|
||||
}
|
||||
yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory })
|
||||
const [diff, other] = yield* Effect.all(
|
||||
[
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (diff.code !== 0 || other.code !== 0) {
|
||||
log.warn("failed to list snapshot files", {
|
||||
diffCode: diff.code,
|
||||
diffStderr: diff.stderr,
|
||||
otherCode: other.code,
|
||||
otherStderr: other.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const tracked = diff.text.split("\0").filter(Boolean)
|
||||
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
|
||||
if (!all.length) return
|
||||
|
||||
const large = (yield* Effect.all(
|
||||
all.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(
|
||||
Effect.map((stat) => {
|
||||
if (!stat || stat.type !== "File") return
|
||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||
return size > limit ? item : undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)).filter((item): item is string => Boolean(item))
|
||||
yield* sync(large)
|
||||
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to add snapshot files", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
})
|
||||
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: state.worktree })
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.worktree,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: state.directory },
|
||||
const statuses = yield* git(
|
||||
[
|
||||
...quote,
|
||||
...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
@@ -361,7 +459,7 @@ export namespace Snapshot {
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const runPromise = makeRunPromise(Service, defaultLayer)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -27,16 +27,20 @@ export const NotFoundError = NamedError.create(
|
||||
const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export const Path = iife(() => {
|
||||
if (Flag.OPENCODE_DB) {
|
||||
if (path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||
}
|
||||
export function getChannelPath() {
|
||||
const channel = Installation.CHANNEL
|
||||
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||
return path.join(Global.Path.data, "opencode.db")
|
||||
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||
}
|
||||
|
||||
export const Path = iife(() => {
|
||||
if (Flag.OPENCODE_DB) {
|
||||
if (Flag.OPENCODE_DB === ":memory:" || path.isAbsolute(Flag.OPENCODE_DB)) return Flag.OPENCODE_DB
|
||||
return path.join(Global.Path.data, Flag.OPENCODE_DB)
|
||||
}
|
||||
return getChannelPath()
|
||||
})
|
||||
|
||||
export type Transaction = SQLiteTransaction<"sync", void>
|
||||
@@ -145,17 +149,27 @@ export namespace Database {
|
||||
}
|
||||
}
|
||||
|
||||
export function transaction<T>(callback: (tx: TxOrDb) => T): T {
|
||||
type NotPromise<T> = T extends Promise<any> ? never : T
|
||||
|
||||
export function transaction<T>(
|
||||
callback: (tx: TxOrDb) => NotPromise<T>,
|
||||
options?: {
|
||||
behavior?: "deferred" | "immediate" | "exclusive"
|
||||
},
|
||||
): NotPromise<T> {
|
||||
try {
|
||||
return callback(ctx.use().tx)
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const result = (Client().transaction as any)((tx: TxOrDb) => {
|
||||
return ctx.provide({ tx, effects }, () => callback(tx))
|
||||
})
|
||||
const result = Client().transaction(
|
||||
(tx: TxOrDb) => {
|
||||
return ctx.provide({ tx, effects }, () => callback(tx))
|
||||
},
|
||||
{ behavior: options?.behavior },
|
||||
)
|
||||
for (const effect of effects) effect()
|
||||
return result
|
||||
return result as NotPromise<T>
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { lazy } from "../util/lazy"
|
||||
import { Lock } from "../util/lock"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import { Git } from "@/git"
|
||||
import { Glob } from "../util/glob"
|
||||
import { git } from "@/util/git"
|
||||
|
||||
export namespace Storage {
|
||||
const log = Log.create({ service: "storage" })
|
||||
@@ -49,7 +49,7 @@ export namespace Storage {
|
||||
}
|
||||
if (!worktree) continue
|
||||
if (!(await Filesystem.isDir(worktree))) continue
|
||||
const result = await git(["rev-list", "--max-parents=0", "--all"], {
|
||||
const result = await Git.run(["rev-list", "--max-parents=0", "--all"], {
|
||||
cwd: worktree,
|
||||
})
|
||||
const [id] = result
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user